research: don't advance to reviewing without an outcome + guard-before-write
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 36s
ci / rust (push) Failing after 1m56s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Two bugs that combine to produce the 409 you get when clicking Approve:

1) notify_run_completed (topology_worker post-hook) was advancing
   topic status processing → reviewing whenever the last sibling run
   terminated — success OR fail. A failed run with 0 outcomes still
   pushed the topic to `reviewing`, the canvas rendered the "Request
   publish" affordance, and the reviewer clicked Approve on nothing.

   Fixed by adding an EXISTS(research_outcomes …) clause to the
   UPDATE. Topic stays in `processing` when no outcome exists; the
   loop's next iteration still has a chance to produce one.

2) decide_publish was calling
     research_publish_approvals::decide(approve=true)
   FIRST (which flips the row to `status='approved'`) and then
   running the "no outcome? 409" guard SECOND. On the 409 return,
   the DB was left half-flipped: approval says approved, topic still
   in reviewing, no outcome exists, and every future click to the
   same approval returns 409 on the "already decided" guard —
   leaving reviewers with no way forward.

   Fixed by moving the outcome-existence check BEFORE the decide()
   call. On 409 now nothing was written, so the reviewer can try
   again cleanly once an outcome is produced.

Also unstuck the current stuck row out-of-band (SQL UPDATE to reset
the approval to pending + topic to processing) so the user isn't
forced to delete the topic to escape the 409 loop.

sqlx dynamic query — the new EXISTS clause wasn't in the offline
cache so I switched notify_run_completed to plain `sqlx::query`.
This commit is contained in:
Omar Sobh
2026-07-11 08:44:34 -07:00
parent ee05037095
commit 7e0620fd08
2 changed files with 33 additions and 17 deletions
+20 -3
View File
@@ -355,7 +355,18 @@ pub async fn notify_run_completed(pool: &PgPool, id: Uuid) -> Result<bool, DbErr
// One statement: subquery locates the topic id, subquery counts siblings
// still in flight (excluding *this* run — it's about to be flipped to
// completed/failed by the caller, but ordering isn't guaranteed here).
let row = sqlx::query!(
//
// Only advance the topic when it has AT LEAST ONE outcome — otherwise a
// failed run with no synthesis would push the topic into `reviewing`,
// the UI would offer "Request publish", the user would click Approve, and
// decide_publish would 409 on the "no outcome" guard. Stays in
// `processing` when zero outcomes exist so the loop's next iteration
// still has a chance to produce one.
// Dynamic query — the added EXISTS clause on research_outcomes
// doesn't have an entry in the offline sqlx cache, so we bind
// values by hand instead of using the `query!` macro.
use sqlx::Row;
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
"UPDATE research_topics t
SET status = 'reviewing', updated_at = now()
WHERE t.id = (
@@ -363,6 +374,10 @@ pub async fn notify_run_completed(pool: &PgPool, id: Uuid) -> Result<bool, DbErr
WHERE id = $1 AND research_topic_id IS NOT NULL
)
AND t.status = 'processing'
AND EXISTS (
SELECT 1 FROM research_outcomes
WHERE topic_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM topology_runs
WHERE research_topic_id = t.id
@@ -370,11 +385,13 @@ pub async fn notify_run_completed(pool: &PgPool, id: Uuid) -> Result<bool, DbErr
AND status IN ('queued', 'running')
)
RETURNING t.id",
id,
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.is_some())
Ok(row
.map(|r| r.try_get::<Uuid, _>("id").is_ok())
.unwrap_or(false))
}
/// Mark a job completed and store its final result blob.