ouroboros/tests/test_objective_repeat_counter.py
ndrew1337 621700b87a
fix: evolution objective-repeat loop (BUG3) + detached-HEAD orphan/diagnostic (BUG1/2) (#53)
* C0(bug3): SSOT canonical_objective_fingerprint + hoist _PLAN_REVIEW_SUFFIX

Leaf module ouroboros/evolution_fingerprint.py with the single objective-repeat
fingerprint (strip plan-review suffix, normalize, sha256[:16]) that all BUG3 layers
key off. Hoist the inline plan-review suffix from post_task_evolution into the shared
_PLAN_REVIEW_SUFFIX constant (byte-identical) so the append and the strip cannot drift.
Zero behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* C1(bug3): stamp per-cycle objective_fp into the evolution transaction

begin_evolution_transaction captures canonical_objective_fingerprint(campaign['objective'])
at cycle START into tx['objective_fp']. This is the SSOT per-cycle key the repeat counter
(C2) increments on and the absorb-clear pops; taken at cycle start because campaign['objective']
can be overwritten by a later promotion before the outcome is recorded. Sole production caller
is queue.py enqueue path, so every cycle (idle + post-task) is stamped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* C2(bug3): per-fingerprint objective-repeat counter + pause gate

Load-bearing fix. A cumulative per-fingerprint counter on the campaign dict (keyed on the
SSOT tx['objective_fp']) increments on every non-absorbing cycle and is cleared only on a
genuine absorb of that fingerprint. Unlike the consecutive-failures breaker (reset by ANY
non-failing cycle), this catches a blocked objective re-proposed NON-consecutively.

- helpers _bump/_clear_objective_repeat_count (setdefault-tolerant, skip empty fp)
- task-done outcome chain: bump on no_op/abandoned, clear on absorbed, waiting_for_restart pass
- ALL THREE durable absorb sites clear, and the durable abandoned / verified-no-commit sites
  bump (catches commit-but-never-absorbs): agent_startup_checks boot-reconcile + restart-verify
- pause-only gate in enqueue_evolution_task_if_needed (after the 3-fail breaker, before the
  next start) keyed on the SAME canonical_objective_fingerprint(campaign['objective']),
  OBJECTIVE_REPEAT_CAP=3, distinct owner escalation, no objective rewrite
- replay test of /tmp/b3 e09bb702: counts[apikey] reaches 3 at cycle 10 (was capped at 2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* C3(bug3) Layer B: detach + dropped-set on no_op/abandoned (never mark done)

A no_op/abandoned objective stays status=open in the backlog (the work is genuinely
unsolved); we only stop FEEDING it back to the evolution chooser. The bump/clear helpers now
also maintain campaign['dropped_objective_fps'] (Layer A input); the task-done abandoned
branch now pops post_task_backlog_id like no_op (it previously leaked the link into a later
absorbed cycle). close_backlog_items(done) stays exclusively at the absorb sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* C4(bug3) Layer A: feed CLOSED/DROPPED + active objective into the chooser prompt

The missing input that let a CLOSED objective be re-suggested. Adds two prompt sections and a
do-not-re-propose rule. _closed_objectives_digest reads the STRUCTURED ledger
(evolution_checkpoints.jsonl: cycle_outcome in {absorbed,abandoned,no_op} OR
outcome_axes.objective/review.outcome_tier=='blocked_with_evidence') — NOT patterns.md prose —
deduped by the SSOT fingerprint. P5-compatible: enriches context + a soft rule; the LLM still
owns the decision (the hard backstop is Layer C).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* G0(bug1/2): detached-HEAD -B fix + GIT_LOST_WORKTREE diagnostic (+cross-bug)

Applies the fix-proposal git.py.patch: on a detached HEAD, _repo_commit_push uses
`git checkout -B ctx.branch_dev HEAD` (preserve the in-flight commit) instead of a plain
checkout that orphans it; a clean tree right after that reconciliation returns a distinct
GIT_LOST_WORKTREE diagnostic instead of the misleading generic GIT_NO_CHANGES.

Cross-bug fix (council blocker): the diagnostic token now carries the _FAILED marker and
avoids the '⚠️ GIT_ERROR' prefix (which loop_tool_execution carves out to is_error=False), so
a lost-worktree cycle classifies as infra_failed and INCREMENTS evolution_consecutive_failures
instead of resetting it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* G0 §6(bug1-class): git_ops checkout -b -> -B + check=True (no silent swallow)

Same detached/branchless-HEAD class as BUG1, different surface (boot/UI reconcile). The
branch-absent path used `checkout -b` with check=False, which swallowed a 'branch exists'
error and proceeded with HEAD possibly detached/wrong; -B force-creates at HEAD and check=True
raises a real failure. (The post-checkout branch assert and the restart-verify branch assert
are documented as defense-in-depth follow-ups — they need test-aware wiring to not bypass the
git-command mocks.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* G0(a): integration test of the real _repo_commit_push detached-HEAD path

Exercises the actual ouroboros.tools.git code (not just the pure-git fix_verification.sh logic):
detached HEAD -> 'git checkout -B ouroboros HEAD' (never the orphaning plain checkout) and
came_from_detached_checkout=True into the stage cycle; on-branch -> plain checkout, no force-move.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Andrew <andgri200@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ouroboros <ouroboros@local.mac>
2026-06-23 23:51:28 +03:00

108 lines
4.7 KiB
Python

"""C2 (BUG 3): the per-fingerprint cumulative objective-repeat counter.
The decisive property the prior consecutive-streak design FAILED: an objective that recurs
NON-consecutively (interleaved with other no_op work) must still accumulate to the pause
threshold. Replays the /tmp/b3 campaign e09bb702 sequence.
"""
from ouroboros.evolution_fingerprint import canonical_objective_fingerprint as fp
from supervisor.evolution_lifecycle import (
_bump_objective_repeat_count,
_clear_objective_repeat_count,
)
# Representative per-cycle objectives from campaign e09bb702.
APIKEY = "Add a pre-flight ANTHROPIC_API_KEY check at the start of commit_reviewed"
REGMAP = "Add a registration-map doc comment in registry.py"
REGISTRY = "Add a blocked_objective registry to the evolution cycle entry point"
DEVMD = "Create an Adding a New Tool checklist section in DEVELOPMENT.md"
def _tx(objective):
"""A transaction shaped like begin_evolution_transaction's output (only objective_fp matters)."""
return {"objective_fp": fp(objective)}
def _counts(campaign):
return campaign.get("objective_repeat_counts") or {}
def _dropped(campaign):
return campaign.get("dropped_objective_fps") or []
def test_replay_tmp_b3_reaches_threshold_3_on_nonconsecutive_recurrence():
"""The API-KEY objective recurs at cyc 5,7,10,11 with DIFFERENT objectives at 6,8,9.
A consecutive-streak counter peaks at 2; the cumulative map must reach 3 at cyc 10."""
c = {} # a campaign with no counter field yet (setdefault must tolerate it)
# cyc4 ABSORBED (before the loop window): clearing an absent fp is a safe no-op
_clear_objective_repeat_count(c, _tx(APIKEY))
# cyc5 no_op API-KEY
_bump_objective_repeat_count(c, _tx(APIKEY))
assert _counts(c)[fp(APIKEY)] == 1
# cyc6 no_op REG-MAP (interleaved — must NOT reset the API-KEY tally)
_bump_objective_repeat_count(c, _tx(REGMAP))
# cyc7 no_op API-KEY
_bump_objective_repeat_count(c, _tx(APIKEY))
assert _counts(c)[fp(APIKEY)] == 2
# cyc8 no_op REGISTRY, cyc9 no_op DEVMD (interleaved)
_bump_objective_repeat_count(c, _tx(REGISTRY))
_bump_objective_repeat_count(c, _tx(DEVMD))
# cyc10 no_op API-KEY -> reaches 3, the gate threshold, BEFORE cyc11 is spent
_bump_objective_repeat_count(c, _tx(APIKEY))
assert _counts(c)[fp(APIKEY)] == 3, "cumulative counter must catch the non-consecutive loop"
# the interleaved objectives each sit at 1 and never interfere
assert _counts(c)[fp(REGMAP)] == 1
assert _counts(c)[fp(REGISTRY)] == 1
assert _counts(c)[fp(DEVMD)] == 1
def test_absorb_clears_only_the_absorbed_fingerprint():
c = {}
_bump_objective_repeat_count(c, _tx(APIKEY))
_bump_objective_repeat_count(c, _tx(APIKEY))
_bump_objective_repeat_count(c, _tx(REGMAP))
_clear_objective_repeat_count(c, _tx(APIKEY)) # genuine absorb on API-KEY
assert fp(APIKEY) not in _counts(c) # its tally is gone
assert _counts(c)[fp(REGMAP)] == 1 # the other objective is untouched
def test_success_immunity_a_different_objective_does_not_clear_the_loop():
"""A non-failing/absorbed cycle on a DIFFERENT objective must NOT reset the looping one."""
c = {}
_bump_objective_repeat_count(c, _tx(APIKEY))
_bump_objective_repeat_count(c, _tx(APIKEY))
_clear_objective_repeat_count(c, _tx(REGMAP)) # some other objective absorbs
assert _counts(c)[fp(APIKEY)] == 2 # the loop tally survives
def test_setdefault_tolerates_a_campaign_persisted_before_the_field_existed():
c = {"status": "active", "objective": APIKEY} # no objective_repeat_counts key
_bump_objective_repeat_count(c, _tx(APIKEY)) # must not KeyError
assert _counts(c)[fp(APIKEY)] == 1
def test_empty_fingerprint_is_never_bucketed():
c = {}
_bump_objective_repeat_count(c, {"objective_fp": ""}) # tx-less / empty objective
_bump_objective_repeat_count(c, {}) # missing key entirely
assert _counts(c) == {}
assert _dropped(c) == []
def test_layer_b_bump_records_dropped_fp_deduped():
c = {}
_bump_objective_repeat_count(c, _tx(APIKEY))
_bump_objective_repeat_count(c, _tx(APIKEY)) # again -> still a single entry
_bump_objective_repeat_count(c, _tx(REGMAP))
assert _dropped(c).count(fp(APIKEY)) == 1 # deduped, not appended twice
assert fp(REGMAP) in _dropped(c)
def test_layer_b_absorb_undrops_only_the_absorbed_fp():
c = {}
_bump_objective_repeat_count(c, _tx(APIKEY))
_bump_objective_repeat_count(c, _tx(REGMAP))
_clear_objective_repeat_count(c, _tx(APIKEY)) # API-KEY landed -> no longer do-not-re-propose
assert fp(APIKEY) not in _dropped(c)
assert fp(REGMAP) in _dropped(c) # the other stays dropped