Never leave the agent stranded in a working state
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 1m20s

The first live run found four dead ends one at a time: states where no
operator message could move the agent. Most had one shape: work ended,
by an exception or by a path that never transitioned, and left the agent
in PLANNING_* or EXECUTING, where every message is answered "busy".
The worker only prints an exception, so that would keep happening.

- Every job the orchestrator runs for an operator message or a Gitea
  comment now goes through _settled(). If the job leaves a working state
  behind, whatever the reason, the agent moves to AWAIT_INPUT with the
  continue / re-plan choice a restart offers, and says why. An exception
  in a waiting state is reported and the state kept, since its commands
  still lead on.
- A sign-off state whose gate record is missing asks to plan again
  instead of crashing on every reply.
- The stub configurations (no executor, no publisher) complete the
  milestone and wait in PR_OPEN instead of resting in EXECUTING.

test_no_stranding.py drives a whole two-milestone project with a fault
injected at each planner, executor and publisher call, and asserts after
every message that the agent is not in a working state and that the
project still reaches 'project complete'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-09-29 16:39:20 +00:00
1 parent 1ccea20a10
commit 92e5452161
3 files changed
+325 -10

No files matched your search

+14 -6
View File
@@ -12,6 +12,7 @@ from nisse_dev.control import (
EXECUTING,
IDLE,
PLANNING_ARCH,
PR_OPEN,
AskHuman,
Orchestrator,
PlanningError,
@@ -52,6 +53,10 @@ class Harness:
return [m.text for m in self.transport.outbound]
def executed(h: Harness) -> bool:
return any(e["to"] == EXECUTING for e in h.events("state.transition"))
@pytest.fixture
def h(tmp_path: Path) -> Harness:
return Harness(tmp_path)
@@ -74,14 +79,16 @@ def test_use_project_submits_arch_plan_and_opens_gate(h: Harness) -> None:
assert opened["task_id"] is not None
def test_full_happy_path_to_executing(h: Harness) -> None:
def test_full_happy_path_through_execution(h: Harness) -> None:
h.transport.receive("don", "use demo")
h.transport.receive("don", "approve")
assert h.state == AWAIT_MILESTONE_SIGNOFF
h.transport.receive("don", "approve")
assert h.state == AWAIT_MILESTONE_SIGNOFF # approved, awaiting start
h.transport.receive("don", "start milestone 1")
assert h.state == EXECUTING
assert executed(h)
# The stub completes at once and waits in PR_OPEN, never in EXECUTING.
assert h.state == PR_OPEN
approvals = h.events("gate.approve")
assert [e["gate"] for e in approvals] == ["arch", "milestone-1"]
@@ -123,7 +130,7 @@ def test_start_milestone_requires_approval_first(h: Harness) -> None:
assert h.state == AWAIT_MILESTONE_SIGNOFF # it was rejection feedback
h.transport.receive("don", "approve rev-2")
h.transport.receive("don", "start milestone 1")
assert h.state == EXECUTING
assert executed(h)
def test_a_message_is_matched_as_stripped_but_recorded_as_sent(
@@ -231,15 +238,16 @@ def test_restart_mid_execution_continue_returns_to_the_approved_plan(
h.transport.receive("don", "use demo")
h.transport.receive("don", "approve")
h.transport.receive("don", "approve")
h.transport.receive("don", "start milestone 1")
assert h.state == EXECUTING
# A crash mid-milestone: the store still says EXECUTING.
state = h.store.get_state()
h.store.transition(EXECUTING, cause="test", project="demo", data=state.data)
h = Harness(tmp_path)
assert h.state == AWAIT_INPUT
h.transport.receive("don", "continue")
assert h.state == AWAIT_MILESTONE_SIGNOFF
assert "start milestone 1" in h.outbound_texts()[-1]
h.transport.receive("don", "start milestone 1")
assert h.state == EXECUTING
assert executed(h)
def test_restart_question_without_its_interrupted_state_still_replans(