Fix AWAIT_INPUT deadlock and make the live milestone test robust
Found while chasing a flaky live test, but the flakiness was a real bug: only the ask_human path recorded a pending item, so AWAIT_INPUT reached any other way — planning failure, stopped milestone, boot reconciliation — refused every operator reply with "no pending question to answer" and the agent could never be recovered. The operator had no way back short of wiping state. Every path into AWAIT_INPUT now records what a reply should resume, and answering dispatches on it: planning failures and questions resume planning, a stopped milestone returns to its still-approved gate, and the fallback says what commands work instead of dead-ending. Two regression tests cover the recovery paths. The live test also scripted a fixed approve/approve/start sequence, which a real model breaks by legitimately calling ask_human at a gate. It now plays the operator adaptively, responding to whatever the agent actually did. Five consecutive live runs pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
999a38a705
commit
bacf65dbe2
3 files changed
+149
-33
No files matched your search
@@ -12,7 +12,14 @@ from nisse_dev.canonical import (
|
||||
ToolCall,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_dev.control import AskHuman, Orchestrator, PlanningError, SubmitPlan
|
||||
from nisse_dev.control import (
|
||||
AWAIT_ARCH_SIGNOFF,
|
||||
AWAIT_MILESTONE_SIGNOFF,
|
||||
AskHuman,
|
||||
Orchestrator,
|
||||
PlanningError,
|
||||
SubmitPlan,
|
||||
)
|
||||
from nisse_dev.llm import RoleMap
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.planner import PLANNER_TOOLS, LLMPlanningAgent
|
||||
@@ -212,6 +219,53 @@ def test_planning_failure_gates_to_await_input(tmp_path: Path) -> None:
|
||||
assert transport.outbound[-1].kind == "question"
|
||||
|
||||
|
||||
def test_operator_can_recover_from_a_planning_failure(tmp_path: Path) -> None:
|
||||
# Regression: AWAIT_INPUT reached by any path other than ask_human used to
|
||||
# record no pending item, so every operator reply was refused with "no
|
||||
# pending question" and the agent was stuck for good.
|
||||
prose = [AssistantText(text="prose", model="m")]
|
||||
client = ScriptedClient([prose, prose, [plan_call("recovered plan")]])
|
||||
_, store, transport = build(tmp_path, client)
|
||||
transport.receive("don", "use demo")
|
||||
assert store.get_state().state == "AWAIT_INPUT"
|
||||
|
||||
transport.receive("don", "try again, keep it short")
|
||||
|
||||
assert store.get_state().state == AWAIT_ARCH_SIGNOFF
|
||||
assert "recovered plan" in transport.outbound[-1].text
|
||||
|
||||
|
||||
def test_operator_can_recover_after_a_stopped_milestone(tmp_path: Path) -> None:
|
||||
class StoppedExecutor:
|
||||
def run(self, milestone: int) -> Any:
|
||||
from nisse_dev.executor import ExecutorOutcome
|
||||
|
||||
return ExecutorOutcome("escalate", "tests failed twice")
|
||||
|
||||
store = StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
||||
transport = InMemoryTransport()
|
||||
client = ScriptedClient([[plan_call("p1")], [plan_call("p2")]])
|
||||
orch = Orchestrator(
|
||||
store,
|
||||
transport,
|
||||
LLMPlanningAgent(client, store, ROLES),
|
||||
operators={"don"},
|
||||
roles=ROLES,
|
||||
executor=StoppedExecutor(),
|
||||
)
|
||||
orch.start()
|
||||
transport.receive("don", "use demo")
|
||||
transport.receive("don", "approve")
|
||||
transport.receive("don", "approve")
|
||||
transport.receive("don", "start milestone 1")
|
||||
assert store.get_state().state == "AWAIT_INPUT"
|
||||
|
||||
transport.receive("don", "ok, I fixed the dependency")
|
||||
|
||||
assert store.get_state().state == AWAIT_MILESTONE_SIGNOFF
|
||||
assert "start milestone 1" in transport.outbound[-1].text
|
||||
|
||||
|
||||
def test_rejection_feedback_reaches_the_planner_via_the_log(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
Reference in new issue
Block a user