diff --git a/backend/core/ouroboros/governance/candidate_generator.py b/backend/core/ouroboros/governance/candidate_generator.py index 8ef4bf5fd0..e2494f3090 100644 --- a/backend/core/ouroboros/governance/candidate_generator.py +++ b/backend/core/ouroboros/governance/candidate_generator.py @@ -1132,6 +1132,22 @@ def fallback_skip_gate_enabled() -> bool: return False +def _dw_autarky_enabled() -> bool: + """Slice 225 Phase 2 master. Default-TRUE — when the Claude fallback breaker + is OPEN/HALF_OPEN (terminal_quota / out-of-credits / transport), STANDARD and + COMPLEX ops keep the DW primary on the full op budget instead of severing it + at the 30s/75s reflex cap into a dead lane (the live GOAL-001::file-00 + generation_failed wedge). Sibling to the P2.1 IMMEDIATE-route gate above, for + the STANDARD/COMPLEX primary-budget path. Operator force-off with =0. NEVER + raises — fail-closed to legacy cascade.""" + try: + return os.environ.get( + "JARVIS_DW_AUTARKY_ENABLED", "true", + ).strip().lower() in ("1", "true", "yes", "on") + except Exception: # noqa: BLE001 + return False + + def immediate_reroute_to_dw( *, dw_is_primary: bool, @@ -4945,9 +4961,36 @@ async def _call_primary( _force_batch = _slice36_should_force_batch(context) except Exception: # noqa: BLE001 — defensive, legacy budget _force_batch = False + # Slice 225 Phase 2 — Sovereign DW Autarky. Read the Claude fallback + # breaker (read-only, no probe side effect — same _claude_breaker_open + # predicate the Slice 127 P2.1 IMMEDIATE reroute uses). When the + # fallback lane is OPEN/HALF_OPEN (incl. terminal_quota / out-of- + # credits), there's no live lane to sever DW into — give DW the full + # runway instead of the 30s/75s reflex cap. Gated default-TRUE; + # OFF (or breaker CLOSED) is the byte-identical legacy cascade. + _fallback_dead = False + if _dw_autarky_enabled(): + try: + from backend.core.ouroboros.governance.doubleword_provider import ( + _claude_breaker_open as _autarky_breaker_open, + ) + _fallback_dead = _autarky_breaker_open() + except Exception: # noqa: BLE001 — fail-closed to legacy cascade + _fallback_dead = False primary_budget = self._compute_primary_budget( remaining, model_id=model_id, force_batch=_force_batch, + fallback_dead=_fallback_dead, ) + if _fallback_dead and primary_budget > _PRIMARY_MAX_TIMEOUT_S: + logger.warning( + "[CandidateGenerator] ⚡ DW AUTARKY ENGAGED: Claude fallback " + "breaker OPEN — granting DW the full %.1fs budget (vs %.1fs " + "reflex cap), no dead-lane handoff. route=%s op=%s model=%s", + primary_budget, _PRIMARY_MAX_TIMEOUT_S, + getattr(context, "provider_route", "?"), + getattr(context, "op_id", "?")[:16], + model_id or "(unspecified)", + ) # Slice 34 Phase 2 — dispatch profiler (default OFF; zero # overhead when disabled). Records the sem-wait + budget # stages into the per-op summary; STAGE_PROVIDER_GENERATE @@ -6486,6 +6529,7 @@ def _compute_primary_budget( *, model_id: str = "", force_batch: bool = False, + fallback_dead: bool = False, ) -> float: """Deterministic Tier 1 primary budget with fallback reserve + Tier 3 cap. @@ -6541,6 +6585,24 @@ def _compute_primary_budget( batch_cap = _envf_or_default("JARVIS_DW_BATCH_TIMEOUT_S", 300.0) return max(min(total_s, batch_cap), 0.0) + # Slice 225 Phase 2 — Sovereign DW Autarky. When the Claude fallback + # lane is unreliable (breaker OPEN/HALF_OPEN — incl. the terminal_quota + # / out-of-credits economic refusal), there is NO live fallback to hand + # off to. Severing DW at the 30s/75s reflex cap only accelerates + # exhaustion into a dead lane — the live-soak GOAL-001::file-00 wedge: + # DW cut at 30s -> Claude 400 "credit balance too low" -> EXHAUSTION, + # generation_failed, no patch ever produced. Give DW the full remaining + # runway up to a cost-safety ceiling instead (default 180s = the COMPLEX + # generation window). Mirrors the force_batch precedent directly above + # ("Claude disabled -> no fallback to reserve -> full runway"). The + # caller stamps fallback_dead from the read-only _claude_breaker_open + # predicate; default False is byte-identical to the legacy cascade. + if fallback_dead: + autarky_cap = _envf_or_default( + "JARVIS_DW_AUTARKY_MAX_BUDGET_S", 180.0, + ) + return max(min(total_s, autarky_cap), 0.0) + fb_reserve = min(_FALLBACK_MIN_RESERVE_S, total_s * 0.35) # Slice 28 Phase 2 — adaptive Tier 3 cap for heavy models diff --git a/backend/core/ouroboros/governance/doubleword_provider.py b/backend/core/ouroboros/governance/doubleword_provider.py index ab4615cf62..3a5ca1d00f 100644 --- a/backend/core/ouroboros/governance/doubleword_provider.py +++ b/backend/core/ouroboros/governance/doubleword_provider.py @@ -2324,6 +2324,35 @@ def _s194_on_abandoned( except Exception: # noqa: BLE001 pass + # Slice 227 — context-aware hedge governor. When the Iron Gate + # will demand exploration for this op (the SAME predicate that + # re-opens the tool loop in Slice 226), the batch arm (no tool + # loop) must not pre-empt the exploring RT arm — its un-explored + # candidate would fail the exploration floor (the live + # GOAL-001::file-00 layer-3 bug). One source of truth across the + # capability, security, and concurrency planes. Rupture fallback + # is fully preserved (batch is buffered, used iff RT ruptures). + from backend.core.ouroboros.governance.dw_transport_hedge import ( + hedge_gate_aware_enabled as _s227_governor_on, + ) + _s227_prefer_fast = False + if _s227_governor_on(): + try: + from backend.core.ouroboros.governance.exploration_engine import ( # noqa: E501 + exploration_gate_demands_tools as _s227_gate_demands, + ) + _s227_prefer_fast = _s227_gate_demands( + str(getattr(context, "task_complexity", "")), + ) + except Exception: # noqa: BLE001 — fail-open to legacy race + _s227_prefer_fast = False + if _s227_prefer_fast: + logger.warning( + "[Cortex] ⚡ HEDGE GOVERNOR: op needs Iron-Gate " + "exploration — batch arm held speculative, RT arm (tool " + "loop) gets the slot unless it ruptures (op=%s)", + (getattr(context, "op_id", "?") or "?")[:16], + ) return await hedged_race( lambda: self._generate_realtime( context, deadline, prompt_override=prompt_override, @@ -2336,6 +2365,7 @@ def _s194_on_abandoned( stable_label="batch", on_outcome=_s190_hedge_outcome, on_abandoned=_s194_on_abandoned, + prefer_fast=_s227_prefer_fast, ) try: # Slice 9.1 — thread repair_context for L2 single-shot @@ -2754,12 +2784,46 @@ async def _generate_realtime( # (the simple-background half of the v40b deadlock would otherwise # remain: loop runs, tools suppressed). Env-gated + BACKGROUND-only # -> byte-identical legacy when Claude is enabled / flag off. - if background_is_terminal_worker(str(_route)): - _will_skip_tools = (_complexity == "trivial") - else: - _will_skip_tools = ( - _complexity in ("trivial", "simple") - or should_skip_venom_for_route(str(_route)) + # Slice 226 — Iron Gate / provider capability alignment. The base + # tool-skip decision (BG-terminal-worker vs complexity/route skip) is + # now computed by the shared exploration_engine predicate so the + # capability plane (tools available) can never contradict the security + # plane (exploration floor). When the Iron Gate will demand exploration + # for a ``simple`` op on a venom-eligible route, the loop stays ON — + # closing the live GOAL-001::file-00 catch-22 (simple -> tools skipped + # -> exploration_insufficient -> guaranteed rejection). trivial stays + # exempt; BACKGROUND/SPECULATIVE keep their preload-credit skip. + from backend.core.ouroboros.governance.exploration_engine import ( + compute_tool_loop_suppressed as _s226_compute_skip, + exploration_gate_demands_tools as _s226_gate_demands, + ) + _s226_is_bg_tw = background_is_terminal_worker(str(_route)) + _s226_route_skip = should_skip_venom_for_route(str(_route)) + _base_skip = _s226_compute_skip( + complexity=str(_complexity), + route=str(_route), + is_bg_terminal_worker=_s226_is_bg_tw, + has_repair_context=False, + ) + _will_skip_tools = _base_skip + # Slice 226 observability (operator pref: escalate capability anomalies + # LOUD, never silent re-route). When the alignment override re-opened + # the loop for a complexity that the cost heuristic would have stripped + # (simple, venom-eligible route, not BG-terminal-worker), flash it. + if ( + not _base_skip + and not _s226_is_bg_tw + and not _s226_route_skip + and str(_complexity).strip().lower() == "simple" + and _s226_gate_demands(str(_complexity)) + ): + logger.warning( + "[DoublewordProvider] ⚡ CAPABILITY ALIGNMENT: Iron Gate floor " + "demands exploration for a 'simple' op — re-opened the Venom " + "tool loop (read_file/search_code) the cost heuristic would " + "have withheld. route=%s op=%s", + _route, + (getattr(context, "op_id", "?") or "?")[:16], ) # ────────────────────────────────────────────────────────────── # Slice 9 — L2 single-shot fast path (DW mirror) diff --git a/backend/core/ouroboros/governance/dw_transport_hedge.py b/backend/core/ouroboros/governance/dw_transport_hedge.py index fa5e801530..c5c322b3dd 100644 --- a/backend/core/ouroboros/governance/dw_transport_hedge.py +++ b/backend/core/ouroboros/governance/dw_transport_hedge.py @@ -27,6 +27,16 @@ def transport_hedge_enabled() -> bool: ) +def hedge_gate_aware_enabled() -> bool: + """Slice 227 master — the context-aware hedge governor. Default **TRUE**: + when an op faces the Iron Gate exploration floor, the batch arm is held + speculative so the tool-using RT arm gets the slot (rupture fallback intact). + OFF restores the byte-identical legacy FIRST_COMPLETED race. NEVER raises.""" + return os.environ.get( + "JARVIS_HEDGE_GATE_AWARE_ENABLED", "true", + ).strip().lower() in ("1", "true", "yes", "on") + + def _storm_threshold() -> float: try: raw = os.environ.get("JARVIS_DW_STORM_SKIP_THRESHOLD", "").strip() @@ -57,6 +67,7 @@ async def hedged_race( on_abandoned: Optional[ Callable[[Optional[BaseException], Optional[BaseException]], None] ] = None, + prefer_fast: bool = False, ) -> Any: """Race ``fast`` (RT) against ``stable`` (batch). Return the FIRST successful result; cancel the loser aggressively. A ``fast`` failure that ``is_rupture`` returns True for is swallowed so @@ -72,7 +83,16 @@ async def hedged_race( both arms resolved, neither succeeded — passing each arm's captured exception (None for an arm that was cancelled / never errored). The caller's triage engine classifies the pair to confirm a hard model/endpoint blockage and rotate candidates. Best-effort: a sink error never - changes the raise behavior; the abandoned race still raises its last exception.""" + changes the raise behavior; the abandoned race still raises its last exception. + + Slice 227 — ``prefer_fast`` (the context-aware hedge governor). When True, a winning STABLE + (batch) result does NOT pre-empt the race: it is held in a speculative buffer and the race + keeps waiting for the FAST (RT) arm. This is the gate-aware mode — the RT arm runs the Venom + tool loop (exploration), the batch arm does not, so an un-explored batch candidate that + arrives first would fail the Iron Gate's exploration floor (the live GOAL-001::file-00 layer-3 + bug). The buffered batch is used ONLY if the RT arm then ruptures / fails / yields no success, + so the hedge's rupture-protection guarantee is fully preserved. ``prefer_fast=False`` (default) + is byte-identical to the legacy FIRST_COMPLETED race.""" loop = asyncio.get_event_loop() t_fast = loop.create_task(fast()) t_stable = loop.create_task(stable()) @@ -81,6 +101,8 @@ async def hedged_race( fast_exc: Optional[BaseException] = None stable_exc: Optional[BaseException] = None fast_ruptured = False + _UNSET = object() + buffered_stable: Any = _UNSET # Slice 227 speculative buffer (prefer_fast) def _report(winner_label: str) -> None: if on_outcome is not None: @@ -89,10 +111,27 @@ def _report(winner_label: str) -> None: except Exception: # noqa: BLE001 — telemetry never breaks the race pass + async def _claim(result: Any, winner_label: str) -> Any: + # Cancel any still-pending loser + await its unwind, then report + return. + for other in pending: + other.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + _report(winner_label) + return result + try: while pending: done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED) - for t in done: + # Slice 227 — in prefer_fast mode, process the FAST arm first within a + # batch where both completed, so a co-completed RT success is taken + # over a stable success. Legacy (prefer_fast=False) keeps the original + # arbitrary set iteration → byte-identical. + done_iter = ( + sorted(done, key=lambda t: 0 if t is t_fast else 1) + if prefer_fast else done + ) + for t in done_iter: try: result = t.result() except asyncio.CancelledError: @@ -102,23 +141,41 @@ def _report(winner_label: str) -> None: # Slice 194 — capture per-arm so a dual failure can be triaged. if t is t_fast: fast_exc = exc + if is_rupture(exc): + fast_ruptured = True + # Slice 227 — the RT arm failed. If we're holding a + # speculative batch result (prefer_fast), claim it now — + # the rupture/failure fallback the hedge exists for. + if buffered_stable is not _UNSET: + return await _claim(buffered_stable, stable_label) + # otherwise wait for the stable arm (still pending) + continue else: stable_exc = exc # a fast-path rupture is non-fatal — let the stable path keep racing - if t is t_fast and is_rupture(exc): - fast_ruptured = True - continue - # a non-rupture fast error or a stable error: if the other is still - # pending, keep waiting; else propagate below + # (legacy comment preserved); a stable error waits for the other arm. continue else: - # FIRST SUCCESS — cancel the loser aggressively + await its unwind - for other in pending: - other.cancel() - if pending: - await asyncio.gather(*pending, return_exceptions=True) - _report(fast_label if t is t_fast else stable_label) - return result + # SUCCESS. + if ( + prefer_fast + and t is t_stable + and buffered_stable is _UNSET + and t_fast in pending + ): + # Batch won the race, but this op needs the RT arm's + # exploration to clear the Iron Gate. Hold batch in the + # speculative buffer and keep waiting for RT — do NOT + # cancel it. RT success supersedes; RT rupture falls back. + buffered_stable = result + continue + # FIRST usable SUCCESS — cancel the loser + return. + return await _claim(result, fast_label if t is t_fast else stable_label) + # Slice 227 — race drained with no live success. A buffered batch result + # (RT never produced a success) is still a valid candidate — use it. + if buffered_stable is not _UNSET: + _report(stable_label) + return buffered_stable # both finished without a success if last_exc is not None: # Slice 194 — the race was ABANDONED (no winner). Hand both arms' diff --git a/backend/core/ouroboros/governance/exploration_engine.py b/backend/core/ouroboros/governance/exploration_engine.py index 1281bba848..53299dbd4c 100644 --- a/backend/core/ouroboros/governance/exploration_engine.py +++ b/backend/core/ouroboros/governance/exploration_engine.py @@ -451,6 +451,114 @@ def _read_int_env(name: str, default: int) -> int: return int(default) +# --------------------------------------------------------------------------- +# Slice 226 — Iron Gate / provider capability alignment +# --------------------------------------------------------------------------- +# +# The exploration gate (orchestrator.py) enforces a per-complexity floor of +# >=1 exploration call ("read the target file") for every NON-trivial op when +# JARVIS_EXPLORATION_GATE is on. Independently, the DW/Prime providers skip the +# Venom tool loop for ``complexity in {trivial, simple}`` as a cost optimization +# — denying the model the read_file/search_code channels it needs to satisfy +# that floor. A ``simple`` op on a venom-eligible route (the live +# GOAL-001::file-00) is thus an unwinnable catch-22: no tools, then rejected for +# not using them (``exploration_insufficient: 0/1``). The intended escape hatch +# (preloaded-prompt credit) silently fails when the target file is too large to +# inline (semantic_index.py = 3246 lines). +# +# These two predicates are the single source of truth both providers consult so +# the capability plane (tools available) and the security plane (exploration +# required) can never contradict. + +_ENV_GATE_ALIGNMENT = "JARVIS_GATE_ALIGNMENT_ENABLED" + + +def gate_alignment_enabled() -> bool: + """Slice 226 master. Default-TRUE; OFF restores the byte-identical legacy + complexity-based tool-skip (simple -> skip). NEVER raises.""" + try: + return os.environ.get(_ENV_GATE_ALIGNMENT, "true").strip().lower() in ( + "1", "true", "yes", "on", + ) + except Exception: # noqa: BLE001 + return False + + +def exploration_gate_demands_tools( + complexity: str, *, gate_enabled: object = None, +) -> bool: + """True iff the Iron Gate exploration floor will demand >=1 exploration + call for an op of this complexity — so a provider MUST keep the Venom tool + loop available rather than skipping it on a complexity heuristic. + + Mirrors the orchestrator's gate-enable condition: ``JARVIS_EXPLORATION_GATE`` + on AND complexity != 'trivial' (trivial is exempt; simple+ carry the >=1 + "read the target file" floor). ``gate_enabled`` may be passed explicitly to + avoid the env read (testing / callers that already resolved it). NEVER + raises — fail-closed to False (legacy, no tools forced).""" + try: + if gate_enabled is None: + gate_enabled = ( + os.environ.get("JARVIS_EXPLORATION_GATE", "true") + .strip().lower() == "true" + ) + if not gate_enabled: + return False + return str(complexity or "").strip().lower() not in ("trivial", "") + except Exception: # noqa: BLE001 + return False + + +def compute_tool_loop_suppressed( + *, + complexity: str, + route: str, + is_bg_terminal_worker: bool, + has_repair_context: bool, + gate_enabled: object = None, +) -> bool: + """Unified Venom-tool-loop suppression decision (Slice 226). + + Faithfully mirrors the historical DW-provider logic + (doubleword_provider ``_generate_realtime``): + + * BG terminal-worker (Claude disabled): skip only ``trivial``. + * Otherwise: skip ``trivial``/``simple`` OR a venom-skip route + (BACKGROUND/SPECULATIVE — these pass the gate via preloaded-prompt + credit, not tools). + * L2 repair single-shot fast path (Slice 9): always skip. + + PLUS the Slice-226 alignment override: when the gate will demand + exploration for this complexity, the COMPLEXITY-based skip is lifted (the + ROUTE-based skip is preserved — BACKGROUND keeps its preload-credit path). + Gated by ``gate_alignment_enabled()``; OFF = byte-identical legacy. + NEVER raises — fail-closed to the legacy decision on any error.""" + try: + from backend.core.ouroboros.governance.route_predicates import ( + should_skip_venom_for_route, + ) + c = str(complexity or "").strip().lower() + r = str(route or "").strip().lower() + if is_bg_terminal_worker: + suppressed = (c == "trivial") + else: + route_skip = should_skip_venom_for_route(r) + suppressed = (c in ("trivial", "simple")) or route_skip + if ( + gate_alignment_enabled() + and suppressed + and not route_skip + and exploration_gate_demands_tools(c, gate_enabled=gate_enabled) + ): + suppressed = False + if has_repair_context and not suppressed: + suppressed = True + return suppressed + except Exception: # noqa: BLE001 — fail-closed to legacy complexity skip + c = str(complexity or "").strip().lower() + return c in ("trivial", "simple") + + @dataclass(frozen=True) class ExplorationFloors: """Per-complexity exploration thresholds. diff --git a/backend/core/ouroboros/governance/orchestrator.py b/backend/core/ouroboros/governance/orchestrator.py index 71995158c9..a7e1556e04 100644 --- a/backend/core/ouroboros/governance/orchestrator.py +++ b/backend/core/ouroboros/governance/orchestrator.py @@ -27,6 +27,7 @@ import ast import asyncio import hashlib +import json import logging import os import sys @@ -141,6 +142,75 @@ } +_ENV_SUBGOAL_WRITEBACK = "JARVIS_SUBGOAL_COMPLETION_WRITEBACK_ENABLED" + + +def _slice_a1_subgoal_completion_writeback(ctx: Any, state: Any) -> None: + """§51.11.34-ROADMAP A1 — close the sub-goal completion feedback loop. + + The multi_step orchestrator emits sub-goal envelopes (stamping + ``sub_goal_id`` + ``parent_goal_id`` into the envelope evidence) and writes + a ``PROPOSED`` row to the canonical goal_decomposition completion ledger at + EMIT time — but historically NOTHING wrote the terminal + ``COMPLETED``/``FAILED`` transition back. ``done_count`` (which counts + ``completed`` rows) was therefore structurally pinned at 0: a roadmap + sub-goal could dispatch and succeed any number of times and the roadmap + would never advance. + + This writeback fires from the orchestrator terminal hook — the same + fail-soft, recorder-independent seam the Slice-134 episodic synapse uses. + When the terminal op carries roadmap sub-goal provenance via + ``ctx.intake_evidence_json``, the terminal state is mapped to a + CompletionStatus (``applied`` -> COMPLETED; any other terminal -> FAILED) + and appended to the completion ledger, so the multi_step orchestrator's + ``done_count`` advances and the roadmap can progress. + + Gated ``JARVIS_SUBGOAL_COMPLETION_WRITEBACK_ENABLED`` (default TRUE — this + closes a structural gap; OFF is byte-identical to the legacy severed loop). + NEVER raises. + """ + try: + raw = os.environ.get(_ENV_SUBGOAL_WRITEBACK, "true").strip().lower() + if raw in ("0", "false", "no", "off"): + return + # Cheap substring pre-check avoids a json.loads on the vast majority of + # ops (sensor signals) that carry no sub_goal provenance. + evidence_json = getattr(ctx, "intake_evidence_json", "") or "" + if not evidence_json or "sub_goal_id" not in evidence_json: + return + try: + evidence = json.loads(evidence_json) + except Exception: # noqa: BLE001 + return + if not isinstance(evidence, dict): + return + sub_goal_id = str(evidence.get("sub_goal_id") or "").strip() + parent_goal_id = str(evidence.get("parent_goal_id") or "").strip() + if not sub_goal_id or not parent_goal_id: + return + state_value = getattr(state, "value", str(state)) or "" + from backend.core.ouroboros.governance.goal_decomposition_planner import ( # noqa: E501 + CompletionStatus, + mark_sub_goal_status, + ) + status = ( + CompletionStatus.COMPLETED + if state_value == "applied" + else CompletionStatus.FAILED + ) + mark_sub_goal_status( + sub_goal_id=sub_goal_id, + parent_goal_id=parent_goal_id, + status=status, + note=( + "terminal:" + str(state_value) + " via orchestrator op " + + str(getattr(ctx, "op_id", "")) + )[:512], + ) + except Exception: # noqa: BLE001 — writeback never perturbs the FSM + return + + def _slice12q_record_terminal( ctx: Any, state: Any, data: Dict[str, Any], ) -> None: @@ -184,6 +254,10 @@ def _slice12q_record_terminal( ) except Exception: # noqa: BLE001 — synapse never perturbs the FSM pass + # §51.11.34-ROADMAP A1 — sub-goal completion writeback (the severed feedback + # wire). Recorder-independent + fail-soft, exactly like the episodic synapse + # above. Closes the roadmap progress loop: terminal op -> completion ledger. + _slice_a1_subgoal_completion_writeback(ctx, state) try: from backend.core.ouroboros.battle_test.session_recorder import ( get_active_recorder, diff --git a/docker-compose.dw-cortex-soak.yml b/docker-compose.dw-cortex-soak.yml index 435ecc01e8..d97d76a3c4 100644 --- a/docker-compose.dw-cortex-soak.yml +++ b/docker-compose.dw-cortex-soak.yml @@ -241,6 +241,11 @@ services: # upstream works (reader valid -> decomposition valid, 2 sub-goals -> # envelope into router); THIS flag is what lets sub-goals emit. ── JARVIS_MULTI_STEP_ORCHESTRATION_ENABLED: "1" + # §51.11.34-ROADMAP A1 — sub-goal completion writeback (the severed + # feedback wire). Default-TRUE; pinned explicit here for soak auditability. + # Closes the loop multi_step EMIT (PROPOSED) -> terminal op (COMPLETED) so + # done_count advances and GOAL-001::file-00 stops being a hostage. + JARVIS_SUBGOAL_COMPLETION_WRITEBACK_ENABLED: "1" # Fallback-sem relief (landmine A): D2/12F-B sem-exhausted fast-fail # confirmed live; raise rescue concurrency to the cap so boot-time op # stacks queue less (observed 135s sem_wait on concurrency=3). diff --git a/tests/governance/test_slice225_dw_autarky.py b/tests/governance/test_slice225_dw_autarky.py new file mode 100644 index 0000000000..e69e4df727 --- /dev/null +++ b/tests/governance/test_slice225_dw_autarky.py @@ -0,0 +1,75 @@ +"""Slice 225 Phase 2 — Sovereign DW Autarky: fallback-aware primary budget. + +ROOT CAUSE (live soak, GOAL-001::file-00): the primary (DW) is severed at the +30s ``_PRIMARY_MAX_TIMEOUT_S`` cap to hand off to the Claude fallback for the +Manifesto §5 cascade — but when Claude is OUT OF CREDITS its breaker trips +(``terminal_quota``), so the sever just accelerates exhaustion into a dead lane. +file-00's heavy generation never gets enough DW runway to produce a patch. + +FIX: when the fallback (Claude) lane is unreliable (breaker OPEN/HALF_OPEN), +``_compute_primary_budget`` gives the DW primary the FULL remaining budget (up +to a sovereign-autarky ceiling, default 180s) instead of the 30s/75s reflex cap +— there is no live fallback to reserve runway for. Mirrors the existing +``force_batch`` precedent ("Claude disabled → no fallback to reserve → full +runway"). ``fallback_dead=False`` (default) is byte-identical to legacy. +""" +from __future__ import annotations + +import importlib + +import pytest + +from backend.core.ouroboros.governance.candidate_generator import ( + CandidateGenerator, + _FALLBACK_MIN_RESERVE_S, + _PRIMARY_MAX_TIMEOUT_S, +) + + +# ── default (fallback alive) is byte-identical to legacy ─────────────────── + +def test_fallback_alive_is_legacy_30s_cap(): + """fallback_dead=False (default) → the 30s Tier-3 cap still binds.""" + assert CandidateGenerator._compute_primary_budget(220.0) == _PRIMARY_MAX_TIMEOUT_S + assert CandidateGenerator._compute_primary_budget( + 220.0, fallback_dead=False) == _PRIMARY_MAX_TIMEOUT_S + + +# ── fallback dead → DW gets the full budget (no 30s sever, no reserve) ────── + +def test_fallback_dead_lifts_the_30s_cap(): + """Claude breaker OPEN → DW gets the full remaining budget, NOT 30s.""" + budget = CandidateGenerator._compute_primary_budget(180.0, fallback_dead=True) + assert budget > _PRIMARY_MAX_TIMEOUT_S, ( + f"expected full budget, got 30s-capped {budget}") + # Full remaining (180s) since it's at/under the autarky ceiling. + assert budget == pytest.approx(180.0, abs=0.5) + + +def test_fallback_dead_does_not_reserve_for_dead_lane(): + """No fb_reserve carved out for a fallback that can't run.""" + budget = CandidateGenerator._compute_primary_budget(100.0, fallback_dead=True) + # Legacy would cap at 30s; autarky gives the full 100s (no 30s reserve hole). + assert budget == pytest.approx(100.0, abs=0.5) + assert budget > 100.0 - _FALLBACK_MIN_RESERVE_S + + +def test_fallback_dead_respects_autarky_ceiling(): + """Even with huge remaining, cost-safety ceiling (default 180s) caps it.""" + budget = CandidateGenerator._compute_primary_budget(600.0, fallback_dead=True) + assert budget == pytest.approx(180.0, abs=0.5), ( + f"expected 180s autarky ceiling, got {budget}") + + +def test_fallback_dead_ceiling_is_env_tunable(monkeypatch): + monkeypatch.setenv("JARVIS_DW_AUTARKY_MAX_BUDGET_S", "240") + budget = CandidateGenerator._compute_primary_budget(600.0, fallback_dead=True) + assert budget == pytest.approx(240.0, abs=0.5) + + +def test_fallback_dead_zero_remaining_is_zero(): + assert CandidateGenerator._compute_primary_budget(0.0, fallback_dead=True) == 0.0 + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/tests/governance/test_slice226_gate_alignment.py b/tests/governance/test_slice226_gate_alignment.py new file mode 100644 index 0000000000..013c88e8c8 --- /dev/null +++ b/tests/governance/test_slice226_gate_alignment.py @@ -0,0 +1,138 @@ +"""Slice 226 — Iron Gate / provider capability alignment. + +ROOT CAUSE (live soak GOAL-001::file-00): a catch-22 between two independent, +complexity-keyed decisions: + + * doubleword_provider tool-skip: ``complexity in {trivial, simple}`` -> skip + the Venom tool loop (cost optimization) -> the model gets NO read_file / + search_code channels. + * Iron Gate exploration floor (exploration_engine + orchestrator): a + non-trivial op must make >=1 exploration call ("read the target file"). + +A ``simple`` op on a venom-eligible route (file-00: standard/roadmap) is thus +denied the tools, then REJECTED for not using them — ``exploration_insufficient: +0/1`` across both GENERATE attempts -> generation_failed, forever. The intended +escape hatch (preloaded-prompt credit) silently fails when the target file is +too large to inline (semantic_index.py is 3246 lines). + +FIX: a single shared predicate, ``exploration_gate_demands_tools(complexity)``, +that both providers consult: when the Iron Gate will demand exploration for this +complexity, the tool loop must NOT be skipped on a complexity heuristic. trivial +stays exempt; BACKGROUND/SPECULATIVE keep skipping (route-based, preload-credit +path) — only the complexity-based simple-op skip is overridden. +""" +from __future__ import annotations + +import os + +import pytest + +from backend.core.ouroboros.governance.exploration_engine import ( + exploration_gate_demands_tools, + compute_tool_loop_suppressed, +) + + +# ── the predicate ────────────────────────────────────────────────────────── + +def test_gate_demands_tools_for_simple_when_enabled(monkeypatch): + monkeypatch.setenv("JARVIS_EXPLORATION_GATE", "true") + assert exploration_gate_demands_tools("simple") is True + + +def test_gate_exempts_trivial(monkeypatch): + monkeypatch.setenv("JARVIS_EXPLORATION_GATE", "true") + assert exploration_gate_demands_tools("trivial") is False + + +def test_gate_demands_tools_for_moderate_and_heavy(monkeypatch): + monkeypatch.setenv("JARVIS_EXPLORATION_GATE", "true") + assert exploration_gate_demands_tools("moderate") is True + assert exploration_gate_demands_tools("heavy") is True + + +def test_gate_off_demands_nothing(monkeypatch): + monkeypatch.setenv("JARVIS_EXPLORATION_GATE", "false") + assert exploration_gate_demands_tools("simple") is False + + +def test_gate_empty_complexity_is_false(monkeypatch): + monkeypatch.setenv("JARVIS_EXPLORATION_GATE", "true") + assert exploration_gate_demands_tools("") is False + + +def test_explicit_gate_enabled_param_overrides_env(monkeypatch): + monkeypatch.delenv("JARVIS_EXPLORATION_GATE", raising=False) + assert exploration_gate_demands_tools("simple", gate_enabled=False) is False + assert exploration_gate_demands_tools("simple", gate_enabled=True) is True + + +# ── the unified tool-skip decision (the catch-22 fix) ────────────────────── + +def test_file00_catch22_resolved(monkeypatch): + """simple + standard route + gate on -> tools MUST stay available.""" + monkeypatch.setenv("JARVIS_EXPLORATION_GATE", "true") + suppressed = compute_tool_loop_suppressed( + complexity="simple", route="standard", + is_bg_terminal_worker=False, has_repair_context=False, + ) + assert suppressed is False, "simple gated op must keep the tool loop" + + +def test_trivial_still_skips(monkeypatch): + """trivial is gate-exempt -> still skipped (legacy cost optimization).""" + monkeypatch.setenv("JARVIS_EXPLORATION_GATE", "true") + assert compute_tool_loop_suppressed( + complexity="trivial", route="standard", + is_bg_terminal_worker=False, has_repair_context=False, + ) is True + + +def test_background_route_still_skips(monkeypatch): + """BACKGROUND skip is route-based (preload-credit path) -> preserved.""" + monkeypatch.setenv("JARVIS_EXPLORATION_GATE", "true") + assert compute_tool_loop_suppressed( + complexity="simple", route="background", + is_bg_terminal_worker=False, has_repair_context=False, + ) is True + + +def test_repair_context_still_skips(monkeypatch): + """L2 single-shot fast path (Slice 9) must remain skipped.""" + monkeypatch.setenv("JARVIS_EXPLORATION_GATE", "true") + assert compute_tool_loop_suppressed( + complexity="moderate", route="standard", + is_bg_terminal_worker=False, has_repair_context=True, + ) is True + + +def test_gate_off_simple_skips_legacy(monkeypatch): + """Gate OFF -> byte-identical legacy: simple still skips.""" + monkeypatch.setenv("JARVIS_EXPLORATION_GATE", "false") + assert compute_tool_loop_suppressed( + complexity="simple", route="standard", + is_bg_terminal_worker=False, has_repair_context=False, + ) is True + + +def test_moderate_never_skipped_regardless(monkeypatch): + """moderate is already tool-eligible — unchanged.""" + monkeypatch.setenv("JARVIS_EXPLORATION_GATE", "true") + assert compute_tool_loop_suppressed( + complexity="moderate", route="standard", + is_bg_terminal_worker=False, has_repair_context=False, + ) is False + + +def test_master_flag_off_is_legacy(monkeypatch): + """JARVIS_GATE_ALIGNMENT_ENABLED=0 -> legacy simple-skip restored.""" + monkeypatch.setenv("JARVIS_EXPLORATION_GATE", "true") + monkeypatch.setenv("JARVIS_GATE_ALIGNMENT_ENABLED", "0") + assert compute_tool_loop_suppressed( + complexity="simple", route="standard", + is_bg_terminal_worker=False, has_repair_context=False, + ) is True + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/tests/governance/test_slice227_hedge_governor.py b/tests/governance/test_slice227_hedge_governor.py new file mode 100644 index 0000000000..7289a9b0ca --- /dev/null +++ b/tests/governance/test_slice227_hedge_governor.py @@ -0,0 +1,117 @@ +"""Slice 227 — Context-aware hedge governor (gate-aware RT/BATCH race). + +ROOT CAUSE (live soak GOAL-001::file-00, layer 3): the proactive transport hedge +races RT (which runs the Venom tool loop → does exploration) against BATCH (a +single completion → NO tool loop, zero exploration). `hedged_race` returns the +FIRST success, so when the batch arm finishes first its un-explored candidate +reaches the Iron Gate → `exploration_insufficient: 0/1` → generation_failed. The +performance layer was silently defeating the security floor. + +FIX: ``hedged_race`` gains ``prefer_fast``. When set (the caller derives it from +the SAME Slice-226 predicate ``exploration_gate_demands_tools`` — one source of +truth across capability/security/concurrency planes), a winning BATCH result is +held in a speculative buffer and the race keeps waiting for the RT arm. BATCH is +used ONLY if RT ruptures/fails — so the hedge's entire reason for being (rupture +protection) is preserved; we just stop letting batch *pre-empt* an RT arm that's +actively exploring. ``prefer_fast=False`` (default) is byte-identical legacy +FIRST_COMPLETED. +""" +from __future__ import annotations + +import asyncio + +import pytest + +from backend.core.ouroboros.governance.dw_transport_hedge import hedged_race + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def _arm(value, *, delay=0.0, exc=None): + async def _go(): + if delay: + await asyncio.sleep(delay) + if exc is not None: + raise exc + return value + return _go + + +# ── legacy (prefer_fast=False) is byte-identical FIRST_COMPLETED ──────────── + +def test_legacy_batch_wins_when_faster(): + r = _run(hedged_race( + _arm("RT", delay=0.05), _arm("BATCH", delay=0.0), prefer_fast=False)) + assert r == "BATCH" + + +def test_legacy_rt_wins_when_faster(): + r = _run(hedged_race( + _arm("RT", delay=0.0), _arm("BATCH", delay=0.05), prefer_fast=False)) + assert r == "RT" + + +# ── prefer_fast: batch can't pre-empt an exploring RT arm ─────────────────── + +def test_prefer_fast_waits_for_rt_even_when_batch_faster(): + """The whole fix: batch finishes first, but RT succeeds → RT wins.""" + r = _run(hedged_race( + _arm("RT", delay=0.05), _arm("BATCH", delay=0.0), prefer_fast=True)) + assert r == "RT", "batch pre-empted the exploring RT arm" + + +def test_prefer_fast_rt_faster_still_wins(): + r = _run(hedged_race( + _arm("RT", delay=0.0), _arm("BATCH", delay=0.05), prefer_fast=True)) + assert r == "RT" + + +def test_prefer_fast_falls_back_to_batch_on_rt_rupture(): + """Rupture protection PRESERVED: batch buffered, RT ruptures → batch wins.""" + r = _run(hedged_race( + _arm(None, delay=0.05, exc=RuntimeError("rupture")), + _arm("BATCH", delay=0.0), + prefer_fast=True, is_rupture=lambda e: True)) + assert r == "BATCH" + + +def test_prefer_fast_uses_batch_on_rt_nonrupture_failure(): + """RT fails (non-rupture) after batch buffered → still use the batch result.""" + r = _run(hedged_race( + _arm(None, delay=0.05, exc=ValueError("bad")), + _arm("BATCH", delay=0.0), + prefer_fast=True, is_rupture=lambda e: False)) + assert r == "BATCH" + + +def test_prefer_fast_both_fail_raises(): + with pytest.raises(BaseException): + _run(hedged_race( + _arm(None, delay=0.0, exc=RuntimeError("rt")), + _arm(None, delay=0.02, exc=RuntimeError("batch")), + prefer_fast=True, is_rupture=lambda e: True)) + + +def test_prefer_fast_reports_winner_rt(): + seen = {} + _run(hedged_race( + _arm("RT", delay=0.02), _arm("BATCH", delay=0.0), + prefer_fast=True, + on_outcome=lambda w, r: seen.update(winner=w))) + assert seen.get("winner") == "rt" + + +def test_prefer_fast_reports_winner_batch_on_rupture(): + seen = {} + _run(hedged_race( + _arm(None, delay=0.03, exc=RuntimeError("rupture")), + _arm("BATCH", delay=0.0), + prefer_fast=True, is_rupture=lambda e: True, + on_outcome=lambda w, r: seen.update(winner=w))) + assert seen.get("winner") == "batch" + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/tests/governance/test_subgoal_completion_writeback.py b/tests/governance/test_subgoal_completion_writeback.py new file mode 100644 index 0000000000..18407004c2 --- /dev/null +++ b/tests/governance/test_subgoal_completion_writeback.py @@ -0,0 +1,127 @@ +"""§51.11.34-ROADMAP A1 — Sub-goal completion writeback (the severed feedback wire). + +ROOT CAUSE this closes: the multi_step orchestrator emits sub-goal envelopes and +writes ``PROPOSED`` to the goal_decomposition completion ledger at EMIT time, but +NOTHING ever writes ``COMPLETED``/``FAILED`` back when the dispatched op reaches a +terminal phase. ``done_count`` (which counts ``completed`` rows) was therefore +STRUCTURALLY pinned at 0 — a roadmap sub-goal (e.g. GOAL-001::file-00) could +dispatch + succeed any number of times and the roadmap would never advance. + +The fix wires the writeback into the orchestrator terminal hook +(``_slice12q_record_terminal``) — the same fail-soft, recorder-independent seam +the Slice-134 episodic synapse fires from. When ``ctx.intake_evidence_json`` +carries a ``sub_goal_id`` + ``parent_goal_id`` (stamped by the multi_step emit +path), the terminal state is mapped to a CompletionStatus and appended to the +canonical completion ledger. + +Gated by ``JARVIS_SUBGOAL_COMPLETION_WRITEBACK_ENABLED`` (default TRUE — this +closes a structural gap; OFF is byte-identical to the legacy severed loop). +""" +from __future__ import annotations + +import json +import os +import tempfile +import types +import unittest +from pathlib import Path + + +def _read_rows(ledger: Path): + if not ledger.exists(): + return [] + out = [] + for line in ledger.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line: + try: + out.append(json.loads(line)) + except Exception: + pass + return out + + +class TestSubGoalCompletionWriteback(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self._ledger = Path(self._tmp.name) / "goal_decomposition_ledger.jsonl" + self._saved = {k: os.environ.get(k) for k in ( + "JARVIS_GOAL_DECOMPOSITION_ENABLED", + "JARVIS_GOAL_DECOMPOSITION_PERSIST_ENABLED", + "JARVIS_GOAL_DECOMPOSITION_LEDGER_PATH", + "JARVIS_SUBGOAL_COMPLETION_WRITEBACK_ENABLED", + )} + os.environ["JARVIS_GOAL_DECOMPOSITION_ENABLED"] = "1" + os.environ["JARVIS_GOAL_DECOMPOSITION_PERSIST_ENABLED"] = "1" + os.environ["JARVIS_GOAL_DECOMPOSITION_LEDGER_PATH"] = str(self._ledger) + os.environ["JARVIS_SUBGOAL_COMPLETION_WRITEBACK_ENABLED"] = "1" + + def tearDown(self): + for k, v in self._saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + self._tmp.cleanup() + + def _ctx(self, sub_goal_id="GOAL-001::file-00", parent="GOAL-001"): + evidence = {"sub_goal_id": sub_goal_id, "parent_goal_id": parent, + "multi_step_orchestrated": True} + return types.SimpleNamespace( + op_id="op-1", terminal_reason_code="", provider_route="standard", + intake_evidence_json=json.dumps(evidence), + ) + + def test_terminal_applied_writes_completed(self): + from backend.core.ouroboros.governance import orchestrator as ORC + ORC._slice12q_record_terminal( + self._ctx(), types.SimpleNamespace(value="applied"), + {"route": "standard"}, + ) + rows = _read_rows(self._ledger) + done = [r for r in rows + if r.get("sub_goal_id") == "GOAL-001::file-00" + and r.get("status") == "completed"] + self.assertTrue(done, f"expected a COMPLETED row; got {rows}") + self.assertEqual(done[-1]["parent_goal_id"], "GOAL-001") + + def test_terminal_blocked_writes_failed(self): + from backend.core.ouroboros.governance import orchestrator as ORC + ORC._slice12q_record_terminal( + self._ctx(), types.SimpleNamespace(value="blocked"), {}, + ) + rows = _read_rows(self._ledger) + failed = [r for r in rows if r.get("status") == "failed" + and r.get("sub_goal_id") == "GOAL-001::file-00"] + self.assertTrue(failed, f"expected a FAILED row; got {rows}") + + def test_no_subgoal_evidence_is_noop(self): + """An op with no sub_goal_id provenance must NOT write to the ledger.""" + from backend.core.ouroboros.governance import orchestrator as ORC + ctx = types.SimpleNamespace( + op_id="op-2", terminal_reason_code="", provider_route="background", + intake_evidence_json=json.dumps({"source": "opportunity_miner"}), + ) + ORC._slice12q_record_terminal(ctx, types.SimpleNamespace(value="applied"), {}) + self.assertEqual(_read_rows(self._ledger), []) + + def test_disabled_is_noop(self): + """Master OFF → byte-identical to the legacy severed loop.""" + os.environ["JARVIS_SUBGOAL_COMPLETION_WRITEBACK_ENABLED"] = "0" + from backend.core.ouroboros.governance import orchestrator as ORC + ORC._slice12q_record_terminal( + self._ctx(), types.SimpleNamespace(value="applied"), {}, + ) + self.assertEqual(_read_rows(self._ledger), []) + + def test_fail_soft_never_raises(self): + from backend.core.ouroboros.governance import orchestrator as ORC + # Garbage ctx (no intake_evidence_json attr, malformed state) must not raise. + ORC._slice12q_record_terminal( + types.SimpleNamespace(op_id="x"), types.SimpleNamespace(value="applied"), + None, + ) + + +if __name__ == "__main__": + unittest.main()