* fix(loop): break Ralph-loop when a bridge-mode pending run goes stale (#556)
In `--loop` mode the daemon defaults to EVOLVE_BRIDGE=true (since v1.85.0),
where a sub-agent solidifies asynchronously. If that sub-agent produces no
changes, crashes, or the daemon restarts onto a stale pending state,
`last_solidify.run_id` never catches up to `last_run.run_id`, so
`isPendingSolidify()` stays true and the gate sleeps forever. The existing
auto-reject safety net only ran when EVOLVE_BRIDGE=false, leaving the default
bridge path with no escape.
Rather than unconditionally rejecting every pending run after evolve.run()
(which would wrongly discard runs a live sub-agent is still working, and
re-break the #96 "33 days of zero events" failure mode), this gates the
auto-reject on a staleness TTL: a pending run is only cleared once it is older
than the sub-agent's own hard ceiling (cycleTimeoutMs, default 45 min), at
which point it cannot still be running. A live sub-agent's in-flight pending
state is left untouched.
- add pendingRunAgeMs(state, now): age from last_run.created_at/started_at,
returns null (never force-reject) on missing/unparseable/future timestamps
- add rejectStalePendingRun(): clears the run with a distinct, auditable reason
(stale_pending_no_solidify_autoreject_no_rollback), no git rollback
- gate the Ralph-loop sleep on the TTL; on staleness, clear and run a fresh
cycle. New env EVOLVER_PENDING_STALE_MS overrides (default = cycleTimeoutMs)
- tests: unit coverage for both helpers + a black-box daemon test proving the
bridge=true default escapes the loop (verified to fail when the fix is reverted)
Closes#556
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(loop): address Bugbot review on #556 stale-pending reject
Two findings from Cursor Bugbot on PR #559:
- High: rejectStalePendingRun() wrote a rejection whenever last_run.run_id
existed, without re-checking pending status. If the sub-agent solidified
between the gate's age snapshot and this write, a successful solidify could
be overwritten with rejected:true. Now re-checks isPendingSolidify() under
its own fresh read and refuses if no longer pending (TOCTOU guard).
- Medium: the gate fell through to run a fresh cycle regardless of whether
rejectStalePendingRun() succeeded. A failed write left the run pending while
the loop started a new cycle on top of it. Now only falls through when the
reject actually took (cleared===true); otherwise sleeps + continues.
Adds a regression test asserting an already-solidified state is left verbatim.
Full loopMode suite 30/30.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(loop): TTL defaults OFF when cycle timeout is disabled (Bugbot #559)
Bugbot Medium on the round-2 commit: pendingStaleMs defaulted to cycleTimeoutMs
even when EVOLVER_CYCLE_TIMEOUT_ENABLED=false. With the hard ceiling disabled a
sub-agent may legitimately run past 45 min, so a 45-min default TTL could reject
an in-progress async solidify and stack a new evolve.run() on top.
Default the TTL to the cycle ceiling only when that ceiling is actually enforced
(cycleTimeoutEnabled). Otherwise default to 0 = staleness auto-reject OFF; the
user can still opt in explicitly via EVOLVER_PENDING_STALE_MS. The gate already
treats pendingStaleMs <= 0 as "TTL check off" and falls back to the original
sleep behavior.
Adds a black-box regression: with timeout disabled and no explicit TTL, a 1h-old
pending run is NOT auto-rejected. Full loopMode suite 31/31.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: autogame-17 <autogame-17@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>