From d2160bbde0b88ff67b6651870dbcca81728dfd75 Mon Sep 17 00:00:00 2001 From: Hamit Arslan Date: Sun, 11 Oct 2026 16:28:49 +0300 Subject: [PATCH] fix: keep safe X style failure reasons on failed runs --- docs/x-workflow.md | 2 ++ src/tin_lite/x_style_activities.py | 15 ++++++---- tests/test_x_style.py | 48 +++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/docs/x-workflow.md b/docs/x-workflow.md index 4b6b6269..72844daa 100644 --- a/docs/x-workflow.md +++ b/docs/x-workflow.md @@ -53,6 +53,8 @@ The parent workflow uses the existing voice and composition workflows in order: An account change during guide review stops the request rather than switching voices. A failed capture does not silently buy another capture or proceed to composition. +Known sampling, integration and billing failures keep their safe reason on the failed run. +Unexpected failures use a generic message without private provider details. Retries reuse the same child runs. Both child definitions are published with the parent and pinned to its catalog revision; the project files are read at each child's start. The parent links to the composer's saved draft instead of saving a second copy. diff --git a/src/tin_lite/x_style_activities.py b/src/tin_lite/x_style_activities.py index e49f44b2..03f67b24 100644 --- a/src/tin_lite/x_style_activities.py +++ b/src/tin_lite/x_style_activities.py @@ -365,11 +365,14 @@ async def extract(self, run_id: str): if isinstance(exc, (ValueError, IntegrationError, BillingError)) else "" ) - raise ApplicationError( + message = ( f"X voice extraction could not be confirmed. {reason}No replacement was " - "purchased. The current guide is unchanged.", - non_retryable=True, - ) from None + "purchased. The current guide is unchanged." + ) + await self.db.save_effect_progress( + conn, execution_key=key, result={"failure_message": message} + ) + raise ApplicationError(message, non_retryable=True) from None @activity.defn(name="x_style_propose") async def propose(self, run_id: str) -> bool: @@ -594,9 +597,11 @@ async def validate(): async def failure(self, run_id: str): run = await self.db.get_run(UUID(run_id)) if run and run.executor == x_style.KEY: + receipt = await self.db.get_effect(f"{run.id}:x_style_model") await self.db.project_failure( run_id=run.id, - error_message=( + error_message=((receipt.result or {}).get("failure_message") if receipt else None) + or ( "X voice capture could not confirm completion. Check the current guide " "and any saved result before trying again." ), diff --git a/tests/test_x_style.py b/tests/test_x_style.py index 3d813f48..154ef1b1 100644 --- a/tests/test_x_style.py +++ b/tests/test_x_style.py @@ -149,6 +149,8 @@ async def get_run(self, run_id, conn=None): return self.run async def get_effect(self, key): + if key.endswith(":x_style_model"): + return self.model_receipt assert key.endswith(":x_style_context") result = {"revision": "a" * 40, "existing_guide": self.existing_guide} if self.policy_version is not None: @@ -164,7 +166,12 @@ async def effect_lock(self, key, operation): yield object(), self.model_receipt async def start_effect(self, conn, *, execution_key, operation): - self.model_receipt = SimpleNamespace(status="started") + self.model_receipt = SimpleNamespace(status="started", result={}) + + async def save_effect_progress(self, conn, *, execution_key, result): + assert execution_key.endswith(":x_style_model") + assert self.model_receipt.status == "started" + self.model_receipt.result = result async def complete_effect(self, conn, *, execution_key, result): self.completed = result @@ -488,3 +495,42 @@ async def timeline(self, connection, **kwargs): with pytest.raises(ApplicationError) as failure: await run_extract(monkeypatch, {"sample_source": "connected"}, FailingX([])) assert "X returned an incomplete result. No replacement was purchased" in str(failure.value) + + +@pytest.mark.parametrize("known", [True, False]) +async def test_failure_projection_keeps_safe_reason_without_rebuying(monkeypatch, known): + from unittest.mock import AsyncMock + + import tin_lite.x_style_activities as activities + from tin_lite.integrations import IntegrationUpstreamError + + monkeypatch.setattr(activities, "model_usage_scope", lambda **kwargs: nullcontext()) + run = SimpleNamespace( + id=RUN_ID, + executor=x_style.KEY, + status=SimpleNamespace(value="running"), + project_id=UUID("a0000000-0000-0000-0000-000000000061"), + input={}, + ) + db = FakeDB(run) + db.project_failure = AsyncMock() + x = FakeX() + x.timeline = AsyncMock( + side_effect=( + IntegrationUpstreamError("X returned an incomplete result") + if known + else ConnectionError("private provider URL") + ) + ) + router = FakeRouter() + worker = XStyleActivities(database=db, storage=None, router=router, x_connection=x) + for _ in range(2): + with pytest.raises(ApplicationError): + await worker.extract(str(RUN_ID)) + await worker.failure(str(RUN_ID)) + message = db.project_failure.await_args.kwargs["error_message"] + assert ("X returned an incomplete result" in message) == known + assert "private provider URL" not in message + assert "No replacement was purchased" in message + assert db.model_receipt.status == "started" + assert x.timeline.await_count == 1 and router.calls == 0