Never leave the agent stranded in a working state
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 1m20s
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:
1 parent
1ccea20a10
commit
92e5452161
3 files changed
+325
-10
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user