Fix workflow commit atomicity, error path leak, and UI error boundary
Quality Gate / gate (push) Failing after 48s

Phase 1 of docs/reviews/2026-08-23-code-review.md.

HIGH-01: process_queued_job committed page evidence and the terminal job
status in separate transactions, so a crash between them left a transcript
persisted against a job stuck in PROCESSING that the worker never reclaims.
The final page's write is now deferred into _finalize_batch_outcome so it
shares the terminal transaction. Intermediate pages remain individually
durable, and the terminal commit is shielded against cancellation the same
way per-page writes already were.

HIGH-04: added tests/integration/test_pipeline_atomicity.py covering both
Transaction B and Transaction C. Confirmed failing against the previous
implementation before the fix.

HIGH-03: classify_unexpected_error interpolated the raw exception into
AppError.message, which the UI renders and the API serializes, leaking the
database path from OperationalError. message is now generic. Because message
also feeds format_error_detail, which writes evidence records, the root cause
is preserved on a new internal-only AppError.detail field rather than
discarded.

HIGH-02: replaced 8 hand-rolled ui.notify error calls in home_page and
people_page with error_presenter.show_error, restoring the correlation
error_id, canonical category, and suggestion. Added an AST guard to
test_ui_boundaries.py so pages cannot hand-roll error notifications again.

Docs updated per documentation-sync: the message/detail split in
docs/error_handling.md and the multi-page atomicity rule in
services.instructions.md.

Verification: ruff clean, 381 tests passing, ty unchanged at 10 known
SQLAlchemy descriptor false positives.

Co-authored-by: Copilot App <[email protected]>
This commit is contained in:
Jim Lancaster
2026-08-23 18:02:04 -05:00
co-authored by Copilot App
parent 8d3c60fce1
commit de18c2e9da
9 changed files with 356 additions and 23 deletions
+41 -5
View File
@@ -2,12 +2,15 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from enum import StrEnum
from uuid import uuid4
logger = logging.getLogger(__name__)
class ErrorCategory(StrEnum):
"""Stable error categories defined by docs/error_handling.md."""
@@ -40,6 +43,7 @@ class AppError(RuntimeError):
suggestion: str = "Retry once. If it persists, review logs and report the error reference id.",
retriable: bool = False,
error_id: str | None = None,
detail: str | None = None,
) -> None:
super().__init__(message)
self.message = message
@@ -47,6 +51,10 @@ class AppError(RuntimeError):
self.suggestion = suggestion
self.retriable = retriable
self.error_id = error_id or new_error_id()
# Internal-only diagnostic text. Persisted to evidence and logs, never rendered
# to users or serialized into API envelopes, because it may embed local
# filesystem paths and other infrastructure detail.
self.detail = detail
@dataclass(frozen=True)
@@ -89,15 +97,43 @@ def build_error_envelope(error: AppError) -> ErrorEnvelope:
def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError:
"""Normalize unknown exceptions into internal_unexpected_error."""
return AppError(
f"Unexpected error during {operation}: {exc}",
"""Normalize unknown exceptions into internal_unexpected_error.
The exception text is deliberately excluded from ``message``. ``AppError.message``
is rendered directly to users by the UI error presenter and is serialized into API
responses by :func:`build_error_envelope`, and unexpected exceptions routinely embed
local filesystem paths (SQLAlchemy ``OperationalError`` carries the database path,
``OSError`` carries the storage root). Leaking those is forbidden by
``.github/instructions/error-handling.instructions.md``.
The detail is preserved on ``AppError.detail`` and logged against ``error_id``. That
keeps the root cause in evidence records and operator logs, which are internal, while
keeping it out of user-facing and API-facing text.
"""
error = AppError(
f"Unexpected error during {operation}.",
category=ErrorCategory.INTERNAL_UNEXPECTED,
suggestion="Retry once. If it persists, review logs and report the error reference id.",
retriable=False,
detail=f"{type(exc).__name__}: {exc}",
)
logger.error(
"Unexpected error operation=%s error_id=%s",
operation,
error.error_id,
exc_info=exc,
)
return error
def format_error_detail(error: AppError) -> str:
"""Return a compact persisted failure string for transcript.error_detail."""
return f"[{error.category.value}] {error.message} | suggestion={error.suggestion} | error_id={error.error_id}"
"""Return a compact persisted failure string for transcript.error_detail.
This is internal provenance, not user-facing output, so it carries
``AppError.detail`` (the root cause) in addition to the user-safe message.
"""
parts = [f"[{error.category.value}] {error.message}"]
if error.detail:
parts.append(f"detail={error.detail}")
parts.extend((f"suggestion={error.suggestion}", f"error_id={error.error_id}"))
return " | ".join(parts)
+62 -9
View File
@@ -233,6 +233,7 @@ async def process_queued_job( # noqa: PLR0915
successful_pages: list[_SuccessfulPage] = []
failed_pages: list[_FailedPage] = []
pending_final_page: _SuccessfulPage | _FailedPage | None = None
externally_stopped = False
prompt_execution = _resolve_job_prompt_execution(source_job=source_job, settings=runtime_settings)
@@ -241,7 +242,8 @@ async def process_queued_job( # noqa: PLR0915
# latency on the first attempt of every worker process (review log [55]).
provider = services.sources.provider
for source in sources:
for index, source in enumerate(sources):
is_final_source = index == len(sources) - 1
if await _job_no_longer_processing(job_id=job.id, services=services, session=session):
externally_stopped = True
break
@@ -408,12 +410,18 @@ async def process_queued_job( # noqa: PLR0915
error.category.value,
)
await _persist_page_outcome_durably(
job=job,
services=services,
page=page_outcome,
session=session,
)
if is_final_source:
# The last page's evidence and the job's terminal status must succeed or
# roll back together, so this write is deferred into _finalize_batch_outcome.
# Earlier pages stay individually durable.
pending_final_page = page_outcome
else:
await _persist_page_outcome_durably(
job=job,
services=services,
page=page_outcome,
session=session,
)
if await _job_no_longer_processing(job_id=job.id, services=services, session=session):
externally_stopped = True
@@ -427,11 +435,12 @@ async def process_queued_job( # noqa: PLR0915
elif failed_pages and not successful_pages:
terminal_status = JobStatus.FAILED
updated_job = await _finalize_batch_outcome(
updated_job = await _finalize_batch_outcome_durably(
job=job,
services=services,
status=terminal_status,
session=session,
final_page=pending_final_page,
)
logger.info(
@@ -546,20 +555,64 @@ async def _job_no_longer_processing(
return latest_job.status != JobStatus.PROCESSING
async def _finalize_batch_outcome_durably(
*,
job: Job,
services: ServiceBundle,
status: JobStatus,
session: AsyncSession | None,
final_page: _SuccessfulPage | _FailedPage | None,
) -> Job:
"""Shield the terminal commit so cancellation cannot discard the last provider call.
Mirrors ``_persist_page_outcome_durably``. Without this, deferring the final page
into the terminal transaction would make that page less durable than the pages
before it.
"""
task = asyncio.create_task(
_finalize_batch_outcome(
job=job,
services=services,
status=status,
session=session,
final_page=final_page,
)
)
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
await task
raise
async def _finalize_batch_outcome(
*,
job: Job,
services: ServiceBundle,
status: JobStatus,
session: AsyncSession | None = None,
final_page: _SuccessfulPage | _FailedPage | None = None,
) -> Job:
"""Persist the terminal aggregate status after all page outcomes are durable."""
"""Persist the final page outcome and the terminal aggregate status in one transaction.
``services.instructions.md`` ("Workflow Transaction Boundaries") requires transcript
content and the paired terminal status to succeed or roll back together. Committing
them separately can leave a transcript persisted against a job stuck in PROCESSING,
which the worker never reclaims because it only claims QUEUED rows.
``final_page`` is ``None`` when the batch produced no page outcome to pair with the
status change (no sources, or the batch stopped before the last page).
"""
if session is None:
async with services.jobs._session_scope() as local_session:
if final_page is not None:
await _write_page_outcome(job=job, services=services, page=final_page, session=local_session)
updated_job = await services.jobs.mark_job_status(job.id, status, session=local_session)
await local_session.commit()
return updated_job
if final_page is not None:
await _write_page_outcome(job=job, services=services, page=final_page, session=session)
updated_job = await services.jobs.mark_job_status(job.id, status, session=session)
await session.commit()
return updated_job
+5 -4
View File
@@ -14,6 +14,7 @@ from transcription.services.photos import PhotoError
from transcription.services.photos import PhotosService
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.media_urls import resolve_media_url
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
@@ -209,7 +210,7 @@ def register_page() -> None: # noqa: PLR0915
description=(description_input.value or "").strip() or None,
)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
show_error(exc, title="Save failed", operation="homepage.photo.update_description")
return
ui.navigate.to("/homepage/edit")
@@ -217,7 +218,7 @@ def register_page() -> None: # noqa: PLR0915
try:
await photos_service.set_primary(photo_id=current_photo.id)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
show_error(exc, title="Update failed", operation="homepage.photo.set_primary")
return
ui.navigate.to("/homepage/edit")
@@ -225,7 +226,7 @@ def register_page() -> None: # noqa: PLR0915
try:
await photos_service.delete_photo(photo_id=current_photo.id)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
show_error(exc, title="Delete failed", operation="homepage.photo.delete")
return
ui.navigate.to("/homepage/edit")
@@ -252,7 +253,7 @@ def register_page() -> None: # noqa: PLR0915
file_bytes=payload,
)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
show_error(exc, title="Upload failed", operation="homepage.photo.create")
return
ui.notify(f"Uploaded {event.file.name}", type="positive")
ui.navigate.to("/homepage/edit")
+8 -4
View File
@@ -262,7 +262,7 @@ def register_page() -> None: # noqa: PLR0915
file_bytes=payload,
)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
show_error(exc, title="Upload failed", operation="people.photo.create")
return
ui.notify("Photo uploaded.", type="positive")
ui.navigate.to(f"/people/{person.id}/photos")
@@ -318,7 +318,11 @@ def register_page() -> None: # noqa: PLR0915
description=(input_control.value or "").strip() or None,
)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
show_error(
exc,
title="Save failed",
operation="people.photo.update_description",
)
return
ui.notify("Description saved.", type="positive")
ui.navigate.to(f"/people/{person.id}/photos")
@@ -327,7 +331,7 @@ def register_page() -> None: # noqa: PLR0915
try:
await photos_service.set_primary(photo_id=photo_id)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
show_error(exc, title="Update failed", operation="people.photo.set_primary")
return
ui.notify("Primary photo updated.", type="positive")
ui.navigate.to(f"/people/{person.id}/photos")
@@ -336,7 +340,7 @@ def register_page() -> None: # noqa: PLR0915
try:
await photos_service.delete_photo(photo_id=photo_id)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
show_error(exc, title="Delete failed", operation="people.photo.delete")
return
ui.notify("Photo deleted.", type="positive")
ui.navigate.to(f"/people/{person.id}/photos")