From 8a3c0f3e77f786efcfd431582418a8c8c0e07fbe Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 11:49:56 +0000 Subject: [PATCH] =?UTF-8?q?A=20recorded=20batch=20failure=20carries=20stru?= =?UTF-8?q?ctured=20details=20onto=20the=20sub=5Ftask=20outputs=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/tasks/subtasks.py | 13 +++++++++++++ tests/domain/tasks/test_subtasks.py | 27 ++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/domain/tasks/subtasks.py b/domain/tasks/subtasks.py index 95f9ec5bc..edb3ee508 100644 --- a/domain/tasks/subtasks.py +++ b/domain/tasks/subtasks.py @@ -5,6 +5,19 @@ from typing import Any, Optional from uuid import UUID, uuid4 +class SubTaskFailure(Exception): + """A *recorded* failure: the work ran, its outcome belongs on the SubTask + record, and the message must NOT be retried (ADR-0055 — status is a + record, not retry fuel). Infra crashes raise anything else and keep their + retry semantics. ``details`` lands on the failed SubTask's outputs.""" + + def __init__( + self, message: str, details: Optional[dict[str, Any]] = None + ) -> None: + super().__init__(message) + self.details = details + + class SubTaskStatus(str, Enum): WAITING = "waiting" IN_PROGRESS = "in progress" diff --git a/tests/domain/tasks/test_subtasks.py b/tests/domain/tasks/test_subtasks.py index 18d09a6a5..8d000ba96 100644 --- a/tests/domain/tasks/test_subtasks.py +++ b/tests/domain/tasks/test_subtasks.py @@ -2,7 +2,7 @@ from uuid import uuid4 import pytest -from domain.tasks.subtasks import SubTask, SubTaskStatus +from domain.tasks.subtasks import SubTask, SubTaskFailure, SubTaskStatus def test_create_subtask_starts_waiting() -> None: @@ -95,6 +95,31 @@ def test_complete_without_result_leaves_outputs_unset() -> None: assert st.outputs is None +def test_fail_with_subtask_failure_records_structured_details() -> None: + # arrange + st = SubTask.create(task_id=uuid4()) + st.start() + + # act + st.fail( + SubTaskFailure( + "1 of 2 properties failed", + details={ + "succeeded": 1, + "failed": [{"property_id": 9, "error": "degenerate prediction"}], + }, + ) + ) + + # assert + assert st.status is SubTaskStatus.FAILED + assert st.outputs == { + "error": "1 of 2 properties failed", + "succeeded": 1, + "failed": [{"property_id": 9, "error": "degenerate prediction"}], + } + + def test_fail_records_error_in_outputs() -> None: # arrange st = SubTask.create(task_id=uuid4())