# Retry / reroll / strategy loops — every bounded feedback loop in the engine.
# The pipeline retries at FOUR independent altitudes, each with its OWN bound + source:
#   1. per-Beat take loop      (EpisodeRunner._dispatch_one_beat) — up to max_takes attempts;
#      with a StrategyEngine each failed Take is classified + a StrategyDiff overlaid; without
#      one, blind retry (same workflow).                                bound: Beat.max_takes (=3)
#   2. strategy selection      (StrategyEngine.select_and_apply)   — escalation chain, capped by
#      cumulative retry SPEND (RetryCostPolicy.max_retry_spend_usd=$6) + a hard 3-free-strategy cap,
#      terminating in ESCALATE_TO_HUMAN.                                bound: $-spend + 3-free cap
#   3. board auto-reroll       (board_builder.build_with_auto_reroll) — story-gate-driven HARD-fail
#      reroll, fix-notes fed back in, capped at max_attempts (=3). REQUIRES story_gate_mode='shadow';
#      RAISES on 'off' and 'enforce' — a hard surface fork.            bound: max_attempts (=3)
#   4. phantom-recovery extension (Beat.grant_phantom_recovery)    — a missing-artifact ("phantom
#      succeeded") demotion grants ONE extra take slot, hard-capped at _MAX_PHANTOM_RECOVERIES=2,
#      extending the take-loop's effective limit (REC-19).             bound: _MAX_PHANTOM_RECOVERIES (=2)
# Budget is the OUTER kill-switch across all four: BudgetGuard.would_exceed raises
# BudgetExhaustedError (NOT a retry — it HALTS the scene). Capability state lives in
# ssot_manifest.yaml (strategy_engine, budget_guard, orchestration, scene_beat_take).
subsystem: retry_loops

capabilities:
  - id: strategy_engine
    reduced_tier: always
    risk_level: high
  - id: budget_guard
    reduced_tier: always
    risk_level: high
  # scene_beat_take + orchestration capabilities declared in orchestration.topo.yaml
  # (canonical home); this file's loops/routes reference them as FKs.

entrypoints:
  # generate_cli entrypoint declared once in orchestration.topo.yaml; the --budget /
  # BudgetGuard + StrategyEngine seeding it does is captured by this file's loops.
  - id: generate_cli_new_take
    file: recoil/pipeline/cli/generate.py
    symbol: main
    invoke: "python3 recoil/pipeline/cli/generate.py --project P --episode N --pass PASS_X --new-take [--strategy NAME] [--seed N] [--make-primary]"
    kind: cli
    summary: "Manual single-pass reroll. force_new_take=True derives grouping from the primary take and runs ONE more Take through _dispatch_one_beat (break after first). Coverage-pass rerolls only (non-coverage deferred REC-111)."
    capabilities: [scene_beat_take, orchestration]

flags:
  - id: story_gate_mode
    defined: recoil/pipeline/_lib/story_gate.py::story_gate_mode
    default: "off"                # MODES=(off, shadow, enforce); env RECOIL_STORY_GATE or project_config story_gate_mode
    forks_capability: orchestration
    gates_routes: [board_autoreroll_route]
    summary: "off | shadow | enforce. build_with_auto_reroll REQUIRES 'shadow' (raises BoardBuilderError on 'off', raises on 'enforce' — not yet supported). Forks board generation into a reroll loop vs a single build."

loops:
  - id: beat_take_loop
    kind: retry
    capability: scene_beat_take
    trigger: "A Take fails (status != succeeded) and the Beat has no primary_take; loop attempts the next Take."
    driver: recoil/pipeline/orchestrator/episode_runner.py::_dispatch_one_beat
    bound:
      max: 3
      source: "Beat.max_takes (recoil/pipeline/core/take.py:206, default 3; CLI --new-take runs exactly one) — effective limit extended by phantom_recovery_loop"
    exit:
      - "beat.primary_take set (a Take succeeded → primary selected)"
      - "beat.is_exhausted (len(takes) >= effective_max, no primary)"
      - "beat.approved"
      - "_strategy_exhausted (StrategyEngine returned None or ESCALATE_TO_HUMAN)"
      - "force_new_take path breaks after the single reroll attempt"
      - "BudgetExhaustedError raised by the inner budget gate → propagates, HALTS scene"
    retries_into: strategy_selection_loop
    risk_level: high

  - id: strategy_selection_loop
    kind: strategy
    capability: strategy_engine
    trigger: "A failed Take is classified by detect_failure_mode; StrategyEngine.select_and_apply picks the next strategy off the failure-mode escalation chain (learned reorder or static chain)."
    driver: recoil/pipeline/orchestrator/strategy_registry.py::select_and_apply
    bound:
      max: 6.0
      source: "RetryCostPolicy.max_retry_spend_usd (recoil/pipeline/orchestrator/production_types.py:80, default $6.00/pass; warn at $4.00) — PLUS a hard 3-accumulated-free-strategy cap before forced escalation"
    exit:
      - "projected cumulative_retry_cost > max_retry_spend_usd for every remaining strategy → _escalate_to_human_diff"
      - ">=3 free/cheap strategies already tried and no affordable expensive one → ESCALATE_TO_HUMAN"
      - "static escalation chain for the FailureMode exhausted (all already_tried)"
      - "StrategyDiff returned with strategy_name == ESCALATE_TO_HUMAN → caller sets _strategy_exhausted"
    reads_artifact: ops_log
    risk_level: high

  - id: board_autoreroll_loop
    kind: reroll
    capability: orchestration
    trigger: "A storyboard strip gets a HARD story-gate verdict that is rerollable (fix-notes present); fix-notes are fed back and the board is regenerated."
    driver: recoil/pipeline/_lib/board_builder.py::build_with_auto_reroll
    bound:
      max: 3
      source: "build_with_auto_reroll(max_attempts=3) (recoil/pipeline/_lib/board_builder.py:794); requires story_gate_mode='shadow' (raises BoardBuilderError otherwise)"
    exit:
      - "_attempt_is_clean → selected, status approved"
      - "attempt not rerollable (_attempt_is_rerollable False) → best attempt rejected"
      - "attempt_number >= max_attempts → stopped_reason 'attempt_cap_reached', rejected"
      - "no fix-notes extractable from the verdict → rejected"
      - "abort-class verdict → _best_attempt_for_abort"
    reads_artifact: board_comments
    risk_level: medium

  - id: phantom_recovery_loop
    kind: retry
    capability: scene_beat_take
    trigger: "invalidate_phantom_succeeded_takes finds a status=succeeded Take whose artifact is missing/evicted; _demote_take(phantom=True) demotes it and grants one extra take slot."
    driver: recoil/pipeline/core/take.py::grant_phantom_recovery
    bound:
      max: 2
      source: "Beat._MAX_PHANTOM_RECOVERIES (recoil/pipeline/core/take.py:212, ClassVar=2, REC-19); each grant extends Beat.is_exhausted effective_max by one slot"
    exit:
      - "phantom_recovery_count >= _MAX_PHANTOM_RECOVERIES → grant_phantom_recovery returns False (no further extension)"
      - "extended take attempt succeeds → primary selected, beat_take_loop exits"
    retries_into: beat_take_loop
    risk_level: medium

  - id: budget_kill_switch
    kind: convergence
    capability: budget_guard
    trigger: "Before each non-dry-run Take attempt, _estimate_take_cost is reserved via would_exceed; over-cap raises BudgetExhaustedError (this is a HALT, not a retry — the outer bound on all loops above)."
    driver: recoil/pipeline/_lib/budget_manager.py::would_exceed
    bound:
      max: 50.0
      source: "EpisodeRunner.budget_usd → BudgetGuard(limit_usd=...) (episode_runner.py:382, default $50.0; CLI --budget overrides). Per-beat reservations released on no-dispatch / zero-cost failure (REC-122)."
    exit:
      - "would_exceed(est) True (or est<=0 and spent>=budget) → BudgetExhaustedError → scene saved + halted"
      - "scene completes within budget (all beats dispatched or skipped)"
    risk_level: high

routes:
  - id: board_autoreroll_route
    capability: orchestration
    surface: board
    entry: recoil/pipeline/_lib/board_builder.py::build_with_auto_reroll
    resolves_via: recoil/pipeline/_lib/board_builder.py::build_with_auto_reroll
    ref_kind: board_rerolled
    gated_by: story_gate_mode
    fallback_route: board_single_build_route
    risk_level: high

  - id: board_single_build_route
    capability: orchestration
    surface: board
    entry: recoil/pipeline/_lib/board_builder.py::build_and_dispatch_board
    resolves_via: recoil/pipeline/_lib/board_builder.py::build_and_dispatch_board
    ref_kind: board
    gated_by: null                # story_gate_mode 'off' → single build, NO reroll loop
    fallback_route: null
    risk_level: low

divergences:
  - id: board_reroll_story_gate_fork
    capability: orchestration
    forks: [board_autoreroll_route, board_single_build_route]
    on_flag: story_gate_mode
    invariant: "build_with_auto_reroll MUST run only under story_gate_mode='shadow'; it RAISES BoardBuilderError on 'off' AND on 'enforce' (enforce ships in v1.1, NotImplementedError). The reroll loop is the ONLY path that consumes story-gate HARD verdicts + fix-notes — a board built via the single-build route bypasses gate-driven reroll entirely."
    risk: "An engineer enabling story_gate_mode='enforce' expecting stricter boards instead crashes the build (raises in StoryGate.__init__ and in build_with_auto_reroll's mode check). And board generation silently has NO auto-reroll whenever mode is 'off' (the default) — HARD board failures pass through un-rerolled."
    reduced_tier: always
    risk_level: high

artifacts:
  - id: ops_log
    path: "<project_root>/_pipeline/state/ops/*.ndjson"
    written_by: [strategy_selection_loop, beat_take_loop]
    schema: "ndjson events: retry_strategy_selected, retry_cost_warning, beat_strategy_exhausted, beat_exhausted, strategy_engine_error"
    read_by: []
    risk_level: low

  - id: board_comments
    path: "<project_root>/prep/<ep_NNN>/storyboards/board_comments.json"
    written_by: [board_autoreroll_loop]
    read_by: []
    schema: "reroll_lineage + per-attempt verdict/route persisted via _finalize_reroll_board; distinct from human board_review_comments"
    risk_level: low
