Fix AWAIT_INPUT deadlock and make the live milestone test robust
CI / checks (push) Successful in 36s
CI / checks (pull_request) Successful in 29s

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:
dongarandClaude Opus 5 committed 2026-09-07 16:46:07 +00:00
1 parent 999a38a705
commit bacf65dbe2
3 files changed
+149 -33

No files matched your search

+59 -27
View File
@@ -99,7 +99,9 @@ def _fresh_data() -> dict[str, Any]:
return {
"revs": {},
"gate": None,
"question": None,
# What an operator reply in AWAIT_INPUT should resume. Every path into
# AWAIT_INPUT must set this, or the reply has nowhere to go.
"pending": None,
"milestone": 1,
"task_id": None,
"roles": None, # A11 mapping, pinned at task start
@@ -138,11 +140,14 @@ class Orchestrator:
self._store.oplog.task_id = self._data.get("task_id")
gate = self._data.get("gate")
if report.action == "await_input":
self._transition(AWAIT_INPUT, cause="boot: unresolved intents")
self._transport.ask_question(
"restart found unresolved work: "
+ "; ".join(report.notes)
+ " — how should I proceed?"
self._await_input(
cause="boot: unresolved intents",
prompt=(
"restart found unresolved work: "
+ "; ".join(report.notes)
+ " — how should I proceed?"
),
pending={"kind": "boot"},
)
elif state.state == IDLE or (
state.state in (AWAIT_ARCH_SIGNOFF, AWAIT_MILESTONE_SIGNOFF) and gate
@@ -157,9 +162,10 @@ class Orchestrator:
else:
# Interrupted mid-turn (planning, executing, unanswered question):
# never blind-resume (A3.6).
self._transition(AWAIT_INPUT, cause=f"boot: restarted in {state.state}")
self._transport.ask_question(
f"restarted mid-{state.state}; continue or re-plan?"
self._await_input(
cause=f"boot: restarted in {state.state}",
prompt=f"restarted mid-{state.state}; continue or re-plan?",
pending={"kind": "boot"},
)
def _handle(self, msg: InboundMessage) -> None:
@@ -217,17 +223,18 @@ class Orchestrator:
gate_name, self._project, feedback=feedback, answer=answer
)
except PlanningError as exc:
self._transition(AWAIT_INPUT, cause=f"planning failed: {exc}")
self._transport.ask_question(
f"planning for {gate_name} failed ({exc}); retry or re-plan?"
self._await_input(
cause=f"planning failed: {exc}",
prompt=f"planning for {gate_name} failed ({exc}); how should I proceed?",
pending={"kind": "planning_failed", "gate": gate_name},
)
return
if isinstance(action, AskHuman):
self._data["question"] = {"text": action.question, "gate": gate_name}
self._transition(
AWAIT_INPUT, cause=f"agent question during {gate_name} planning"
self._await_input(
cause=f"agent question during {gate_name} planning",
prompt=action.question,
pending={"kind": "question", "gate": gate_name},
)
self._transport.ask_question(action.question)
return
rev = self._data["revs"].get(gate_name, 0) + 1
self._data["revs"][gate_name] = rev
@@ -334,23 +341,48 @@ class Orchestrator:
# until then a finished milestone simply reports and waits.
self._transport.send_message(f"milestone {milestone} done: {outcome.note}")
return
self._transition(AWAIT_INPUT, cause=f"milestone {milestone} {outcome.status}")
self._transport.ask_question(
f"milestone {milestone} stopped ({outcome.status}): {outcome.note}"
self._await_input(
cause=f"milestone {milestone} {outcome.status}",
prompt=f"milestone {milestone} stopped ({outcome.status}): {outcome.note}",
pending={"kind": "milestone_stopped", "milestone": milestone},
)
def _await_input(self, *, cause: str, prompt: str, pending: dict[str, Any]) -> None:
self._data["pending"] = pending
self._transition(AWAIT_INPUT, cause=cause)
self._transport.ask_question(prompt)
def _handle_answer(self, text: str) -> None:
question = self._data.get("question")
if question is None:
pending = self._data.get("pending")
if pending is None:
# Reached only if state was forced externally; say what works
# rather than dead-ending the operator.
self._transport.send_message(
"awaiting operator resolution; no pending question to answer"
"waiting for direction, but I have no pending item to resume; "
"try 'status', or 'use <project>' to start over"
)
return
self._data["question"] = None
gate_name = question["gate"]
back = PLANNING_ARCH if gate_name == "arch" else MILESTONE_PLANNING
self._transition(back, cause="operator answered question")
self._run_planning_turn(gate_name, answer=text)
self._data["pending"] = None
match pending.get("kind"):
case "question" | "planning_failed":
gate_name = pending["gate"]
back = PLANNING_ARCH if gate_name == "arch" else MILESTONE_PLANNING
self._transition(back, cause="operator answered; resuming planning")
self._run_planning_turn(gate_name, answer=text)
case "milestone_stopped":
milestone = pending["milestone"]
self._transition(
AWAIT_MILESTONE_SIGNOFF,
cause="operator answered; plan still approved",
)
self._transport.send_message(
f"noted. the milestone {milestone} plan is still approved — "
f"say 'start milestone {milestone}' to run it again"
)
case _:
self._transport.send_message(
"noted. say 'status' for where things stand"
)
def _transition(self, new_state: str, *, cause: str) -> None:
state = self._store.get_state()