feat(strategist) S7: strategist resume / open-round repair

DESIGN_STRATEGIST.md §5. Support resume from a crash mid-strategist-loop.

_resume_strategist_state inspects investigation_rounds for a tail entry
without completed_at — an "open" round, i.e. one that started but never
closed. Two repairs:

  1. Mark the round closed with strategist_action="interrupted_resume"
     so the run history reflects what actually happened.
  2. Walk that round's leads; any still in "assigned" state are
     re-marked as "failed" with failure_reason="interrupted before
     complete". The Retry-failed-leads + Gap-analysis passes that run
     after the strategist loop can pick them up.

Returns max(round_number) + 1 — the round at which to resume the loop.
On a clean graph (no prior rounds) returns 1 and makes no changes.

_phase3_strategist_loop now calls this helper before the main for-loop
and uses its return value as start_round, so a resume run lands at the
right round number rather than restarting from R1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
BattleTag
2026-05-21 02:27:05 -10:00
parent 093f3cec1f
commit 388321ee30
2 changed files with 98 additions and 1 deletions

View File

@@ -3407,6 +3407,54 @@ class TestInvestigationRound:
assert "not in" in result
assert graph.strategist_complete_requested is False
@pytest.mark.asyncio
async def test_resume_repairs_open_round(self, tmp_path):
"""Simulate a crash mid-round: half-open InvestigationRound + a
lead in 'assigned' state. _resume_strategist_state must close the
round and re-mark the lead as failed."""
from unittest.mock import AsyncMock
from orchestrator import Orchestrator
graph = EvidenceGraph()
hid = await graph.add_hypothesis("h", "d")
rid = await graph.start_investigation_round(1)
lid = await graph.add_lead(
target_agent="filesystem", description="probe",
proposed_by="strategist", motivating_hypothesis=hid,
expected_evidence_type="supports", round_number=1,
)
lead = next(l for l in graph.leads if l.id == lid)
lead.status = "assigned"
llm = AsyncMock()
class FakeFactory:
def get_or_create_agent(self, name):
return AsyncMock()
orch = Orchestrator(llm, graph, FakeFactory(), config={})
next_round = await orch._resume_strategist_state()
round_ = graph.get_investigation_round(rid)
assert round_.completed_at != ""
assert round_.strategist_action == "interrupted_resume"
lead2 = next(l for l in graph.leads if l.id == lid)
assert lead2.status == "failed"
assert "interrupted" in lead2.context.get("failure_reason", "")
assert next_round == 2
@pytest.mark.asyncio
async def test_resume_state_is_idempotent_on_clean_graph(self):
"""No prior rounds → resume returns 1, no changes."""
from unittest.mock import AsyncMock
from orchestrator import Orchestrator
graph = EvidenceGraph()
llm = AsyncMock()
class FakeFactory:
def get_or_create_agent(self, name):
return AsyncMock()
orch = Orchestrator(llm, graph, FakeFactory(), config={})
result = await orch._resume_strategist_state()
assert result == 1
assert graph.investigation_rounds == []
@pytest.mark.asyncio
async def test_strategist_loop_exits_on_declare_complete(self):
"""Mock strategist that declares complete in round 1 — orchestrator