Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ Exit code conventions:
- `0` success (or a replayed failure reproduced intentionally).
- `1` the run behaved differently, or the operation could not complete safely.
- `2` invalid, unreadable, or unresolvable input, or (for `run`) a check that
could not execute.
could not execute, or (for `minimize`) a replay that never reached the
failure the trace records.
- `3` `validate --verify-integrity` on a legacy bundle without integrity
metadata.

Expand Down Expand Up @@ -320,11 +321,17 @@ Exit codes:

- `0` minimization succeeded and produced a valid minimal trace.
- `1` minimization failed (no failure detected, or minimization error).
- `2` invalid input, unreadable trace, or unresolvable entrypoint.
- `2` invalid input, unreadable trace, or unresolvable entrypoint, or a replay
that never reached the failure the trace records.

Common errors:

- `Failed to detect original failure signature: trace replay completed successfully; no failure to minimize` - the trace does not fail when replayed.
- `Cannot minimize: the replay failed before reaching any recorded call ...` - the
recording fails after at least one dependency call and the replay consumed
none of them, so what it raised is not the recording's failure. Either the
fixture could not be built, or the change moved the failure earlier than the
recording. No bundle is written; exception type cannot decide between the two.
- `cannot resolve entrypoint ...` - the entrypoint specification is invalid or the module cannot be imported.
- `Minimized trace does not reproduce the original failure` - rare internal error during delta debugging.

Expand Down
8 changes: 8 additions & 0 deletions src/stepfork/cli/minimize.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from stepfork.minimize.delta_debug import create_minimized_trace
from stepfork.minimize.engine import MinimizationEngine, generate_report
from stepfork.minimize.models import MinimizationReport
from stepfork.minimize.signature import ReplayNeverReachedRecordedCall
from stepfork.trace.models import Trace

console = Console()
Expand Down Expand Up @@ -149,6 +150,13 @@ def minimize_command(
except KeyboardInterrupt:
console.print("\n[yellow]Minimization interrupted by user[/yellow]")
raise typer.Exit(1) from None
except ReplayNeverReachedRecordedCall as exc:
# The run could not reach the failure it was asked to minimize, so it
# says nothing about the trace. Exit 2 separates that from a failed
# minimization (exit 1) and from invalid input, which prints its own
# message. See issue #22.
console.print(f"\n[yellow]✗ Cannot minimize:[/yellow] {exc}")
raise typer.Exit(2) from None
except Exception as exc:
console.print(
f"\n[red]Minimization failed:[/red] {exc}",
Expand Down
5 changes: 5 additions & 0 deletions src/stepfork/minimize/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
MinimizationResult,
)
from stepfork.minimize.signature import (
ReplayNeverReachedRecordedCall,
detect_failure_signature,
format_signature,
)
Expand Down Expand Up @@ -110,6 +111,10 @@ def minimize(self) -> MinimizationResult:
timeout_seconds=self.timeout_seconds,
)
self.total_replay_attempts += 1
except ReplayNeverReachedRecordedCall:
# A refusal, not a failed minimization: the caller must be able to
# tell them apart by exit status, so it travels untouched.
raise
except Exception as exc:
return MinimizationResult(
success=False,
Expand Down
75 changes: 70 additions & 5 deletions src/stepfork/minimize/signature.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,24 @@
ReplaySession,
)
from stepfork.trace.canonical import canonical_json_bytes
from stepfork.trace.models import JsonValue, Trace
from stepfork.trace.models import EventType, JsonValue, Trace


class FailureDetectionError(Exception):
"""Raised when failure signature cannot be determined."""


class ReplayNeverReachedRecordedCall(FailureDetectionError):
"""Raised when the replay failed without consuming any recorded call.

The recording says the failure happened after at least one dependency call,
so a replay that consumed none of them never reached the place the trace
fails. The failure it raised instead is not the recording's failure, and a
bundle minimized from it would be derived from something that did not
happen. See issue #22.
"""


def detect_failure_signature(
trace: Trace,
entrypoint: str,
Expand Down Expand Up @@ -67,15 +78,16 @@ def detect_failure_signature(
except Exception as exc:
raise FailureDetectionError(f"cannot create replay session: {exc}") from exc

# Attempt replay and capture the failure
# Attempt replay and capture the failure. The session's divergence and
# consumed-call count are read *after* the attempt in every outcome: an
# exception can leave state worth reporting, and the success path is not
# the only one that carries a divergence.
exception: BaseException | None = None
actual_output: Any = None
divergence_error: ReplayError | None = None

try:
with session as replay:
with session:
actual_output = entrypoint_fn()
divergence_error = replay.divergence
except ReplayPolicyError as exc:
# The replay harness refused execution (forbidden/manual/derived mode,
# unauthorized live tool). The agent never ran its check, so this is
Expand All @@ -95,13 +107,38 @@ def detect_failure_signature(
# Application exception during replay
exception = exc

divergence_error: ReplayError | None = session.divergence
consumed = len(session.calls) - session.pending

# A policy refusal the agent swallowed internally still poisons the
# outcome; refuse instead of recording it as a divergence signature.
if isinstance(divergence_error, ReplayPolicyError):
raise FailureDetectionError(
f"replay policy refused the attempt: {divergence_error}"
) from divergence_error

# The recording fails after at least ``calls_before`` dependency calls. A
# replay that consumed none of them never reached that place, so whatever
# it raised is not the recording's failure -- and minimizing from it writes
# a bundle derived from something that did not happen. Exception type
# cannot decide this: a setup error and a change that moves the failure
# earlier are the same observation. See issue #22.
calls_before = _calls_before_recorded_failure(trace)
if (
exception is not None
and consumed == 0
and calls_before >= 1
and divergence_error is None
):
raise ReplayNeverReachedRecordedCall(
"the replay failed before reaching any recorded call "
f"({_describe_attempt(exception, actual_output)}), and the trace "
f"records its failure after {calls_before} dependency call(s): "
"this run never reached the recorded failure. Either the fixture "
"could not be built, or the change moved the failure earlier than "
"the recording. No bundle written."
)

# Determine the failure signature based on what happened
if exception is not None:
return _signature_from_exception(exception, divergence_error)
Expand All @@ -120,6 +157,34 @@ def detect_failure_signature(
)


def _calls_before_recorded_failure(trace: Trace) -> int:
"""Count dependency calls that precede the trace's own error event.

A recording that fails during a dependency call still has that call's
``tool_call``/``llm_request`` event before its ``error`` event, so this
counts the calls a faithful replay has to reach before it can fail the
way the recording did. A trace with no error event returns 0: it records
no failure, so nothing here can be compared against it.
"""
calls = 0
for event in trace.events:
if event.type is EventType.ERROR:
return calls
if event.type in (EventType.LLM_REQUEST, EventType.TOOL_CALL):
calls += 1
return 0


def _describe_attempt(
exception: BaseException | None,
actual_output: Any,
) -> str:
"""Name what the attempt did, for a refusal message a reader can act on."""
if exception is None:
return f"the entrypoint returned {actual_output!r}"
return f"{type(exception).__name__}: {exception}"


def _signature_from_exception(
exception: BaseException,
divergence: ReplayError | None,
Expand Down
Loading
Loading