From 684e11b9cfd0caf1724619553ecc8c5864508fbf Mon Sep 17 00:00:00 2001 From: 900 Date: Mon, 25 May 2026 18:21:51 +0900 Subject: [PATCH 1/2] fix(auto): close safe-default synthesis ack --- src/ouroboros/auto/interview_driver.py | 33 +++++++++++++--------- tests/unit/auto/test_interview_pipeline.py | 18 +++++++----- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/ouroboros/auto/interview_driver.py b/src/ouroboros/auto/interview_driver.py index b188d6186..4b171a02b 100644 --- a/src/ouroboros/auto/interview_driver.py +++ b/src/ouroboros/auto/interview_driver.py @@ -32,6 +32,8 @@ log = structlog.get_logger(__name__) +INTERVIEW_SAFE_DEFAULT_SYNTHESIS_STOP_REASON_CODE = "interview_safe_default_synthesis_nonclosure" + @dataclass(frozen=True, slots=True) class InterviewTurn: @@ -431,26 +433,31 @@ async def run(self, state: AutoPipelineState, ledger: SeedDraftLedger) -> AutoIn synthesis_pushed=False, ) state.ledger = ledger.to_dict() - state.mark_blocked(blocker, tool_name="interview.safe_default_synthesis") + state.mark_blocked( + blocker, + tool_name="interview.safe_default_synthesis", + error_code=INTERVIEW_SAFE_DEFAULT_SYNTHESIS_STOP_REASON_CODE, + ) record_authoring_backend(state) self._save(state) return AutoInterviewResult( "blocked", state.interview_session_id, ledger, self.max_rounds, blocker ) state.interview_session_id = synthesis_turn.session_id - state.pending_question = synthesis_turn.question if not (synthesis_turn.seed_ready or synthesis_turn.completed): - _revert_safe_default_entries(ledger, finalization.defaulted_sections) - blocker = ( - "safe-default synthesis did not close the persisted interview: " - "backend_done=False, ledger defaults rolled back" - ) - state.ledger = ledger.to_dict() - state.mark_blocked(blocker, tool_name="interview.safe_default_synthesis") - record_authoring_backend(state) - self._save(state) - return AutoInterviewResult( - "blocked", state.interview_session_id, ledger, self.max_rounds, blocker + # The backend accepted the driver-originated synthesis but + # treated it as another conversational answer instead of a + # terminal turn. At this point the ledger is already + # structurally complete and the synthesis has been written + # into the persisted transcript, so fail forward as a + # safe-default closure instead of rolling defaults back and + # blocking a deterministic local task. + log.info( + "auto.interview.safe_default_synthesis_ack_nonterminal", + auto_session_id=state.auto_session_id, + interview_session_id=state.interview_session_id, + defaulted_sections=finalization.defaulted_sections, + backend_question=synthesis_turn.question, ) log.info( "auto.interview.safe_default.closed", diff --git a/tests/unit/auto/test_interview_pipeline.py b/tests/unit/auto/test_interview_pipeline.py index 7031c292d..384cfc55a 100644 --- a/tests/unit/auto/test_interview_pipeline.py +++ b/tests/unit/auto/test_interview_pipeline.py @@ -846,6 +846,7 @@ async def answer( assert result.status == "blocked" assert state.interview_completed is False assert "transcript sync failed" in (result.blocker or "") + assert state.last_error_code == "interview_safe_default_synthesis_nonclosure" assert ledger.open_gaps() assert not any( entry.key == f"{section_name}.safe_default_finalization" @@ -855,7 +856,9 @@ async def answer( @pytest.mark.asyncio -async def test_interview_driver_blocks_when_synthesis_does_not_close_backend(tmp_path) -> None: +async def test_interview_driver_closes_when_synthesis_is_acked_without_backend_done( + tmp_path, +) -> None: async def start(goal: str, cwd: str) -> InterviewTurn: # noqa: ARG001 return InterviewTurn("What else should we know?", "interview_defaults") @@ -877,12 +880,13 @@ async def answer( result = await driver.run(state, ledger) - assert result.status == "blocked" - assert state.interview_completed is False - assert state.pending_question == "Still need one more thing" - assert "did not close the persisted interview" in (result.blocker or "") - assert ledger.open_gaps() - assert not any( + assert result.status == "seed_ready" + assert state.interview_completed is True + assert state.pending_question is None + assert state.interview_closure_mode == "safe_default" + assert state.last_error_code is None + assert ledger.open_gaps() == [] + assert any( entry.key == f"{section_name}.safe_default_finalization" for section_name, section in ledger.sections.items() for entry in section.entries From bc2ef97ec305ca0189932154a767e781f2a465b5 Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 25 May 2026 20:53:22 +0900 Subject: [PATCH 2/2] fix(auto): block nonterminal safe-default synthesis --- src/ouroboros/auto/interview_driver.py | 29 ++++++++++++---------- tests/unit/auto/test_interview_pipeline.py | 18 ++++++-------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/ouroboros/auto/interview_driver.py b/src/ouroboros/auto/interview_driver.py index 4b171a02b..bd54b4072 100644 --- a/src/ouroboros/auto/interview_driver.py +++ b/src/ouroboros/auto/interview_driver.py @@ -444,20 +444,23 @@ async def run(self, state: AutoPipelineState, ledger: SeedDraftLedger) -> AutoIn "blocked", state.interview_session_id, ledger, self.max_rounds, blocker ) state.interview_session_id = synthesis_turn.session_id + state.pending_question = synthesis_turn.question if not (synthesis_turn.seed_ready or synthesis_turn.completed): - # The backend accepted the driver-originated synthesis but - # treated it as another conversational answer instead of a - # terminal turn. At this point the ledger is already - # structurally complete and the synthesis has been written - # into the persisted transcript, so fail forward as a - # safe-default closure instead of rolling defaults back and - # blocking a deterministic local task. - log.info( - "auto.interview.safe_default_synthesis_ack_nonterminal", - auto_session_id=state.auto_session_id, - interview_session_id=state.interview_session_id, - defaulted_sections=finalization.defaulted_sections, - backend_question=synthesis_turn.question, + _revert_safe_default_entries(ledger, finalization.defaulted_sections) + blocker = ( + "safe-default synthesis did not close the persisted interview: " + "backend_done=False, ledger defaults rolled back" + ) + state.ledger = ledger.to_dict() + state.mark_blocked( + blocker, + tool_name="interview.safe_default_synthesis", + error_code=INTERVIEW_SAFE_DEFAULT_SYNTHESIS_STOP_REASON_CODE, + ) + record_authoring_backend(state) + self._save(state) + return AutoInterviewResult( + "blocked", state.interview_session_id, ledger, self.max_rounds, blocker ) log.info( "auto.interview.safe_default.closed", diff --git a/tests/unit/auto/test_interview_pipeline.py b/tests/unit/auto/test_interview_pipeline.py index 384cfc55a..31ec1f99a 100644 --- a/tests/unit/auto/test_interview_pipeline.py +++ b/tests/unit/auto/test_interview_pipeline.py @@ -856,9 +856,7 @@ async def answer( @pytest.mark.asyncio -async def test_interview_driver_closes_when_synthesis_is_acked_without_backend_done( - tmp_path, -) -> None: +async def test_interview_driver_blocks_when_synthesis_does_not_close_backend(tmp_path) -> None: async def start(goal: str, cwd: str) -> InterviewTurn: # noqa: ARG001 return InterviewTurn("What else should we know?", "interview_defaults") @@ -880,13 +878,13 @@ async def answer( result = await driver.run(state, ledger) - assert result.status == "seed_ready" - assert state.interview_completed is True - assert state.pending_question is None - assert state.interview_closure_mode == "safe_default" - assert state.last_error_code is None - assert ledger.open_gaps() == [] - assert any( + assert result.status == "blocked" + assert state.interview_completed is False + assert state.pending_question == "Still need one more thing" + assert "did not close the persisted interview" in (result.blocker or "") + assert state.last_error_code == "interview_safe_default_synthesis_nonclosure" + assert ledger.open_gaps() + assert not any( entry.key == f"{section_name}.safe_default_finalization" for section_name, section in ledger.sections.items() for entry in section.entries