@@ -1385,74 +1385,92 @@ async def _heal_stale_verdict_pending(session) -> int:
13851385 verdict_pending_completed = 0
13861386 reimport_trial_ids : list [str ] = []
13871387 for (task_id ,) in stale_verdict_pending :
1388- task = (
1389- await session .execute (
1390- select (TaskModel ).where (TaskModel .id == str (task_id )).with_for_update ()
1391- )
1392- ).scalar_one_or_none ()
1393- if not task or task .status != TaskStatus .VERDICT_PENDING :
1394- continue
1395- # The candidate scan precedes the row lock. A trial settlement may
1396- # have created a fresh QA trial while cleanup waited, so recheck
1397- # after locking before repairing state or creating a duplicate.
1398- active_qa = await session .scalar (
1399- text (
1400- """
1401- SELECT 1 FROM trials
1402- WHERE task_id = :task_id AND kind = 'qa'
1403- AND deleted_at IS NULL
1404- AND superseded_by_trial_id IS NULL
1405- AND status::text NOT IN ('SUCCESS', 'FAILED', 'SKIPPED')
1406- LIMIT 1
1407- """
1408- ),
1409- {"task_id" : task .id },
1410- )
1411- if active_qa is not None :
1412- continue
1413- if task .verdict_status in (VerdictStatus .SUCCESS , VerdictStatus .FAILED ):
1414- task .status = TaskStatus .COMPLETED
1415- task .finished_at = task .finished_at or utcnow ()
1416- verdict_pending_completed += 1
1417- continue
1418- # A terminal QA trial with a non-terminal verdict means the import
1419- # never landed (worker died between settle and import). Re-import
1420- # after this transaction; only create a fresh QA trial when none
1421- # exists.
1422- settled_qa = await session .scalar (
1423- text (
1424- """
1425- SELECT tr.id FROM trials tr
1426- WHERE tr.task_id = :task_id AND tr.kind = 'qa'
1427- AND tr.deleted_at IS NULL
1428- AND tr.superseded_by_trial_id IS NULL
1429- AND tr.status::text IN ('SUCCESS', 'FAILED')
1430- ORDER BY tr.created_at DESC LIMIT 1
1431- """
1432- ),
1433- {"task_id" : task .id },
1434- )
1435- if settled_qa is not None :
1436- logger .info (
1437- "healer: task %s has settled qa trial %s with no verdict, re-importing" ,
1438- task .id ,
1439- settled_qa ,
1440- )
1441- reimport_trial_ids .append (str (settled_qa ))
1442- continue
1443- # start_qa_for_task itself has no audit gate; creating a QA trial
1444- # while an audit is live would bake "(none recorded)" findings into
1445- # its brief. Skip for now: the audit's settlement re-enters
1446- # admission, and the next sweep retries regardless.
1447- if await live_analysis_trial_id (session , task .id , kind = "audit" ) is not None :
1448- continue
1449- if await start_qa_for_task (session , task ):
1450- logger .info (
1451- "healer: task %s was wedged in VERDICT_PENDING with no qa trial" ,
1452- task .id ,
1388+ # Savepoint per task: one unrepairable task (e.g. its experiment
1389+ # memberships are gone, so QA creation raises) must not abort the
1390+ # step and starve every task behind it in the updated_at ordering.
1391+ try :
1392+ async with session .begin_nested ():
1393+ task = (
1394+ await session .execute (
1395+ select (TaskModel )
1396+ .where (TaskModel .id == str (task_id ))
1397+ .with_for_update ()
1398+ )
1399+ ).scalar_one_or_none ()
1400+ if not task or task .status != TaskStatus .VERDICT_PENDING :
1401+ continue
1402+ # The candidate scan precedes the row lock. A trial
1403+ # settlement may have created a fresh QA trial while cleanup
1404+ # waited, so recheck after locking before repairing state or
1405+ # creating a duplicate.
1406+ active_qa = await session .scalar (
1407+ text (
1408+ """
1409+ SELECT 1 FROM trials
1410+ WHERE task_id = :task_id AND kind = 'qa'
1411+ AND deleted_at IS NULL
1412+ AND superseded_by_trial_id IS NULL
1413+ AND status::text NOT IN ('SUCCESS', 'FAILED', 'SKIPPED')
1414+ LIMIT 1
1415+ """
1416+ ),
1417+ {"task_id" : task .id },
1418+ )
1419+ if active_qa is not None :
1420+ continue
1421+ if task .verdict_status in (VerdictStatus .SUCCESS , VerdictStatus .FAILED ):
1422+ task .status = TaskStatus .COMPLETED
1423+ task .finished_at = task .finished_at or utcnow ()
1424+ verdict_pending_completed += 1
1425+ continue
1426+ # A terminal QA trial with a non-terminal verdict means the
1427+ # import never landed (worker died between settle and
1428+ # import). Re-import after this transaction; only create a
1429+ # fresh QA trial when none exists.
1430+ settled_qa = await session .scalar (
1431+ text (
1432+ """
1433+ SELECT tr.id FROM trials tr
1434+ WHERE tr.task_id = :task_id AND tr.kind = 'qa'
1435+ AND tr.deleted_at IS NULL
1436+ AND tr.superseded_by_trial_id IS NULL
1437+ AND tr.status::text IN ('SUCCESS', 'FAILED')
1438+ ORDER BY tr.created_at DESC LIMIT 1
1439+ """
1440+ ),
1441+ {"task_id" : task .id },
1442+ )
1443+ if settled_qa is not None :
1444+ logger .info (
1445+ "healer: task %s has settled qa trial %s with no "
1446+ "verdict, re-importing" ,
1447+ task .id ,
1448+ settled_qa ,
1449+ )
1450+ reimport_trial_ids .append (str (settled_qa ))
1451+ continue
1452+ # start_qa_for_task itself has no audit gate; creating a QA
1453+ # trial while an audit is live would bake "(none recorded)"
1454+ # findings into its brief. Skip for now: the audit's
1455+ # settlement re-enters admission, and the next sweep retries
1456+ # regardless.
1457+ if (
1458+ await live_analysis_trial_id (session , task .id , kind = "audit" )
1459+ is not None
1460+ ):
1461+ continue
1462+ if await start_qa_for_task (session , task ):
1463+ logger .info (
1464+ "healer: task %s was wedged in VERDICT_PENDING "
1465+ "with no qa trial" ,
1466+ task .id ,
1467+ )
1468+ else :
1469+ verdict_pending_completed += 1
1470+ except Exception : # noqa: BLE001 -- log and move to the next task
1471+ logger .exception (
1472+ "healer: verdict-pending repair failed for task %s" , task_id
14531473 )
1454- else :
1455- verdict_pending_completed += 1
14561474
14571475 for trial_id in reimport_trial_ids :
14581476 try :
0 commit comments