test_architecture_failure_journal.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. from __future__ import annotations
  2. from datetime import UTC, datetime, timedelta
  3. from types import SimpleNamespace
  4. import pytest
  5. from script_build_host.application.failure_policy import classify_script_failure
  6. from script_build_host.application.mission_failure_flow import MissionFailureFlow
  7. from script_build_host.application.mission_failure_resolver import MissionFailureResolver
  8. from script_build_host.application.recovery_planner import (
  9. MissionRecoveryPlanner,
  10. RecoveryCommandKind,
  11. )
  12. from script_build_host.domain.errors import ProtocolViolation, ScriptBuildError
  13. from script_build_host.domain.failure_journal import (
  14. FailureEventType,
  15. FailureSubject,
  16. MissionFailureEvent,
  17. RecoveryAction,
  18. )
  19. def test_outer_protocol_wrapper_does_not_replace_context_root_cause() -> None:
  20. inner = MissionFailureEvent.observed(
  21. script_build_id=1,
  22. root_trace_id="root",
  23. code="CONTEXT_BUDGET_EXCEEDED",
  24. summary="validator prompt is too large",
  25. subject=FailureSubject("task", "attempt", "validation"),
  26. depth=3,
  27. )
  28. outer = MissionFailureEvent(
  29. "outer",
  30. 1,
  31. "root",
  32. FailureEventType.OBSERVED,
  33. "PROTOCOL_VIOLATION",
  34. "phase boundary not reached",
  35. depth=0,
  36. cause_event_key=inner.event_key,
  37. occurred_at=datetime.now(UTC),
  38. )
  39. resolved = MissionFailureResolver().resolve((inner, outer))
  40. assert resolved.primary_cause.code == "CONTEXT_BUDGET_EXCEEDED"
  41. def test_failure_resolver_rejects_unknown_cause_link() -> None:
  42. event = MissionFailureEvent(
  43. "outer",
  44. 1,
  45. "root",
  46. FailureEventType.OBSERVED,
  47. "PROTOCOL_VIOLATION",
  48. "wrapper",
  49. cause_event_key="missing",
  50. )
  51. with pytest.raises(ProtocolViolation, match="unknown"):
  52. MissionFailureResolver().resolve((event,))
  53. def test_unknown_failure_code_fails_closed_without_guessing_prefix() -> None:
  54. failure = classify_script_failure(
  55. ScriptBuildError("TASK_MADE_UP", "unknown"),
  56. source_tool="tool",
  57. )
  58. assert failure.code == "INTERNAL_UNCLASSIFIED_FAILURE"
  59. assert failure.details["observed_code"] == "TASK_MADE_UP"
  60. def test_revalidation_is_not_planned_without_snapshot_and_failed_validation() -> None:
  61. failure = MissionFailureEvent.observed(
  62. script_build_id=1,
  63. root_trace_id="root",
  64. code="VALIDATION_EVIDENCE_UNAUTHORIZED",
  65. summary="failed before validation was frozen",
  66. subject=FailureSubject("task", "attempt", None),
  67. depth=2,
  68. )
  69. task = SimpleNamespace(
  70. task_id="task",
  71. current_spec_version=1,
  72. status=SimpleNamespace(value="needs_replan"),
  73. validation_ids=[],
  74. )
  75. attempt = SimpleNamespace(
  76. attempt_id="attempt",
  77. task_id="task",
  78. spec_version=1,
  79. snapshot_id=None,
  80. )
  81. ledger = SimpleNamespace(
  82. root_task_id="root-task",
  83. tasks={
  84. "root-task": SimpleNamespace(status=SimpleNamespace(value="needs_replan")),
  85. "task": task,
  86. },
  87. attempts={"attempt": attempt},
  88. validations={},
  89. operations={},
  90. )
  91. command = MissionRecoveryPlanner().plan((failure,), ledger)
  92. assert command.kind is RecoveryCommandKind.MANUAL_RECONCILIATION
  93. @pytest.mark.asyncio
  94. async def test_new_passed_attempt_recovers_prior_validation_failure() -> None:
  95. now = datetime.now(UTC)
  96. class Repository:
  97. def __init__(self) -> None:
  98. self.events = ()
  99. async def append(self, events):
  100. self.events = events
  101. repository = Repository()
  102. attempts = {
  103. "attempt-old": SimpleNamespace(
  104. attempt_id="attempt-old",
  105. created_at=now.isoformat(),
  106. status=SimpleNamespace(value="submitted"),
  107. failure=None,
  108. ),
  109. "attempt-new": SimpleNamespace(
  110. attempt_id="attempt-new",
  111. created_at=(now + timedelta(seconds=2)).isoformat(),
  112. status=SimpleNamespace(value="submitted"),
  113. failure=None,
  114. ),
  115. }
  116. validations = {
  117. "validation-old": SimpleNamespace(
  118. validation_id="validation-old",
  119. attempt_id="attempt-old",
  120. created_at=(now + timedelta(seconds=1)).isoformat(),
  121. status=SimpleNamespace(value="error"),
  122. verdict=None,
  123. failure=SimpleNamespace(code="CONTEXT_BUDGET_EXCEEDED", message="too large"),
  124. ),
  125. "validation-new": SimpleNamespace(
  126. validation_id="validation-new",
  127. attempt_id="attempt-new",
  128. created_at=(now + timedelta(seconds=3)).isoformat(),
  129. status=SimpleNamespace(value="completed"),
  130. verdict=SimpleNamespace(value="passed"),
  131. failure=None,
  132. ),
  133. }
  134. task = SimpleNamespace(
  135. task_id="task",
  136. attempt_ids=list(attempts),
  137. validation_ids=list(validations),
  138. status=SimpleNamespace(value="completed"),
  139. updated_at=(now + timedelta(seconds=4)).isoformat(),
  140. )
  141. ledger = SimpleNamespace(
  142. tasks={"task": task},
  143. attempts=attempts,
  144. validations=validations,
  145. )
  146. events = await MissionFailureFlow(repository).sync_ledger(
  147. script_build_id=1,
  148. root_trace_id="root",
  149. ledger=ledger,
  150. )
  151. recovery = next(item for item in events if item.event_type is FailureEventType.RECOVERY)
  152. assert recovery.code == RecoveryAction.ATTEMPT_SUCCEEDED
  153. assert recovery.successor_attempt_id == "attempt-new"