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

+74 -4
View File
@@ -13,8 +13,10 @@ work that is already running: the transport's thread sets the cancel flag
directly, and the worker records the stop once the work has returned.
"""
import sys
import traceback
import uuid
from collections.abc import Iterable, Iterator, Mapping
from collections.abc import Callable, Iterable, Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any, Protocol
@@ -56,6 +58,11 @@ TRANSITIONS = {
}
# States that are only right while work is running. Once a job ends the agent
# must be somewhere it can wait for the operator, or every message would be
# answered "busy" until a restart.
_WORKING = (PLANNING_ARCH, MILESTONE_PLANNING, EXECUTING)
_BOOT_CHOICES = (
"reply 'continue' to pick up where the restart cut in, "
"or 're-plan' to plan the current step again"
@@ -310,7 +317,38 @@ class Orchestrator:
)
self._worker.submit(lambda: self._record(msg))
return
self._worker.submit(lambda: self._handle_now(msg))
self._worker.submit(
lambda: self._settled("your message", lambda: self._handle_now(msg))
)
def _settled(self, what: str, job: Callable[[], None]) -> None:
"""Run one job and make sure it ends in a state that can wait for the
operator, however it ended. An exception is reported rather than left
to the worker, which would only print it; a job left in a working
state is moved to AWAIT_INPUT with the choice a restart offers."""
error: Exception | None = None
try:
job()
except Exception as exc: # noqa: BLE001 -- reported below
error = exc
traceback.print_exception(exc, file=sys.stderr)
state = self._store.get_state()
current = state.state if state else IDLE
reason = f"{type(error).__name__}: {error}" if error else "no outcome"
try:
if current in _WORKING:
self._await_input(
cause=f"stranded in {current}: {reason}",
prompt=f"{what} ended mid-{current} ({reason}); {_BOOT_CHOICES}",
# The same choice as after a restart, answered the same way.
pending={"kind": "boot", "interrupted": current},
)
elif error is not None:
self._transport.send_message(
f"could not handle {what} ({reason}); still {current}"
)
except Exception as exc: # noqa: BLE001 -- nothing left to tell
traceback.print_exception(exc, file=sys.stderr)
def _record(self, msg: InboundMessage) -> None:
self._store.append_event(
@@ -375,6 +413,22 @@ class Orchestrator:
else:
self._transport.send_message("no active project; say 'use <project>'")
elif current in (AWAIT_ARCH_SIGNOFF, AWAIT_MILESTONE_SIGNOFF):
if not self._data.get("gate"):
# A sign-off state with no plan to sign off: replies could
# never be applied, so plan again from the operator's choice.
self._await_input(
cause=f"{current} without an open gate",
prompt=f"the plan awaiting sign-off is missing; {_BOOT_CHOICES}",
pending={
"kind": "boot",
"interrupted": (
PLANNING_ARCH
if current == AWAIT_ARCH_SIGNOFF
else MILESTONE_PLANNING
),
},
)
return
self._handle_gate_reply(msg, text)
elif current == PR_OPEN:
self._handle_pr_open(text)
@@ -568,7 +622,9 @@ class Orchestrator:
def _execute_milestone(self, milestone: int) -> None:
if self._executor is None:
# The stub completes at once, so it still leaves EXECUTING.
self._transport.send_message(f"[stub] executing milestone {milestone}")
self._publish_milestone(milestone)
return
self._transport.send_message(f"executing milestone {milestone}")
with self._long_work():
@@ -626,7 +682,11 @@ class Orchestrator:
thread, which waits for the worker so its cursor never moves past a
comment that has not been handled.
"""
self._worker.call(lambda: self._handle_plan_comment_now(comment))
self._worker.call(
lambda: self._settled(
"a plan comment", lambda: self._handle_plan_comment_now(comment)
)
)
def _handle_plan_comment_now(self, comment: Any) -> None:
if comment.author not in self._gitea_operators:
@@ -652,7 +712,11 @@ class Orchestrator:
code, so it is operator-scoped like any other (A4, and the same
reasoning C4 applies to tickets).
"""
self._worker.call(lambda: self._handle_review_comment_now(comment))
self._worker.call(
lambda: self._settled(
"a review comment", lambda: self._handle_review_comment_now(comment)
)
)
def _handle_review_comment_now(self, comment: Any) -> None:
if comment.author not in self._gitea_operators:
@@ -723,6 +787,12 @@ class Orchestrator:
def _publish_milestone(self, milestone: int) -> None:
if self._publisher is None:
# No PR to wait on, but PR_OPEN's commands still lead on.
self._transition(PR_OPEN, cause=f"[stub] milestone {milestone} done")
self._transport.send_message(
f"[stub] milestone {milestone} done; no publisher, so no PR — "
"say 'next milestone' or 'project complete'"
)
return
gate = self._data.get("gate") or {}
try: