Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/x-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 10 additions & 5 deletions src/tin_lite/x_style_activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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."
),
Expand Down
48 changes: 47 additions & 1 deletion tests/test_x_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Loading