From cd05f898a7afa68f7e4b8d89608194fa007b8847 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:17:32 -0400 Subject: [PATCH 01/10] fix(tui): prevent stale status/fix-jobs responses from overwriting fresher data (#596) Add loadingStatus and loadingFixJobs in-flight guards matching the existing loadingJobs pattern. When a fetch is already in flight, handleTickMsg skips dispatching another, preventing overlapping requests from racing and overwriting fresher data with stale responses. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/roborev/tui/control_handlers.go | 1 + cmd/roborev/tui/fetch.go | 2 +- cmd/roborev/tui/handlers_msg.go | 41 +++++++++++-- cmd/roborev/tui/handlers_review.go | 1 + cmd/roborev/tui/tui.go | 5 ++ cmd/roborev/tui/tui_test.go | 91 +++++++++++++++++++++++++++++ cmd/roborev/tui/types.go | 1 + 7 files changed, 135 insertions(+), 7 deletions(-) diff --git a/cmd/roborev/tui/control_handlers.go b/cmd/roborev/tui/control_handlers.go index 41e3c75d0..e515cfc8d 100644 --- a/cmd/roborev/tui/control_handlers.go +++ b/cmd/roborev/tui/control_handlers.go @@ -394,6 +394,7 @@ func (m model) handleCtrlSetView( m.currentView = v var cmd tea.Cmd if v == viewTasks { + m.loadingFixJobs = true cmd = m.fetchFixJobs() } return m, controlResponse{OK: true}, cmd diff --git a/cmd/roborev/tui/fetch.go b/cmd/roborev/tui/fetch.go index 7dbc5d0d0..3ca8acea3 100644 --- a/cmd/roborev/tui/fetch.go +++ b/cmd/roborev/tui/fetch.go @@ -211,7 +211,7 @@ func (m model) fetchStatus() tea.Cmd { return func() tea.Msg { var status storage.DaemonStatus if err := m.getJSON("/api/status", &status); err != nil { - return errMsg(err) + return statusErrMsg{err: err} } return statusMsg(status) } diff --git a/cmd/roborev/tui/handlers_msg.go b/cmd/roborev/tui/handlers_msg.go index ffeb433fd..af9376169 100644 --- a/cmd/roborev/tui/handlers_msg.go +++ b/cmd/roborev/tui/handlers_msg.go @@ -228,6 +228,7 @@ func (m model) handleJobsMsg(msg jobsMsg) (tea.Model, tea.Cmd) { // handleStatusMsg processes daemon status updates. func (m model) handleStatusMsg(msg statusMsg) (tea.Model, tea.Cmd) { + m.loadingStatus = false m.status = storage.DaemonStatus(msg) m.consecutiveErrors = 0 if m.status.Version != "" { @@ -435,6 +436,7 @@ func (m model) handleBranchesMsg( func (m model) handleFixJobsMsg( msg fixJobsMsg, ) (tea.Model, tea.Cmd) { + m.loadingFixJobs = false if msg.err != nil { m.err = msg.err } else { @@ -791,9 +793,11 @@ func (m model) handleReconnectMsg(msg reconnectMsg) (tea.Model, tea.Cmd) { } m.clearFetchFailed() m.loadingJobs = true - cmds := []tea.Cmd{ - m.fetchJobs(), m.fetchStatus(), m.fetchRepoNames(), - } + cmds := []tea.Cmd{m.fetchJobs(), m.fetchRepoNames()} + // Force fetches on reconnect — previous in-flight requests + // were against the old connection and will fail or be stale. + m.loadingStatus = true + cmds = append(cmds, m.fetchStatus()) if cmd := m.fetchUnloadedBranches(); cmd != nil { cmds = append(cmds, cmd) } @@ -843,10 +847,20 @@ func (m model) handleTickMsg( ) (tea.Model, tea.Cmd) { // Skip job refresh while pagination or another refresh is in flight if m.loadingMore || m.loadingJobs { - return m, tea.Batch(m.tick(), m.fetchStatus()) + cmds := []tea.Cmd{m.tick()} + if !m.loadingStatus { + m.loadingStatus = true + cmds = append(cmds, m.fetchStatus()) + } + return m, tea.Batch(cmds...) } - cmds := []tea.Cmd{m.tick(), m.fetchJobs(), m.fetchStatus()} - if m.tasksWorkflowEnabled() && (m.currentView == viewTasks || m.hasActiveFixJobs()) { + cmds := []tea.Cmd{m.tick(), m.fetchJobs()} + if !m.loadingStatus { + m.loadingStatus = true + cmds = append(cmds, m.fetchStatus()) + } + if m.tasksWorkflowEnabled() && (m.currentView == viewTasks || m.hasActiveFixJobs()) && !m.loadingFixJobs { + m.loadingFixJobs = true cmds = append(cmds, m.fetchFixJobs()) } return m, tea.Batch(cmds...) @@ -907,6 +921,17 @@ func (m model) handlePaginationErrMsg( } // handleErrMsg processes generic error messages. +func (m model) handleStatusErrMsg( + msg statusErrMsg, +) (tea.Model, tea.Cmd) { + m.loadingStatus = false + m.err = msg.err + if cmd := m.handleConnectionError(msg.err); cmd != nil { + return m, cmd + } + return m, nil +} + func (m model) handleErrMsg( msg errMsg, ) (tea.Model, tea.Cmd) { @@ -928,11 +953,13 @@ func (m model) handleFixTriggerResultMsg( ), 3*time.Second, viewTasks) } else if msg.warning != "" { m.setFlash(msg.warning, 5*time.Second, viewTasks) + m.loadingFixJobs = true return m, m.fetchFixJobs() } else { m.setFlash(fmt.Sprintf( "Fix job #%d enqueued", msg.job.ID, ), 3*time.Second, viewTasks) + m.loadingFixJobs = true return m, m.fetchFixJobs() } return m, nil @@ -970,6 +997,7 @@ func (m model) handleApplyPatchResultMsg( "Patch for job #%d doesn't apply cleanly"+ " - triggering rebase", msg.jobID, ), 5*time.Second, viewTasks) + m.loadingFixJobs = true return m, tea.Batch( m.triggerRebase(msg.jobID), m.fetchFixJobs(), ) @@ -992,6 +1020,7 @@ func (m model) handleApplyPatchResultMsg( "Patch from job #%d applied and committed", msg.jobID, ), 3*time.Second, viewTasks) + m.loadingFixJobs = true cmds := []tea.Cmd{m.fetchFixJobs()} if msg.parentJobID > 0 { cmds = append( diff --git a/cmd/roborev/tui/handlers_review.go b/cmd/roborev/tui/handlers_review.go index 9ba284d68..34842e080 100644 --- a/cmd/roborev/tui/handlers_review.go +++ b/cmd/roborev/tui/handlers_review.go @@ -369,6 +369,7 @@ func (m model) handleToggleTasksKey() (tea.Model, tea.Cmd) { } if m.currentView == viewQueue { m.currentView = viewTasks + m.loadingFixJobs = true return m, m.fetchFixJobs() } return m, nil diff --git a/cmd/roborev/tui/tui.go b/cmd/roborev/tui/tui.go index 89925fcec..ed8195a28 100644 --- a/cmd/roborev/tui/tui.go +++ b/cmd/roborev/tui/tui.go @@ -285,6 +285,8 @@ type model struct { hasMore bool // true if there are more jobs to load loadingMore bool // true if currently loading more jobs (pagination) loadingJobs bool // true if currently loading jobs (full refresh) + loadingStatus bool // true if currently loading daemon status + loadingFixJobs bool // true if currently loading fix jobs heightDetected bool // true after first WindowSizeMsg (real terminal height known) fetchSeq int // incremented on filter changes; stale fetch responses are discarded paginateNav viewKind // non-zero: auto-navigate in this view after pagination loads @@ -566,6 +568,7 @@ func newModel(ep daemon.DaemonEndpoint, opts ...option) model { width: 80, // sensible defaults until we get WindowSizeMsg height: 24, loadingJobs: true, // Init() calls fetchJobs, so mark as loading + loadingStatus: true, // Init() calls fetchStatus, so mark as loading hideClosed: hideClosed, activeRepoFilter: activeRepoFilter, activeBranchFilter: activeBranchFilter, @@ -793,6 +796,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { result, cmd = m.handleJobsErrMsg(msg) case paginationErrMsg: result, cmd = m.handlePaginationErrMsg(msg) + case statusErrMsg: + result, cmd = m.handleStatusErrMsg(msg) case errMsg: result, cmd = m.handleErrMsg(msg) case reconnectMsg: diff --git a/cmd/roborev/tui/tui_test.go b/cmd/roborev/tui/tui_test.go index 1f6682a57..e97232e6c 100644 --- a/cmd/roborev/tui/tui_test.go +++ b/cmd/roborev/tui/tui_test.go @@ -463,6 +463,97 @@ func TestTUIJobsErrMsgClearsLoadingJobs(t *testing.T) { } } +func TestTUITickInFlightGuards(t *testing.T) { + tests := []struct { + name string + loadingStatus bool + loadingFixJobs bool + tasksEnabled bool + view viewKind + wantStatus bool // loadingStatus after tick + wantFixJobs bool // loadingFixJobs after tick + }{ + { + name: "skips status fetch when already loading", + loadingStatus: true, + tasksEnabled: false, + wantStatus: true, // stays true, no new fetch dispatched + wantFixJobs: false, + }, + { + name: "dispatches status fetch when idle", + loadingStatus: false, + tasksEnabled: false, + wantStatus: true, // set to true by dispatch + wantFixJobs: false, + }, + { + name: "skips fix-jobs fetch when already loading", + loadingFixJobs: true, + tasksEnabled: true, + view: viewTasks, + wantStatus: true, + wantFixJobs: true, // stays true, no new fetch dispatched + }, + { + name: "dispatches fix-jobs fetch when idle on tasks view", + loadingFixJobs: false, + tasksEnabled: true, + view: viewTasks, + wantStatus: true, + wantFixJobs: true, // set to true by dispatch + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + m := newModel(testEndpoint, withExternalIODisabled()) + m.loadingJobs = false + m.loadingMore = false + m.loadingStatus = tt.loadingStatus + m.loadingFixJobs = tt.loadingFixJobs + m.tasksEnabled = tt.tasksEnabled + if tt.view != 0 { + m.currentView = tt.view + } + + m2, cmd := updateModel(t, m, tickMsg(time.Now())) + + assert.NotNil(cmd, "tick should always return commands") + assert.Equal(tt.wantStatus, m2.loadingStatus) + assert.Equal(tt.wantFixJobs, m2.loadingFixJobs) + }) + } +} + +func TestTUIStatusResponseClearsLoadingFlag(t *testing.T) { + assert := assert.New(t) + m := newModel(testEndpoint, withExternalIODisabled()) + m.loadingStatus = true + + m2, _ := updateModel(t, m, statusMsg{}) + assert.False(m2.loadingStatus, "statusMsg should clear loadingStatus") + + // Error path should also clear the flag. + m.loadingStatus = true + m3, _ := updateModel(t, m, statusErrMsg{err: fmt.Errorf("connection refused")}) + assert.False(m3.loadingStatus, "statusErrMsg should clear loadingStatus") +} + +func TestTUIFixJobsResponseClearsLoadingFlag(t *testing.T) { + assert := assert.New(t) + m := newModel(testEndpoint, withExternalIODisabled()) + m.loadingFixJobs = true + + m2, _ := updateModel(t, m, fixJobsMsg{jobs: []storage.ReviewJob{makeJob(1)}}) + assert.False(m2.loadingFixJobs, "fixJobsMsg should clear loadingFixJobs") + + // Error path should also clear the flag. + m.loadingFixJobs = true + m3, _ := updateModel(t, m, fixJobsMsg{err: fmt.Errorf("connection refused")}) + assert.False(m3.loadingFixJobs, "fixJobsMsg with error should clear loadingFixJobs") +} + func TestTUIHideClosedMalformedConfigNotOverwritten(t *testing.T) { tmpDir := setupTuiTestEnv(t) diff --git a/cmd/roborev/tui/types.go b/cmd/roborev/tui/types.go index bc82a5ae3..ef1b470c3 100644 --- a/cmd/roborev/tui/types.go +++ b/cmd/roborev/tui/types.go @@ -167,6 +167,7 @@ type rerunResultMsg struct { err error } type errMsg error +type statusErrMsg struct{ err error } type configSaveErrMsg struct{ err error } type jobsErrMsg struct { err error From 4a9d7786c1eb1fe6c4b006682fac8701db48e7af Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:29:23 -0400 Subject: [PATCH 02/10] fix(tui): route all status/fix-jobs fetches through in-flight guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add startFetchStatus and startFetchFixJobs helpers that check the loading flag before dispatching. Use them at all call sites — tick handler, reconnect, view switch, fix trigger, and patch apply — so no path can launch a concurrent request while one is already in flight. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/roborev/tui/control_handlers.go | 3 +-- cmd/roborev/tui/fetch.go | 20 +++++++++++++++ cmd/roborev/tui/handlers_msg.go | 38 ++++++++++++++--------------- cmd/roborev/tui/handlers_review.go | 3 +-- 4 files changed, 41 insertions(+), 23 deletions(-) diff --git a/cmd/roborev/tui/control_handlers.go b/cmd/roborev/tui/control_handlers.go index e515cfc8d..d50ea1048 100644 --- a/cmd/roborev/tui/control_handlers.go +++ b/cmd/roborev/tui/control_handlers.go @@ -394,8 +394,7 @@ func (m model) handleCtrlSetView( m.currentView = v var cmd tea.Cmd if v == viewTasks { - m.loadingFixJobs = true - cmd = m.fetchFixJobs() + cmd = m.startFetchFixJobs() } return m, controlResponse{OK: true}, cmd } diff --git a/cmd/roborev/tui/fetch.go b/cmd/roborev/tui/fetch.go index 3ca8acea3..9862f004b 100644 --- a/cmd/roborev/tui/fetch.go +++ b/cmd/roborev/tui/fetch.go @@ -217,6 +217,16 @@ func (m model) fetchStatus() tea.Cmd { } } +// startFetchStatus dispatches fetchStatus if no status fetch is already +// in flight, and sets the loadingStatus flag. Returns nil if skipped. +func (m *model) startFetchStatus() tea.Cmd { + if m.loadingStatus { + return nil + } + m.loadingStatus = true + return m.fetchStatus() +} + func (m model) checkForUpdate() tea.Cmd { return func() tea.Msg { info, err := update.CheckForUpdate(false) // Use cache @@ -820,3 +830,13 @@ func (m model) fetchFixJobs() tea.Cmd { return fixJobsMsg{jobs: result.Jobs} } } + +// startFetchFixJobs dispatches fetchFixJobs if no fix-jobs fetch is already +// in flight, and sets the loadingFixJobs flag. Returns nil if skipped. +func (m *model) startFetchFixJobs() tea.Cmd { + if m.loadingFixJobs { + return nil + } + m.loadingFixJobs = true + return m.fetchFixJobs() +} diff --git a/cmd/roborev/tui/handlers_msg.go b/cmd/roborev/tui/handlers_msg.go index af9376169..943817bbd 100644 --- a/cmd/roborev/tui/handlers_msg.go +++ b/cmd/roborev/tui/handlers_msg.go @@ -848,20 +848,19 @@ func (m model) handleTickMsg( // Skip job refresh while pagination or another refresh is in flight if m.loadingMore || m.loadingJobs { cmds := []tea.Cmd{m.tick()} - if !m.loadingStatus { - m.loadingStatus = true - cmds = append(cmds, m.fetchStatus()) + if cmd := m.startFetchStatus(); cmd != nil { + cmds = append(cmds, cmd) } return m, tea.Batch(cmds...) } cmds := []tea.Cmd{m.tick(), m.fetchJobs()} - if !m.loadingStatus { - m.loadingStatus = true - cmds = append(cmds, m.fetchStatus()) + if cmd := m.startFetchStatus(); cmd != nil { + cmds = append(cmds, cmd) } - if m.tasksWorkflowEnabled() && (m.currentView == viewTasks || m.hasActiveFixJobs()) && !m.loadingFixJobs { - m.loadingFixJobs = true - cmds = append(cmds, m.fetchFixJobs()) + if m.tasksWorkflowEnabled() && (m.currentView == viewTasks || m.hasActiveFixJobs()) { + if cmd := m.startFetchFixJobs(); cmd != nil { + cmds = append(cmds, cmd) + } } return m, tea.Batch(cmds...) } @@ -953,14 +952,12 @@ func (m model) handleFixTriggerResultMsg( ), 3*time.Second, viewTasks) } else if msg.warning != "" { m.setFlash(msg.warning, 5*time.Second, viewTasks) - m.loadingFixJobs = true - return m, m.fetchFixJobs() + return m, m.startFetchFixJobs() } else { m.setFlash(fmt.Sprintf( "Fix job #%d enqueued", msg.job.ID, ), 3*time.Second, viewTasks) - m.loadingFixJobs = true - return m, m.fetchFixJobs() + return m, m.startFetchFixJobs() } return m, nil } @@ -997,10 +994,11 @@ func (m model) handleApplyPatchResultMsg( "Patch for job #%d doesn't apply cleanly"+ " - triggering rebase", msg.jobID, ), 5*time.Second, viewTasks) - m.loadingFixJobs = true - return m, tea.Batch( - m.triggerRebase(msg.jobID), m.fetchFixJobs(), - ) + cmds := []tea.Cmd{m.triggerRebase(msg.jobID)} + if cmd := m.startFetchFixJobs(); cmd != nil { + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) } else if msg.commitFailed { detail := fmt.Sprintf( "Job #%d: %v", msg.jobID, msg.err, @@ -1020,8 +1018,10 @@ func (m model) handleApplyPatchResultMsg( "Patch from job #%d applied and committed", msg.jobID, ), 3*time.Second, viewTasks) - m.loadingFixJobs = true - cmds := []tea.Cmd{m.fetchFixJobs()} + cmds := []tea.Cmd{} + if cmd := m.startFetchFixJobs(); cmd != nil { + cmds = append(cmds, cmd) + } if msg.parentJobID > 0 { cmds = append( cmds, diff --git a/cmd/roborev/tui/handlers_review.go b/cmd/roborev/tui/handlers_review.go index 34842e080..29a3324ff 100644 --- a/cmd/roborev/tui/handlers_review.go +++ b/cmd/roborev/tui/handlers_review.go @@ -369,8 +369,7 @@ func (m model) handleToggleTasksKey() (tea.Model, tea.Cmd) { } if m.currentView == viewQueue { m.currentView = viewTasks - m.loadingFixJobs = true - return m, m.fetchFixJobs() + return m, m.startFetchFixJobs() } return m, nil } From 906356df4d4a2c576f15aeb52328440d193791e7 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:35:44 -0400 Subject: [PATCH 03/10] fix(tui): force fix-jobs refresh on reconnect, defer refresh after mutations On reconnect, force-reset loadingFixJobs and dispatch a fresh fetch (matching the existing status handling) so a stale in-flight request from the dead connection doesn't block all future fix-jobs fetches. For state-mutating handlers (fix enqueue, patch apply, rebase), use requestFetchFixJobs which sets a fixJobsStale flag when a fetch is already in flight. handleFixJobsMsg checks this flag and dispatches a follow-up fetch to pick up post-mutation state. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/roborev/tui/fetch.go | 13 +++++++++++++ cmd/roborev/tui/handlers_msg.go | 21 +++++++++++++++++---- cmd/roborev/tui/tui.go | 1 + cmd/roborev/tui/tui_test.go | 20 ++++++++++++++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/cmd/roborev/tui/fetch.go b/cmd/roborev/tui/fetch.go index 9862f004b..fe6a4d678 100644 --- a/cmd/roborev/tui/fetch.go +++ b/cmd/roborev/tui/fetch.go @@ -840,3 +840,16 @@ func (m *model) startFetchFixJobs() tea.Cmd { m.loadingFixJobs = true return m.fetchFixJobs() } + +// requestFetchFixJobs is like startFetchFixJobs but for handlers that follow +// state-mutating operations (fix enqueue, patch apply). If a fetch is already +// in flight, it marks the current data as stale so handleFixJobsMsg will +// dispatch a follow-up fetch when the in-flight one returns. +func (m *model) requestFetchFixJobs() tea.Cmd { + if m.loadingFixJobs { + m.fixJobsStale = true + return nil + } + m.loadingFixJobs = true + return m.fetchFixJobs() +} diff --git a/cmd/roborev/tui/handlers_msg.go b/cmd/roborev/tui/handlers_msg.go index 943817bbd..2d299d0b1 100644 --- a/cmd/roborev/tui/handlers_msg.go +++ b/cmd/roborev/tui/handlers_msg.go @@ -447,6 +447,14 @@ func (m model) handleFixJobsMsg( m.fixSelectedIdx = len(m.fixJobs) - 1 } } + // A state-mutating handler requested a refresh while this fetch was + // in flight. The data we just received predates that mutation, so + // dispatch a follow-up fetch to pick up the latest state. + if m.fixJobsStale { + m.fixJobsStale = false + m.loadingFixJobs = true + return m, m.fetchFixJobs() + } return m, nil } @@ -798,6 +806,11 @@ func (m model) handleReconnectMsg(msg reconnectMsg) (tea.Model, tea.Cmd) { // were against the old connection and will fail or be stale. m.loadingStatus = true cmds = append(cmds, m.fetchStatus()) + if m.tasksWorkflowEnabled() { + m.loadingFixJobs = true + m.fixJobsStale = false + cmds = append(cmds, m.fetchFixJobs()) + } if cmd := m.fetchUnloadedBranches(); cmd != nil { cmds = append(cmds, cmd) } @@ -952,12 +965,12 @@ func (m model) handleFixTriggerResultMsg( ), 3*time.Second, viewTasks) } else if msg.warning != "" { m.setFlash(msg.warning, 5*time.Second, viewTasks) - return m, m.startFetchFixJobs() + return m, m.requestFetchFixJobs() } else { m.setFlash(fmt.Sprintf( "Fix job #%d enqueued", msg.job.ID, ), 3*time.Second, viewTasks) - return m, m.startFetchFixJobs() + return m, m.requestFetchFixJobs() } return m, nil } @@ -995,7 +1008,7 @@ func (m model) handleApplyPatchResultMsg( " - triggering rebase", msg.jobID, ), 5*time.Second, viewTasks) cmds := []tea.Cmd{m.triggerRebase(msg.jobID)} - if cmd := m.startFetchFixJobs(); cmd != nil { + if cmd := m.requestFetchFixJobs(); cmd != nil { cmds = append(cmds, cmd) } return m, tea.Batch(cmds...) @@ -1019,7 +1032,7 @@ func (m model) handleApplyPatchResultMsg( msg.jobID, ), 3*time.Second, viewTasks) cmds := []tea.Cmd{} - if cmd := m.startFetchFixJobs(); cmd != nil { + if cmd := m.requestFetchFixJobs(); cmd != nil { cmds = append(cmds, cmd) } if msg.parentJobID > 0 { diff --git a/cmd/roborev/tui/tui.go b/cmd/roborev/tui/tui.go index ed8195a28..bbd7d8a3a 100644 --- a/cmd/roborev/tui/tui.go +++ b/cmd/roborev/tui/tui.go @@ -287,6 +287,7 @@ type model struct { loadingJobs bool // true if currently loading jobs (full refresh) loadingStatus bool // true if currently loading daemon status loadingFixJobs bool // true if currently loading fix jobs + fixJobsStale bool // true if a mutation occurred while fix-jobs fetch was in flight heightDetected bool // true after first WindowSizeMsg (real terminal height known) fetchSeq int // incremented on filter changes; stale fetch responses are discarded paginateNav viewKind // non-zero: auto-navigate in this view after pagination loads diff --git a/cmd/roborev/tui/tui_test.go b/cmd/roborev/tui/tui_test.go index e97232e6c..cce58e718 100644 --- a/cmd/roborev/tui/tui_test.go +++ b/cmd/roborev/tui/tui_test.go @@ -554,6 +554,26 @@ func TestTUIFixJobsResponseClearsLoadingFlag(t *testing.T) { assert.False(m3.loadingFixJobs, "fixJobsMsg with error should clear loadingFixJobs") } +func TestTUIFixJobsStaleFlagTriggersFollowUp(t *testing.T) { + assert := assert.New(t) + m := newModel(testEndpoint, withExternalIODisabled()) + + // Simulate: a fetch is in flight and a mutation marks data as stale. + m.loadingFixJobs = true + m.fixJobsStale = true + + // When the in-flight fetch returns, it should dispatch a follow-up. + m2, cmd := updateModel(t, m, fixJobsMsg{jobs: []storage.ReviewJob{makeJob(1)}}) + assert.True(m2.loadingFixJobs, "should re-dispatch fetch when stale") + assert.False(m2.fixJobsStale, "stale flag should be cleared") + assert.NotNil(cmd, "should return a follow-up fetch command") + + // Without the stale flag, no follow-up. + m3, cmd := updateModel(t, m2, fixJobsMsg{jobs: []storage.ReviewJob{makeJob(1)}}) + assert.False(m3.loadingFixJobs, "should not re-dispatch without stale flag") + assert.Nil(cmd, "should return no command without stale flag") +} + func TestTUIHideClosedMalformedConfigNotOverwritten(t *testing.T) { tmpDir := setupTuiTestEnv(t) From bfadaefb90ed543bb13069c6b3abd3c6822aef17 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:38:08 -0400 Subject: [PATCH 04/10] test(tui): add regression test for stale flag on error path The fixJobsStale check already covers both success and error paths (it's outside the if/else), but add an explicit test to prove it. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/roborev/tui/tui_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmd/roborev/tui/tui_test.go b/cmd/roborev/tui/tui_test.go index cce58e718..2246823e3 100644 --- a/cmd/roborev/tui/tui_test.go +++ b/cmd/roborev/tui/tui_test.go @@ -572,6 +572,14 @@ func TestTUIFixJobsStaleFlagTriggersFollowUp(t *testing.T) { m3, cmd := updateModel(t, m2, fixJobsMsg{jobs: []storage.ReviewJob{makeJob(1)}}) assert.False(m3.loadingFixJobs, "should not re-dispatch without stale flag") assert.Nil(cmd, "should return no command without stale flag") + + // Error path should also honor the stale flag. + m.loadingFixJobs = true + m.fixJobsStale = true + m4, cmd := updateModel(t, m, fixJobsMsg{err: fmt.Errorf("connection refused")}) + assert.True(m4.loadingFixJobs, "should re-dispatch on error when stale") + assert.False(m4.fixJobsStale, "stale flag should be cleared on error path") + assert.NotNil(cmd, "should return follow-up command on error path") } func TestTUIHideClosedMalformedConfigNotOverwritten(t *testing.T) { From bedf12008f0b87ab11548e36362d9783d91eb14c Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:47:43 -0400 Subject: [PATCH 05/10] fix(tui): apply in-flight guards to SSE event handlers Route fetchStatus/fetchFixJobs in handleSSEEventMsg and consumeSSEPendingRefresh through the startFetch helpers, consistent with the tick and reconnect paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/roborev/tui/handlers_msg.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/cmd/roborev/tui/handlers_msg.go b/cmd/roborev/tui/handlers_msg.go index 2d299d0b1..1e6873f39 100644 --- a/cmd/roborev/tui/handlers_msg.go +++ b/cmd/roborev/tui/handlers_msg.go @@ -734,11 +734,15 @@ func (m model) handleSSEEventMsg() (tea.Model, tea.Cmd) { m.loadingJobs = true cmds := []tea.Cmd{ m.fetchJobs(), - m.fetchStatus(), waitForSSE(m.sseCh, m.sseStop), } + if cmd := m.startFetchStatus(); cmd != nil { + cmds = append(cmds, cmd) + } if m.tasksWorkflowEnabled() && (m.currentView == viewTasks || m.hasActiveFixJobs()) { - cmds = append(cmds, m.fetchFixJobs()) + if cmd := m.startFetchFixJobs(); cmd != nil { + cmds = append(cmds, cmd) + } } return m, tea.Batch(cmds...) } @@ -752,9 +756,14 @@ func (m *model) consumeSSEPendingRefresh() tea.Cmd { } m.ssePendingRefresh = false m.loadingJobs = true - cmds := []tea.Cmd{m.fetchJobs(), m.fetchStatus()} + cmds := []tea.Cmd{m.fetchJobs()} + if cmd := m.startFetchStatus(); cmd != nil { + cmds = append(cmds, cmd) + } if m.tasksWorkflowEnabled() && (m.currentView == viewTasks || m.hasActiveFixJobs()) { - cmds = append(cmds, m.fetchFixJobs()) + if cmd := m.startFetchFixJobs(); cmd != nil { + cmds = append(cmds, cmd) + } } return tea.Batch(cmds...) } From 3d6c1a623a7fa3bb61cdc859e1c55140c7723ae6 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:16:16 -0400 Subject: [PATCH 06/10] fix(tui): add stale-aware status/fix-jobs refresh for SSE events SSE events signal daemon state changes, so use requestFetchStatus and requestFetchFixJobs in the SSE handlers. When a fetch is already in flight, these set a stale flag so handleStatusMsg/handleFixJobsMsg dispatch a follow-up fetch to pick up post-event state. Also clear statusStale on reconnect to avoid stale follow-ups from the dead connection. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/roborev/tui/fetch.go | 13 +++++++++++++ cmd/roborev/tui/handlers_msg.go | 21 +++++++++++++++++---- cmd/roborev/tui/tui.go | 3 ++- cmd/roborev/tui/tui_test.go | 28 ++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/cmd/roborev/tui/fetch.go b/cmd/roborev/tui/fetch.go index fe6a4d678..2554395d5 100644 --- a/cmd/roborev/tui/fetch.go +++ b/cmd/roborev/tui/fetch.go @@ -227,6 +227,19 @@ func (m *model) startFetchStatus() tea.Cmd { return m.fetchStatus() } +// requestFetchStatus is like startFetchStatus but for paths triggered by +// daemon state changes (SSE events). If a fetch is already in flight, it +// marks the current data as stale so handleStatusMsg will dispatch a +// follow-up fetch when the in-flight one returns. +func (m *model) requestFetchStatus() tea.Cmd { + if m.loadingStatus { + m.statusStale = true + return nil + } + m.loadingStatus = true + return m.fetchStatus() +} + func (m model) checkForUpdate() tea.Cmd { return func() tea.Msg { info, err := update.CheckForUpdate(false) // Use cache diff --git a/cmd/roborev/tui/handlers_msg.go b/cmd/roborev/tui/handlers_msg.go index 1e6873f39..9929099fd 100644 --- a/cmd/roborev/tui/handlers_msg.go +++ b/cmd/roborev/tui/handlers_msg.go @@ -240,6 +240,11 @@ func (m model) handleStatusMsg(msg statusMsg) (tea.Model, tea.Cmd) { } m.lastConfigReloadCounter = m.status.ConfigReloadCounter m.statusFetchedOnce = true + if m.statusStale { + m.statusStale = false + m.loadingStatus = true + return m, m.fetchStatus() + } return m, nil } @@ -736,11 +741,13 @@ func (m model) handleSSEEventMsg() (tea.Model, tea.Cmd) { m.fetchJobs(), waitForSSE(m.sseCh, m.sseStop), } - if cmd := m.startFetchStatus(); cmd != nil { + // SSE events signal daemon state changes — use the stale-aware + // helpers so a skipped fetch gets retried after the in-flight one. + if cmd := m.requestFetchStatus(); cmd != nil { cmds = append(cmds, cmd) } if m.tasksWorkflowEnabled() && (m.currentView == viewTasks || m.hasActiveFixJobs()) { - if cmd := m.startFetchFixJobs(); cmd != nil { + if cmd := m.requestFetchFixJobs(); cmd != nil { cmds = append(cmds, cmd) } } @@ -757,11 +764,11 @@ func (m *model) consumeSSEPendingRefresh() tea.Cmd { m.ssePendingRefresh = false m.loadingJobs = true cmds := []tea.Cmd{m.fetchJobs()} - if cmd := m.startFetchStatus(); cmd != nil { + if cmd := m.requestFetchStatus(); cmd != nil { cmds = append(cmds, cmd) } if m.tasksWorkflowEnabled() && (m.currentView == viewTasks || m.hasActiveFixJobs()) { - if cmd := m.startFetchFixJobs(); cmd != nil { + if cmd := m.requestFetchFixJobs(); cmd != nil { cmds = append(cmds, cmd) } } @@ -814,6 +821,7 @@ func (m model) handleReconnectMsg(msg reconnectMsg) (tea.Model, tea.Cmd) { // Force fetches on reconnect — previous in-flight requests // were against the old connection and will fail or be stale. m.loadingStatus = true + m.statusStale = false cmds = append(cmds, m.fetchStatus()) if m.tasksWorkflowEnabled() { m.loadingFixJobs = true @@ -947,6 +955,11 @@ func (m model) handleStatusErrMsg( ) (tea.Model, tea.Cmd) { m.loadingStatus = false m.err = msg.err + if m.statusStale { + m.statusStale = false + m.loadingStatus = true + return m, m.fetchStatus() + } if cmd := m.handleConnectionError(msg.err); cmd != nil { return m, cmd } diff --git a/cmd/roborev/tui/tui.go b/cmd/roborev/tui/tui.go index bbd7d8a3a..72ffe69fe 100644 --- a/cmd/roborev/tui/tui.go +++ b/cmd/roborev/tui/tui.go @@ -287,7 +287,8 @@ type model struct { loadingJobs bool // true if currently loading jobs (full refresh) loadingStatus bool // true if currently loading daemon status loadingFixJobs bool // true if currently loading fix jobs - fixJobsStale bool // true if a mutation occurred while fix-jobs fetch was in flight + statusStale bool // true if a state change occurred while status fetch was in flight + fixJobsStale bool // true if a state change occurred while fix-jobs fetch was in flight heightDetected bool // true after first WindowSizeMsg (real terminal height known) fetchSeq int // incremented on filter changes; stale fetch responses are discarded paginateNav viewKind // non-zero: auto-navigate in this view after pagination loads diff --git a/cmd/roborev/tui/tui_test.go b/cmd/roborev/tui/tui_test.go index 2246823e3..2162c1a33 100644 --- a/cmd/roborev/tui/tui_test.go +++ b/cmd/roborev/tui/tui_test.go @@ -582,6 +582,34 @@ func TestTUIFixJobsStaleFlagTriggersFollowUp(t *testing.T) { assert.NotNil(cmd, "should return follow-up command on error path") } +func TestTUIStatusStaleFlagTriggersFollowUp(t *testing.T) { + assert := assert.New(t) + m := newModel(testEndpoint, withExternalIODisabled()) + + // Simulate: a fetch is in flight and an SSE event marks data as stale. + m.loadingStatus = true + m.statusStale = true + + // When the in-flight fetch returns, it should dispatch a follow-up. + m2, cmd := updateModel(t, m, statusMsg{}) + assert.True(m2.loadingStatus, "should re-dispatch fetch when stale") + assert.False(m2.statusStale, "stale flag should be cleared") + assert.NotNil(cmd, "should return a follow-up fetch command") + + // Without the stale flag, no follow-up. + m3, cmd := updateModel(t, m2, statusMsg{}) + assert.False(m3.loadingStatus, "should not re-dispatch without stale flag") + assert.Nil(cmd, "should return no command without stale flag") + + // Error path should also honor the stale flag. + m.loadingStatus = true + m.statusStale = true + m4, cmd := updateModel(t, m, statusErrMsg{err: fmt.Errorf("connection refused")}) + assert.True(m4.loadingStatus, "should re-dispatch on error when stale") + assert.False(m4.statusStale, "stale flag should be cleared on error path") + assert.NotNil(cmd, "should return follow-up command on error path") +} + func TestTUIHideClosedMalformedConfigNotOverwritten(t *testing.T) { tmpDir := setupTuiTestEnv(t) From 188d5c36ab778969bee4a30202f6ffe431848219 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:45:56 -0400 Subject: [PATCH 07/10] fix(tui): add fetch generation to discard pre-reconnect responses Increment a monotonic fetchGen counter on reconnect and embed it in statusMsg, statusErrMsg, and fixJobsMsg. Response handlers discard messages from older generations, preventing a late pre-reconnect response from overwriting post-reconnect state. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/roborev/tui/fetch.go | 10 ++++++---- cmd/roborev/tui/handlers_msg.go | 12 +++++++++++- cmd/roborev/tui/tui.go | 1 + cmd/roborev/tui/tui_test.go | 26 +++++++++++++------------- cmd/roborev/tui/types.go | 11 +++++++++-- 5 files changed, 40 insertions(+), 20 deletions(-) diff --git a/cmd/roborev/tui/fetch.go b/cmd/roborev/tui/fetch.go index 2554395d5..c24721b0f 100644 --- a/cmd/roborev/tui/fetch.go +++ b/cmd/roborev/tui/fetch.go @@ -208,12 +208,13 @@ func (m model) fetchMoreJobs() tea.Cmd { } func (m model) fetchStatus() tea.Cmd { + gen := m.fetchGen return func() tea.Msg { var status storage.DaemonStatus if err := m.getJSON("/api/status", &status); err != nil { - return statusErrMsg{err: err} + return statusErrMsg{err: err, gen: gen} } - return statusMsg(status) + return statusMsg{status: status, gen: gen} } } @@ -831,6 +832,7 @@ func (m model) fetchPatch(jobID int64) tea.Cmd { // fetchFixJobs fetches fix jobs from the daemon. func (m model) fetchFixJobs() tea.Cmd { + gen := m.fetchGen return func() tea.Msg { params := neturl.Values{} params.Set("job_type", "fix") @@ -838,9 +840,9 @@ func (m model) fetchFixJobs() tea.Cmd { result, err := m.loadJobsPage(params) if err != nil { - return fixJobsMsg{err: err} + return fixJobsMsg{err: err, gen: gen} } - return fixJobsMsg{jobs: result.Jobs} + return fixJobsMsg{jobs: result.Jobs, gen: gen} } } diff --git a/cmd/roborev/tui/handlers_msg.go b/cmd/roborev/tui/handlers_msg.go index 9929099fd..280bec8a3 100644 --- a/cmd/roborev/tui/handlers_msg.go +++ b/cmd/roborev/tui/handlers_msg.go @@ -229,7 +229,10 @@ func (m model) handleJobsMsg(msg jobsMsg) (tea.Model, tea.Cmd) { // handleStatusMsg processes daemon status updates. func (m model) handleStatusMsg(msg statusMsg) (tea.Model, tea.Cmd) { m.loadingStatus = false - m.status = storage.DaemonStatus(msg) + if msg.gen < m.fetchGen { + return m, nil // discard pre-reconnect response + } + m.status = msg.status m.consecutiveErrors = 0 if m.status.Version != "" { m.daemonVersion = m.status.Version @@ -442,6 +445,9 @@ func (m model) handleFixJobsMsg( msg fixJobsMsg, ) (tea.Model, tea.Cmd) { m.loadingFixJobs = false + if msg.gen < m.fetchGen { + return m, nil // discard pre-reconnect response + } if msg.err != nil { m.err = msg.err } else { @@ -816,6 +822,7 @@ func (m model) handleReconnectMsg(msg reconnectMsg) (tea.Model, tea.Cmd) { m.daemonVersion = msg.version } m.clearFetchFailed() + m.fetchGen++ // invalidate pre-reconnect in-flight responses m.loadingJobs = true cmds := []tea.Cmd{m.fetchJobs(), m.fetchRepoNames()} // Force fetches on reconnect — previous in-flight requests @@ -954,6 +961,9 @@ func (m model) handleStatusErrMsg( msg statusErrMsg, ) (tea.Model, tea.Cmd) { m.loadingStatus = false + if msg.gen < m.fetchGen { + return m, nil // discard pre-reconnect error + } m.err = msg.err if m.statusStale { m.statusStale = false diff --git a/cmd/roborev/tui/tui.go b/cmd/roborev/tui/tui.go index 72ffe69fe..8e0500671 100644 --- a/cmd/roborev/tui/tui.go +++ b/cmd/roborev/tui/tui.go @@ -289,6 +289,7 @@ type model struct { loadingFixJobs bool // true if currently loading fix jobs statusStale bool // true if a state change occurred while status fetch was in flight fixJobsStale bool // true if a state change occurred while fix-jobs fetch was in flight + fetchGen uint64 // monotonic generation; incremented on reconnect to discard stale responses heightDetected bool // true after first WindowSizeMsg (real terminal height known) fetchSeq int // incremented on filter changes; stale fetch responses are discarded paginateNav viewKind // non-zero: auto-navigate in this view after pagination loads diff --git a/cmd/roborev/tui/tui_test.go b/cmd/roborev/tui/tui_test.go index 2162c1a33..6a04a4c4c 100644 --- a/cmd/roborev/tui/tui_test.go +++ b/cmd/roborev/tui/tui_test.go @@ -777,9 +777,9 @@ func TestTUIVersionMismatchDetection(t *testing.T) { m := newModel(testEndpoint, withExternalIODisabled()) // Simulate receiving status with different version - status := statusMsg(storage.DaemonStatus{ + status := statusMsg{status: storage.DaemonStatus{ Version: "different-version", - }) + }} m2, _ := updateModel(t, m, status) @@ -799,9 +799,9 @@ func TestTUIVersionMismatchDetection(t *testing.T) { m := newModel(testEndpoint, withExternalIODisabled()) // Simulate receiving status with same version as TUI - status := statusMsg(storage.DaemonStatus{ + status := statusMsg{status: storage.DaemonStatus{ Version: version.Version, - }) + }} m2, _ := updateModel(t, m, status) @@ -916,10 +916,10 @@ func TestTUIConfigReloadFlash(t *testing.T) { t.Run("no flash on first status fetch", func(t *testing.T) { // First status fetch with a ConfigReloadCounter should NOT flash - status1 := statusMsg(storage.DaemonStatus{ + status1 := statusMsg{status: storage.DaemonStatus{ Version: "1.0.0", ConfigReloadCounter: 1, - }) + }} m2, _ := updateModel(t, m, status1) @@ -947,10 +947,10 @@ func TestTUIConfigReloadFlash(t *testing.T) { m.lastConfigReloadCounter = 1 // Second status with different ConfigReloadCounter should flash - status2 := statusMsg(storage.DaemonStatus{ + status2 := statusMsg{status: storage.DaemonStatus{ Version: "1.0.0", ConfigReloadCounter: 2, - }) + }} m2, _ := updateModel(t, m, status2) @@ -973,10 +973,10 @@ func TestTUIConfigReloadFlash(t *testing.T) { m.lastConfigReloadCounter = 0 // No reload had occurred // Now config is reloaded - status := statusMsg(storage.DaemonStatus{ + status := statusMsg{status: storage.DaemonStatus{ Version: "1.0.0", ConfigReloadCounter: 1, - }) + }} m2, _ := updateModel(t, m, status) @@ -993,10 +993,10 @@ func TestTUIConfigReloadFlash(t *testing.T) { m.lastConfigReloadCounter = 1 // Same counter - status := statusMsg(storage.DaemonStatus{ + status := statusMsg{status: storage.DaemonStatus{ Version: "1.0.0", ConfigReloadCounter: 1, - }) + }} m2, _ := updateModel(t, m, status) @@ -1093,7 +1093,7 @@ func TestTUIReconnectOnConsecutiveErrors(t *testing.T) { { name: "resets error count on successful status fetch", initialErrors: 5, - msg: statusMsg(storage.DaemonStatus{Version: "1.0.0"}), + msg: statusMsg{status: storage.DaemonStatus{Version: "1.0.0"}}, wantErrors: 0, wantReconnecting: false, wantCmd: false, diff --git a/cmd/roborev/tui/types.go b/cmd/roborev/tui/types.go index ef1b470c3..b2932ec76 100644 --- a/cmd/roborev/tui/types.go +++ b/cmd/roborev/tui/types.go @@ -127,7 +127,10 @@ type jobsMsg struct { seq int // fetch sequence number — stale responses (seq < model.fetchSeq) are discarded stats storage.JobStats // aggregate counts from server } -type statusMsg storage.DaemonStatus +type statusMsg struct { + status storage.DaemonStatus + gen uint64 // fetch generation — discard if < model.fetchGen +} type reviewMsg struct { review *storage.Review responses []storage.Response // Responses for this review @@ -167,7 +170,10 @@ type rerunResultMsg struct { err error } type errMsg error -type statusErrMsg struct{ err error } +type statusErrMsg struct { + err error + gen uint64 +} type configSaveErrMsg struct{ err error } type jobsErrMsg struct { err error @@ -225,6 +231,7 @@ type reconnectMsg struct { type fixJobsMsg struct { jobs []storage.ReviewJob err error + gen uint64 // fetch generation — discard if < model.fetchGen } type fixTriggerResultMsg struct { From d269e6a1ccdf576c5d82a4745c8a4f674de764da Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:55:00 -0400 Subject: [PATCH 08/10] fix(tui): increment fetchSeq on reconnect, check gen before clearing flags Increment fetchSeq on reconnect so pre-reconnect job responses are also discarded (jobs already had seq-based staleness checking). Move loadingStatus/loadingFixJobs flag clearing after the fetchGen check so stale pre-reconnect responses don't clear the in-flight guard for the current-generation request. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/roborev/tui/handlers_msg.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cmd/roborev/tui/handlers_msg.go b/cmd/roborev/tui/handlers_msg.go index 280bec8a3..049a29d2f 100644 --- a/cmd/roborev/tui/handlers_msg.go +++ b/cmd/roborev/tui/handlers_msg.go @@ -228,10 +228,10 @@ func (m model) handleJobsMsg(msg jobsMsg) (tea.Model, tea.Cmd) { // handleStatusMsg processes daemon status updates. func (m model) handleStatusMsg(msg statusMsg) (tea.Model, tea.Cmd) { - m.loadingStatus = false if msg.gen < m.fetchGen { return m, nil // discard pre-reconnect response } + m.loadingStatus = false m.status = msg.status m.consecutiveErrors = 0 if m.status.Version != "" { @@ -444,10 +444,10 @@ func (m model) handleBranchesMsg( func (m model) handleFixJobsMsg( msg fixJobsMsg, ) (tea.Model, tea.Cmd) { - m.loadingFixJobs = false if msg.gen < m.fetchGen { return m, nil // discard pre-reconnect response } + m.loadingFixJobs = false if msg.err != nil { m.err = msg.err } else { @@ -822,7 +822,8 @@ func (m model) handleReconnectMsg(msg reconnectMsg) (tea.Model, tea.Cmd) { m.daemonVersion = msg.version } m.clearFetchFailed() - m.fetchGen++ // invalidate pre-reconnect in-flight responses + m.fetchGen++ // invalidate pre-reconnect status/fix-jobs responses + m.fetchSeq++ // invalidate pre-reconnect jobs responses m.loadingJobs = true cmds := []tea.Cmd{m.fetchJobs(), m.fetchRepoNames()} // Force fetches on reconnect — previous in-flight requests @@ -960,10 +961,10 @@ func (m model) handlePaginationErrMsg( func (m model) handleStatusErrMsg( msg statusErrMsg, ) (tea.Model, tea.Cmd) { - m.loadingStatus = false if msg.gen < m.fetchGen { return m, nil // discard pre-reconnect error } + m.loadingStatus = false m.err = msg.err if m.statusStale { m.statusStale = false From d306d1665c97f13a1cc72e6669231c11a69dcb56 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:14:31 -0400 Subject: [PATCH 09/10] fix(tui): clear loadingMore on reconnect to prevent stuck pagination Reset loadingMore during reconnect so a stale pre-reconnect pagination response (now discarded by fetchSeq) doesn't leave the flag stuck true, which would cause future tick/SSE refreshes to skip full job reloads. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/roborev/tui/handlers_msg.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/roborev/tui/handlers_msg.go b/cmd/roborev/tui/handlers_msg.go index 049a29d2f..4f8c77b9d 100644 --- a/cmd/roborev/tui/handlers_msg.go +++ b/cmd/roborev/tui/handlers_msg.go @@ -822,9 +822,10 @@ func (m model) handleReconnectMsg(msg reconnectMsg) (tea.Model, tea.Cmd) { m.daemonVersion = msg.version } m.clearFetchFailed() - m.fetchGen++ // invalidate pre-reconnect status/fix-jobs responses - m.fetchSeq++ // invalidate pre-reconnect jobs responses + m.fetchGen++ // invalidate pre-reconnect status/fix-jobs responses + m.fetchSeq++ // invalidate pre-reconnect jobs responses m.loadingJobs = true + m.loadingMore = false cmds := []tea.Cmd{m.fetchJobs(), m.fetchRepoNames()} // Force fetches on reconnect — previous in-flight requests // were against the old connection and will fail or be stale. From 2663a0461101b0cc60925836236b4091819a7d36 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:21:37 -0400 Subject: [PATCH 10/10] fix(tui): always clear fix-jobs flags on reconnect Clear loadingFixJobs and fixJobsStale unconditionally on reconnect, not just when tasksWorkflowEnabled() is true. Prevents a stuck loadingFixJobs flag if a pre-reconnect request was in flight while tasks were disabled. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/roborev/tui/handlers_msg.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/roborev/tui/handlers_msg.go b/cmd/roborev/tui/handlers_msg.go index 4f8c77b9d..a872cdddc 100644 --- a/cmd/roborev/tui/handlers_msg.go +++ b/cmd/roborev/tui/handlers_msg.go @@ -832,9 +832,10 @@ func (m model) handleReconnectMsg(msg reconnectMsg) (tea.Model, tea.Cmd) { m.loadingStatus = true m.statusStale = false cmds = append(cmds, m.fetchStatus()) + m.loadingFixJobs = false + m.fixJobsStale = false if m.tasksWorkflowEnabled() { m.loadingFixJobs = true - m.fixJobsStale = false cmds = append(cmds, m.fetchFixJobs()) } if cmd := m.fetchUnloadedBranches(); cmd != nil {