Skip to content

fix(dashboard): give a session a terminal status after an interrupted review - #864

Merged
devops-thiago merged 1 commit into
mainfrom
fix/863-interrupted-session-rows
Sep 16, 2026
Merged

devops-thiago merged 1 commit into
mainfrom
fix/863-interrupted-session-rows

Conversation

@devops-thiago

Copy link
Copy Markdown
Owner

What type of PR is this?

  • 🐛 Bug fix
  • ✨ Feature
  • 📝 Documentation
  • 🔧 Refactor
  • 🚀 Performance
  • ✅ Test
  • 🔒 Security
  • 📦 Dependency update
  • 🏗️ CI/CD

Description

A reviewsession row is written in_progress by ReviewSession.create when the review starts, and only ReviewOrchestrator.applyReviewResult / applyReviewFailure (and ReviewSessionUpdater.recordFailure) ever move it to a terminal status, from inside the process running that review. A review killed between those two writes — a deploy restart, a crash, a docker kill — never got the terminal one, and nothing reconciled the row afterwards, so it stayed in_progress for the life of the database. I confirmed there is no sweep of any kind: the only StartupEvent observers are StartupConfigValidator, DashboardWebSocketKeepAlive and SessionCostBackfill (cost only), there is no @Scheduled anywhere, and the only ShutdownEvent observer is the WebSocket keepalive.

InterruptedSessionReconciler observes StartupEvent and moves every in_progress row to failed with Review interrupted before it finished (bot restart or crash) as the errormessage. It is a bulk JPQL update touching those two columns only, so the tokens and cost already accumulated on a stranded row are kept, and no schema change is needed. A failure of the sweep is logged and swallowed, as in SessionCostBackfill, so a database problem cannot stop the bot from booting.

failed + a distinct reason, not a new status value. The dashboard's session list maps status to an icon with if (status === 'completed') … if (status === 'failed') … return '⏳' (frontend/app/(dashboard)/sessions/page.tsx:340-350), so a row under a new status would keep rendering as the pending hourglass in yellow — exactly the "looks like it is still running" state this issue is about — and the frontend would have to ship before the backend to avoid it. /summary counts completedReviews and failedReviews with two status = '…' counts against a totalReviews that counts everything (DashboardResource:342-353), so a third value would leave the overview cards not adding up. The cost and token analytics group by model over status = 'completed' only, so they are unaffected either way. errormessage is already carried through toSessionDetail and rendered on the session page, which is what makes an interruption distinguishable from a review that failed on its own, at no cost to the filters or the statistics. Reusing failed does move these rows into the "Failed (30d)" card; that is the trade-off, and it is the smaller distortion of the two — the alternative leaves them in neither card and still looking alive.

No age-based sweep. A row can only be stranded by the process dying, so the next startup reconciles it whatever killed it; the exposure is the window between the death and the restart, when nobody is looking at a dashboard served by a process that is down. A periodic sweep would have to guess a safe age: ReviewDispatcher submits to an executor with no wall-clock bound, so the longest a live review can take is max-ai-calls (6) model calls of up to ai-timeout-seconds (300s) each, plus retries, publication and GitHub write backoff (GITHUB_WRITE_RETRY_BUDGET, 5m) — a threshold, not a bound, and one that would fail a live long review as interrupted if set too low. It would also need the quarkus-scheduler extension, which the project does not depend on today. The startup sweep covers every case that actually strands a row, including the 10 existing ones, so this stays out until there is a reason for it.

Issue claim that does not hold. The issue suggests the age sweep could key off "the longest a review can take, which is bounded by the dispatcher timeout". There is no dispatcher timeout: ReviewDispatcher.dispatch calls reviewExecutor.execute(...) and returns, and nothing applies a wall-clock limit to the review as a whole — the only timeouts are per AI call (thrillhousebot.review.ai-timeout-seconds, quarkus.langchain4j.openai.timeout) and per GitHub write. That is part of why the sweep is unconditional rather than age-based. The production row counts (10 rows, oldest 2026-06-09, newest 2026-09-09T15:00:07Z) are taken from the issue; I have no access to the production database. Everything else in the issue matched the code.

The single-process assumption (README, "Known limitations") is the reason an unconditional sweep is safe, and the class javadoc states it together with what a second replica would need first (an owner or a lease on the row).

Related Issues

Fixes #863

How Has This Been Tested?

  • Unit tests
  • Integration tests
  • Manual testing

Red first. The tests were written before the reconciler existed, so the suite did not compile against main:

[ERROR] /Users/thiago/repos/tb-863/src/test/java/dev/thiagogonzaga/thrillhousebot/dashboard/InterruptedSessionReconcilerTest.java:[39,11] cannot find symbol
[ERROR]   symbol:   class InterruptedSessionReconciler
[ERROR]   location: class dev.thiagogonzaga.thrillhousebot.dashboard.InterruptedSessionReconcilerTest

With the class in place but the sweep not implemented (reconcile() returning 0 without touching a row), the same tests failed on behaviour:

[ERROR]   InterruptedSessionReconcilerTest.shouldFailASessionLeftInProgressByAnEarlierRun:55 expected: <1> but was: <0>
[ERROR]   InterruptedSessionReconcilerTest.shouldReconcileOnStartup:94 expected: <failed> but was: <in_progress>
[ERROR]   InterruptedSessionReconcilerTest.shouldCountAReconciledSessionAsFailedOnTheDashboard:111 1 expectation failed.
[ERROR]   InterruptedSessionReconcilerTest.shouldShowTheInterruptionReasonOnTheSessionPage:126 1 expectation failed.

with, in full, for the two dashboard expectations:

java.lang.AssertionError: 
1 expectation failed.
JSON path failedReviews doesn't match.
Expected: <1>
  Actual: <0>

Green after the fix. Eight new tests: a stranded row becomes failed with the interrupted reason; its tokens and cost survive the sweep; a database whose rows are all terminal is left alone and the sweep reports 0; the startup path does the same work as the direct call; /api/dashboard/summary counts a reconciled row as failed (1 total, 0 completed, 1 failed); /api/dashboard/sessions/{id} renders it as failed carrying the interrupted reason; and two mock-based tests for the startup path with nothing to reconcile and with the database refusing.

Gates, from the worktree root:

./mvnw -B clean compile spotbugs:check spotless:check
[INFO] BugInstance size is 0
[INFO] BUILD SUCCESS

./mvnw -B clean test
[INFO] Tests run: 3897, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Patch coverage of the src/main diff against origin/main, line and branch, from target/site/jacoco/jacoco.xml: GAPS: none.

Checklist

  • My code follows the project's coding standards
  • I have performed a self-review of my own code
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have updated the documentation accordingly
  • My changes generate no new warnings or errors

Additional Notes

The 10 stranded production rows are reconciled by this sweep on the first boot after the deploy, with no manual SQL. They move into the failed count on the overview, where nine of them are older than the card's 30-day window.

This is not #26. A graceful drain reduces how often a review is interrupted; it cannot reconcile a row already stranded, nor one from a crash or a docker kill. The two are complementary, and nothing here depends on #26.

… review

A session row is written in_progress when a review starts and updated when it
ends, so a review killed between those two writes never got the terminal one.
Nothing reconciled the row afterwards and it stayed in_progress for the life of
the database, counting as a running review and hiding the genuinely in-flight
ones among the stale rows.

A new startup sweep moves every in_progress row to failed with "Review
interrupted before it finished (bot restart or crash)" as the reason. No review
survives a restart, so a row still in progress at boot belongs to a review that
is over, whatever killed it: the sweep is unconditional rather than age-based,
and it reconciles the rows stranded before it existed.

The reason, not a status of its own, is what separates an interruption from a
review that failed on its own. An unknown status would render as the pending
hourglass those rows already show and would fall outside both the completed and
the failed counters on the overview. Only the status and the message are
written, so the tokens and the cost the review had paid for stay on the row, and
no schema change is needed.

Sweeping every row at boot is safe because the bot is a single process. The
javadoc says so, and says what a second replica would need first.
@devops-thiago devops-thiago added this to the v0.6.9 milestone Sep 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot PR Summary

What this PR does

Adds an @ApplicationScoped InterruptedSessionReconciler that observes StartupEvent and bulk-updates every reviewsession row still marked in_progress to failed with the message "Review interrupted before it finished (bot restart or crash)", keeping tokens and cost intact, with failure logged and swallowed so a database problem cannot block boot. Documents the behavior in the README and CHANGELOG and adds unit/integration tests covering the sweep, the dashboard counters, and the swallowed-failure startup path.

Description vs. Implementation

No mismatch found between the PR description and the change.

Control-Flow Diagram

🔀 Show diagram
flowchart TD
  A["Quarkus boot completes"] --> B["onStart observes StartupEvent"]
  B --> C{"repository bulk update\nin_progress -> failed"}
  C -->|"reconciled > 0"| D["log.info count"]
  C -->|"0 rows"| E["no log, done"]
  C -->|"RuntimeException"| F["log.warn and continue boot"]
Loading

Changes Overview

  • Files changed: 5
  • Lines added: +307
  • Lines removed: 0

Changed Files

File Change Summary
CHANGELOG.md Modified Adds an Unreleased/Fixed entry describing the reconciliation sweep, its reasoning, and the single-process constraint.
README.md Modified Adds a paragraph explaining the restart-interruption behavior and the reconciled failed status in the dashboard docs section.
src/main/java/dev/thiagogonzaga/thrillhousebot/dashboard/InterruptedSessionReconciler.java Added New startup observer whose reconcile() runs one parameterized bulk JPQL update from in_progress to failed with the interrupted reason; failure is caught, warned, and swallowed.
src/test/java/dev/thiagogonzaga/thrillhousebot/dashboard/InterruptedSessionReconcilerStartupTest.java Added Plain Mockito unit tests: onStart neither throws nor logs harmfully with zero rows, and swallows a repository RuntimeException.
src/test/java/dev/thiagogonzaga/thrillhousebot/dashboard/InterruptedSessionReconcilerTest.java Added Quarkus tests: stranded row becomes failed with the reason, tokens/cost survive, terminal rows untouched, startup path equivalent, and dashboard summary/detail endpoints render it.

Risk Assessment

Risk Count
🔴 Critical 0
🟠 High 0
🟡 Medium 0
🔵 Low 0

No new issues found in this PR, but the review cannot be approved until required CI is confirmed green.

⚠️ Required CI Checks Status

Some required checks are still pending or have failed:

Check Type Status Detail
test check-run ⏳ Pending -
frontend check-run ⏳ Pending -
trivy check-run ⏳ Pending -
format check-run ⏳ Pending -

Automated review by ThrillhouseBot. Reply with /review to re-run.

@thrillhousebot thrillhousebot Bot added bug Something isn't working documentation Improvements or additions to documentation java Pull requests that update java code labels Sep 16, 2026
@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@sonarqubecloud

Copy link
Copy Markdown

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required CI is now green for e13a9c1, so the approval the earlier review held back is posted. The code was not re-reviewed: that review found no issues, and only the CI gate held its approval.

@devops-thiago

Copy link
Copy Markdown
Owner Author

/review

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything's coming up Thrillhouse! 🎉

No issues found in this PR.

@devops-thiago

Copy link
Copy Markdown
Owner Author

/pause

@thrillhousebot

Copy link
Copy Markdown
Contributor

⏸️ ThrillhouseBot is now paused on this PR — automatic and manual reviews are silenced. Comment /resume to re-enable.

@devops-thiago
devops-thiago merged commit a479914 into main Sep 16, 2026
20 checks passed
@devops-thiago
devops-thiago deleted the fix/863-interrupted-session-rows branch September 16, 2026 10:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation java Pull requests that update java code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A review interrupted by a restart leaves its session row in_progress for good

1 participant