Source code for ralph.agents.invoke._open_code_resumable_exit_error
"""OpenCodeResumableExitError — deterministic rc=0 classification.
R7 (Trustworthy Idle Watchdog spec) requires that ambiguous agent
exits are diagnosed, classified deterministically, and handled. The
ambiguous case this module owns:
The agent subprocess exits cleanly (rc=0) BUT the session did
not produce a completion artifact AND did not call
``declare_complete``. The legacy
``FailureClassifier.classify`` path would surface this as an
ambiguous warning (the log line
``[flagged_for_review=true]`` was observed in the wild before
the deterministic classification was added).
Deterministic classification contract (single source of truth):
1. ``OpenCodeResumableExitError`` is a typed-cause exception that
the failure classifier MUST recognize BEFORE the broader
``AgentInvocationError`` branch. The classifier's typed-cause
branch lives in ``ralph/recovery/failure_classifier.py`` under
``FailureClassifier._categorize_exc`` (the
``OpenCodeResumableExitError`` branch precedes the
``AgentInvocationError`` branch). This ordering is what makes
the classification deterministic: the exception NEVER falls
through to ``FailureCategory.AMBIGUOUS``.
2. The classifier maps ``OpenCodeResumableExitError`` to
``FailureCategory.AGENT`` (NOT ``AMBIGUOUS``) and lifts the
typed ``resumable_session_id`` attribute so the recovery
controller can resume the existing session. The classifier's
typed-cause branch is the only path that consults
``getattr(exc, "resumable_session_id", None)``; the broader
``AgentInvocationError`` branch does not see this attribute.
3. ``recovery_action_for_failure_reason("OpenCodeResumableExitError", ...)``
in ``ralph/agents/invoke/_session_resume.py`` returns:
* ``"resume"`` when ``has_prior_session=True`` -- the
existing session carries forward via
``resumable_session_id``.
* ``"fresh"`` when ``has_prior_session=False`` -- no prior
session to resume; the runner constructs a new session
via ``fresh_session_options(opts)``.
The resume and fresh paths are FUNCTION-SEPARATE -- they
never share code. Watchdog-driven kills NEVER start a new
session as if beginning new work; only deliberate phase
transitions may.
4. NO log line containing ``flagged_for_review=true`` is emitted
on this exception path (the previous pass added the
deterministic classification precisely to remove the
repeated ambiguous-warning spam).
R7 root-cause diagnostic surface (NEW):
In addition to ``resumable_session_id`` (the resume hook), the
exception carries four NEW keyword-only diagnostic attributes
that capture the watchdog state at the moment of the rc=0 exit.
The four attributes are preserved on the exception for
programmatic access (an on-call operator reads them via
``exc.last_observed_tool_call``, ``exc.last_evidence_summary``,
``exc.elapsed_seconds``, ``exc.transcript_tail``) without
walking the exception chain or re-invoking the watchdog.
Diagnostic attributes (all keyword-only with default ``None`` /
``()`` so legacy two-arg callers are unaffected):
* ``last_observed_tool_call``: ``str | None`` -- the most recent
parsed tool-call verb (e.g. ``"read_file"``,
``"tool_use:Edit"``) from the line reader layer. ``None`` if
no tool call was recorded. SURFACED IN MESSAGE.
* ``last_evidence_summary``: ``str | None`` -- the watchdog's
``last_evidence_summary(now).to_dict_list()`` str-coerced
payload (the per-channel evidence summary at the moment
of the exit). ``None`` if no evidence summary was captured.
PROGRAMMATIC-ONLY: not rendered into the exception message
to avoid unbounded message size from a verbose evidence
payload.
* ``elapsed_seconds``: ``float | None`` -- the watchdog's
``idle_elapsed_seconds(clock.monotonic())`` value at the
moment of the exit. ``None`` if no elapsed value was
captured. SURFACED IN MESSAGE.
* ``transcript_tail``: ``tuple[str, ...]`` -- the last 10
lines of the bounded output transcript at the moment of the
exit (hard-capped via tuple slice in the line-reader
construction site). Default ``()`` for legacy callers.
PROGRAMMATIC-ONLY: not rendered into the exception message
to avoid unbounded message size from a long transcript.
The exception message embeds ONLY the two bounded-size
diagnostic fields -- ``last_observed_tool_call`` and
``elapsed_seconds`` -- so a logged traceback remains concise
and human-readable. The full four-attribute surface is preserved
on the exception for callers that need the verbose evidence
summary or transcript tail. Format:
``OpenCodeResumableExitError: Agent 'opencode' failed
with code 0: agent session exited without required
completion evidence (no artifact, no declare_complete)
[last_tool_call=read_file, elapsed=420.0s]``
The ``[last_tool_call=..., elapsed=...]`` suffix is omitted when
both diagnostic attributes are ``None`` (legacy callers see the
original message unchanged); a partial suffix is rendered when
exactly one attribute is set. ``last_evidence_summary`` and
``transcript_tail`` are NEVER rendered into the message -- they
are programmatic-only by design (see per-attribute notes above).
Lock-in regression test:
``tests/recovery/test_opencode_resumable_exit_classification.py``
proves the four contract points above. The NEW diagnostic
surface is pinned by ``tests/recovery/test_opencode_resumable_exit_classifier.py``
tests ``test_diagnostic_context_carried`` and
``test_backward_compatible_construction``.
References:
- ``ralph/agents/invoke/_session_resume.py`` for the
recovery-action contract.
- ``ralph/recovery/failure_classifier.py:_categorize_exc`` for
the typed-cause branch ordering.
- ``ralph/agents/invoke/_errors.py`` for the ``AgentInvocationError``
base class hierarchy.
- ``ralph/agents/invoke/_completion.py:_CompletionCheckOptions``
for the dataclass that threads the diagnostic fields.
"""
from __future__ import annotations
from ralph.agents.invoke._errors import AgentInvocationError
[docs]
class OpenCodeResumableExitError(AgentInvocationError):
"""Raised when an agent session exits without required completion evidence.
The session can be continued; the runner maps this into a session-preserving retry.
The exception carries the captured transport-level session id via
:attr:`resumable_session_id` so the failure classifier's typed-cause
branch can thread it through the ``FailureCategory.AGENT`` resume
path (see module docstring for the full contract).
R7 root-cause diagnostic surface (NEW): the exception also carries
four keyword-only diagnostic attributes -- ``last_observed_tool_call``,
``last_evidence_summary``, ``elapsed_seconds``, and
``transcript_tail`` -- that capture the watchdog state at the
moment of the rc=0 exit. The four attributes are preserved on
the exception for programmatic access; the two bounded-size
fields (``last_observed_tool_call`` and ``elapsed_seconds``) are
ALSO surfaced in the exception message so a logged traceback is
actionable. The verbose fields (``last_evidence_summary`` and
``transcript_tail``) are programmatic-only by design -- not
rendered into the message to avoid unbounded message size.
"""
def __init__(
self,
agent_name: str,
session_id: str | None = None,
*,
last_observed_tool_call: str | None = None,
last_evidence_summary: str | None = None,
elapsed_seconds: float | None = None,
transcript_tail: tuple[str, ...] = (),
) -> None:
self.resumable_session_id = session_id
self.last_observed_tool_call = last_observed_tool_call
self.last_evidence_summary = last_evidence_summary
self.elapsed_seconds = elapsed_seconds
self.transcript_tail = transcript_tail
base_message = (
"agent session exited without required completion evidence "
"(no artifact, no declare_complete)"
)
diagnostic_suffix_parts: list[str] = []
if last_observed_tool_call is not None:
diagnostic_suffix_parts.append(f"last_tool_call={last_observed_tool_call}")
if elapsed_seconds is not None:
diagnostic_suffix_parts.append(f"elapsed={round(elapsed_seconds, 1)}s")
if diagnostic_suffix_parts:
full_message = base_message + " [" + ", ".join(diagnostic_suffix_parts) + "]"
else:
full_message = base_message
super().__init__(agent_name, 0, full_message)
__all__ = ["OpenCodeResumableExitError"]