Merge pull request #177 from razzant/fix/managed-update-core

fix(update): land release merges from a modified install — hermetic unmerged candidate, rescue before rollback, degradable touched tests
This commit is contained in:
Anton Razzhigaev 2026-08-10 16:15:31 +03:00 committed by GitHub
commit 0bccde5696
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2505 additions and 256 deletions

View file

@ -111,7 +111,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
├── local_model_autostart.py ← Local model startup helper
├── deep_self_review.py ← Deep self-review: Generated Deep Self-Review Atlas repository context + full memory whitelist → 1M-context model. Guaranteed-fit assembly (v6.27.1): the in-prompt OMITTED-files section is bounded (counts per reason + capped sample; full coverage stays in the persisted atlas manifest) and reserved inside the atlas fixed budget; an atlas that did not assemble (`atlas_assembly_failed`: over hard budget, or a REQUIRED artifact omitted) retries once with the compact manifest and otherwise returns no pack at all, and a final-shrink rebuild (tighter hard budget by the measured overage) replaces the historical fatal 'Review pack too large' error — the gate remains as the fail-closed last assertion. File selection is ranked by import-graph centrality (reverse-import in-degree from code_intelligence, additive bonus ≤600, deep-review-only)
├── review.py ← Code collection, complexity metrics, pre-commit review
├── preflight_runner.py ← Hermetic reviewed-change pytest gate: disposable git worktree, candidate diff replay, temp data/settings/pycache env, and live OUROBOROS_*/secret-class scrub so review tests cannot inherit operator behavior or mutate live repo/data. Runs CI's own two-pass split in that one worktree (parallel `not serial` with `-n auto --dist loadscope --max-worker-restart=0 --timeout=300`, then a flag-free `serial` pass) under ONE total budget, with `LANE_EXCLUSION_EXPR` as the marker-lane SSOT; a dead xdist worker and a missing xdist/timeout plugin are distinct named hard blocks, never a retry and never a silent serial fallback
├── preflight_runner.py ← Hermetic reviewed-change pytest gate: disposable git worktree, ONE hardened candidate capture (`git diff --binary --no-ext-diff --no-textconv --no-color --src-prefix=a/ --dst-prefix=b/ HEAD` applied as RAW BYTES, identically for every index state including an unfinished merge — whose unmerged entries the former staged+unstaged pair could only render as contentless stubs and `--cc` hunks `git apply` rejects or silently drops; capture/apply failure is the typed hard block PREFLIGHT_CANDIDATE_ASSEMBLY, never a test verdict; the honest bound is an exact tracked projection of the live worktree plus its safe non-ignored untracked entries), temp data/settings/pycache env, and live OUROBOROS_*/secret-class scrub so review tests cannot inherit operator behavior or mutate live repo/data. Runs CI's own two-pass split in that one worktree (parallel `not serial` with `-n auto --dist loadscope --max-worker-restart=0 --timeout=300`, then a flag-free `serial` pass) under ONE total budget, with `LANE_EXCLUSION_EXPR` as the marker-lane SSOT; a dead xdist worker and a missing xdist/timeout plugin are distinct named hard blocks, never a retry and never a silent serial fallback
├── review_substrate.py ← Reviewer-slot coordinator used by task acceptance and planning helpers; duplicate model ids remain independent slots. Actor records keep transport status, parse status, semantic verdict, model/provider, role, coverage, quorum contribution, reason, enforcement impact, and review-binding hashes distinct; only a compact projection reaches task/event/UI records. Task acceptance enforces adaptive quorum, one substantive call and no more than two physical attempts per actor, metric-grounded criterion evidence, provenance, and a public-info-only anti-cheat boundary. Commit/triad/scope P3 orchestration remains a separate one-pass contract. (v6.87.21) Slot execution has ONE seam: `_run_slot` builds an immutable `ReviewAssignment` and binds it ONCE through `review_execution._review_route_executor` — the single place a transport is chosen (closed `ReviewRouteKind`: `api_chat` and `agent_session` — never a vendor/harness name), bound before the first send so the durable prompt record is written from the route's own lazily rendered projection; `_execute_slot_attempt` is the single physical-attempt seam that runs the already-bound executor, and the route's executor returns a typed `ReviewAttemptResult`. Attempt rails, persistence, parsing, actor projection and quorum stay above the seam and are route-agnostic; a route that cannot deliver raises the typed `ReviewRouteUnavailable` on its own slot instead of falling back to another transport. Prompt assembly lives BELOW the seam: `ApiChatReviewExecutor` renders the historical messages lazily and memoizes them, so the durable prompt record and both permitted physical sends share one byte-identical rendering (pinned by a golden digest test) and a non-API route never assembles an API pack. Everything below the seam — route vocabulary, assignment, attempt result, executors, and the api_chat prompt renderers — lives in `review_execution.py`, which never imports the coordinator back; `review_substrate` re-exports the historical renderer names for existing callers. (phase 5) `AgentSessionReviewExecutor` delivers a slot as ONE delegated read-only Claudexor session through the shared `run_delegated_review_session` nanny loop (custody, settlement, verified-cancel time cap, D7 full-artifact read; the delegated advisory rides the same loop). Its typed verdict follows D19: `outputSchema` is asked only when the route's own live manifest (`GET /v2/harnesses`) declares structured output — the agent-capability catalog's harness rows carry no such field at all, so reading it there answered False for every route — trusted only on the run's own `outputConformance == "passed"` (never run success); otherwise the strict parser first, then LIGHT-MODEL extraction canonicalizes narrative to the review's own contract — bare `[]` or a findings array — so a session's clean verdict survives `empty_array_is_verified_clean` unchanged, with every extraction-instead-of-schema landing disclosed as `capability_delta` (actor usage + durable event). Per-row delivery comes from `OUROBOROS_REVIEW_ROUTES` / `OUROBOROS_SCOPE_REVIEW_ROUTES` with the session target in `OUROBOROS_REVIEW_SESSION_ROUTE` (falling back to `OUROBOROS_SUBAGENT_HARNESS`); task acceptance and plan review are pinned `api_chat` (D15). The advisory route is `OUROBOROS_ADVISORY_REVIEW_ROUTE` (`api` | `agent_session`), and every `ANTHROPIC_API_KEY` check on the advisory path is route-dependent — the api route requires the key exactly as before. Scope session delivery is assembled by `tools/scope_review_session.py` from the SAME `build_scope_review_prompt` builder (retrieval pointers instead of packs, canonical docs as `generate_doc_nav_map` navigation maps); its coverage manifest is forensics, never a gate: `host_file_read_attestation: unobserved` is a non-blocking disclosed fact (the host does not see which files the session opened — a provenance limit, not a coverage finding), and the api-only ≥1M window floor does not apply to the agentic-delivery session mode, which BIBLE P3 admits as an ALTERNATE AUTHORITATIVE delivery mode once its window is sourced at ≥200K (D16).
├── review_execution.py ← (v6.87.21, phase 5) Review execution BELOW the substrate's seam: the closed route vocabulary (`ReviewRouteKind`: `api_chat` and `agent_session` — never a vendor/harness name), the immutable `ReviewAssignment`, the per-route executors returning a typed `ReviewAttemptResult` (a route that cannot deliver raises the typed `ReviewRouteUnavailable` on its own slot, never a fallback to another transport), the api_chat prompt renderers (rendered lazily and memoized so the durable prompt record and both permitted physical sends share one byte-identical rendering), and `AgentSessionReviewExecutor` with the shared `run_delegated_review_session` nanny loop and the D19 typed-verdict order — see the `review_substrate.py` row above for the seam's coordinator side and the full phase-5 contract. One-way dependency: this module never imports the coordinator back; `review_substrate.py` re-exports the historical renderer names for existing callers.
├── reviewer_slot_config.py ← Structured reviewer-slot SSOT: stable slot ids, route targets, per-slot effort, legacy projections, save/runtime validation, and disclosure-only last-effective execution records. Malformed configuration loudly refuses commit, scope, advisory, plan, and skill review; task acceptance deliberately retains the projected legacy/default API panel.
@ -530,6 +530,8 @@ After exit code 42, the launcher refreshes the existing bundle metadata and runs
Managed supervisor bootstrap is the sole owner of destructive dirty-tree recovery. Before any reset/clean, `supervisor.git_ops` writes a rescue directory containing porcelain status, a binary diff, a stash-created rescue object/ref when possible, copied untracked files with completeness metadata, unpushed-commit evidence, and `rescue_meta.json`. An incomplete snapshot blocks `rescue_and_reset`; it is not permission to discard what could not be captured. Normal managed bootstrap then cleans back to the local branch's own HEAD, not to `managed/<branch>`.
Managed update is the second user of that machinery, with the opposite failure policy. Every destructive rollback path (orphan watchdog, boot attempt cap, failed smoke, failed re-materialization) shares one choke point in `rollback_managed_update`, and the boot-resume re-materialization resets the tree on its own; both take a FRESH rescue before the first destructive command, because the pre-update snapshot was captured before the merge existed and holds none of the resolver's work. The hook is FAIL-OPEN by owner decision — a rescue that cannot be taken never blocks the rollback, it is logged and disclosed — and it writes one durable `supervisor.jsonl` line at capture time, BEFORE the destruction, so the record survives a crash between the reset and the terminal event; a `git status` that cannot answer counts as dirty. The snapshot understands merges — MERGE_HEAD, the unmerged path list and MERGE_MSG are recorded best-effort, `git stash create`'s refusal on an unmerged index is disclosed instead of silently leaving no ref, and `changes.diff` is written as raw bytes with a hardened capture argv and environment (no external diff/textconv drivers, no colour, pinned prefixes, no `GIT_DIFF_OPTS`), because it is the only carrier of a resolution stash cannot capture — and is deliberately NOT linked to an active evolution transaction, which would flip that campaign's cycle to abandoned for an unrelated reason. The update transaction carries a pointer to what was rescued, persisted before the first destructive command: a replayed rollback does not duplicate a snapshot it already took, a retry after a failed attempt drops the marker and re-rescues the tree it actually finds, and the resolver's own objective names the latest rescue directory (with an honest count when several were taken) — for the whole transaction, since re-materialization re-creates MERGE_HEAD and a dirty tree WITHOUT replaying the rescued edits and must never be read as their return.
An active Evolution transaction or managed-update merge uses `rescue_and_block`: recovery evidence is linked to the transaction, the tree is left intact, and Evolution is paused rather than erasing partially resolved work. With no such owner, startup uses `rescue_and_reset`. Source/local-development server startup skips the managed checkout/reset path and performs only dependency sync plus import test. Worker startup checks are diagnostic and warning-only: launcher-management environment variables propagate into worker, review, and test subprocesses, so allowing each constructor to auto-rescue would let an incidental child steal or clean another actor's in-progress edits.
`server.py` establishes `OUROBOROS_AGENT_PYTHON` from its actual interpreter immediately after binding the repo import root and before workers or review subprocesses start. Hermetic commit/review preflight uses that handle (then `sys.executable`, then `python3`) so tests run in the environment that contains Ouroboros dependencies; plugin verification is part of that preflight, not a separate launcher claim that every interpreter was live-probed at startup.
@ -1535,10 +1537,22 @@ refusal is a step, not the end (`budget_exceeded` is the sibling failure, when
even the content-free manifest cannot fit); 4) touched files degrade to
diff-only, FREELY DEGRADABLE ones first and largest-first within each tier — an
artifact owed in full is reached only after rung 5, since degrading one is a
typed assembly failure and can never buy a fitting pack — their full
post-change snapshots are replaced by
typed assembly failure and can never buy a fitting pack; touched TEXT tests,
current and deleted alike, belong to that FREE tier (full snapshots / inlined
HEAD content while the budget allows, diff-only only under pressure) instead of
being atlas anchors the ladder could not reach, which used to make one large
touched test terminate the pack as `required_artifact_omitted` even though the
staged diff already carried its complete change — binary test fixtures stay out
(a text diff does not carry their changes, so a "changes included" row would be
a false claim), renamed paths conservatively stay out with them (the staged
diff may carry only a rename header), and a deleted test over the inline cap
keeps its own suppressed
marker rather than crediting the ladder with tokens the fixed part never held —
their full post-change snapshots are replaced by
an explicit `TOUCHED FILE BUDGET DEGRADATION NOTE` while their complete
changes remain visible in the staged diff, and the ladder DECLARES those paths
changes remain visible in the staged diff, which is therefore captured
byte-exact and immune to operator diff config, and fails prompt assembly closed
rather than degrading to a placeholder; the ladder DECLARES those paths
to the atlas (`ReviewContextAtlasRequest.diff_only_included`, v6.87.15) so the
durable coverage row reads "changes included …; full snapshot omitted" instead
of the `already_included` claim the prompt itself contradicts — diff-only is

View file

@ -1217,6 +1217,14 @@ Before every commit, verify the following:
same update lock and honors this admission owner; it must not stash/reset
behind the fence. Managed merge tests pass before restart; the ordinary
self-modification commit/tag/test/push ordering remains unchanged.
- Take a fresh rescue before every destructive rollback and before boot-resume
re-materialization: the pre-update snapshot predates the merge and holds none
of the resolution. The hook is fail-open — never block a rollback on it — but
its outcome, captured or failed, is disclosed durably at capture time, before
the destruction. Record the pointer in the update transaction so a replayed
rollback does not re-snapshot and a retry rescues what appeared since; keep
that pointer until the transaction ends, because a re-materialized merge looks
identical to restored work and is not evidence the rescue was applied.
- Manual Restore reuses the same writer fence and pins the previous HEAD on a
local recovery branch before reset. Promotion resolves the development SHA
once and uses that exact SHA for both the local QA ref and any remote push.
@ -1520,7 +1528,12 @@ that:
### The commit gate mirrors the CI split
`ouroboros/preflight_runner.py::run_hermetic_pytest` runs the same two logical
passes as CI in one disposable checkout and scrubbed temporary data root:
passes as CI in one disposable checkout and scrubbed temporary data root. The
candidate is captured universally — one hardened worktree-vs-`HEAD` binary diff
applied as raw bytes, assembled identically whether the source index is clean,
dirty, or mid-merge — and a capture or apply failure is the typed
`PREFLIGHT_CANDIDATE_ASSEMBLY` hard block with its own remediation, never a
test failure:
1. parallel `not serial` with xdist, loadscope distribution, no worker restart,
and the configured per-test timeout;

View file

@ -260,22 +260,38 @@ def _preflight_pass_specs(
]
def _run_git(repo_dir: pathlib.Path, args: Sequence[str], *, input_text: str = "", timeout: int = 30) -> subprocess.CompletedProcess:
def _run_git(
repo_dir: pathlib.Path,
args: Sequence[str],
*,
input_text: "str | bytes" = "",
timeout: int = 30,
binary_stdout: bool = False,
) -> subprocess.CompletedProcess:
# BINARY pipes, decoded here so callers keep the str contract: text-mode pipes
# translate \n to os.linesep, and on Windows a CRLF-mangled stdin corrupts a
# multi-line git payload (a replayed diff's context lines stop matching the
# LF worktree, so `git apply` rejects the candidate diff wholesale).
#
# ``binary_stdout`` skips the decode for the ONE payload that must survive
# byte-for-byte — the candidate capture that is fed straight back into
# `git apply`. Git classifies NUL-free non-UTF-8 content (latin-1 logs,
# cp1251 fixtures) as TEXT, so its bytes travel on plain diff lines, and
# decode(errors="replace") would substitute U+FFFD for each of them —
# silently corrupting the candidate while the gate stays green.
# ``input_text`` symmetrically accepts those captured bytes unmodified.
payload = input_text.encode("utf-8") if isinstance(input_text, str) else input_text
proc = subprocess.run(
["git", *args],
cwd=str(repo_dir),
input=input_text.encode("utf-8") if input_text else None,
input=payload or None,
capture_output=True,
timeout=timeout,
)
return subprocess.CompletedProcess(
proc.args,
proc.returncode,
(proc.stdout or b"").decode("utf-8", "replace"),
(proc.stdout or b"") if binary_stdout else (proc.stdout or b"").decode("utf-8", "replace"),
(proc.stderr or b"").decode("utf-8", "replace"),
)
@ -330,12 +346,24 @@ def _head_tracks_tests(repo: pathlib.Path, refs: Sequence[str] = _TESTS_BASELINE
return any(_ref_tracks_tests(repo, ref) for ref in refs)
def _apply_diff(worktree: pathlib.Path, diff_text: str) -> None:
def _apply_diff(worktree: pathlib.Path, diff_text: "str | bytes") -> None:
# Accepts bytes so the candidate capture reaches `git apply` undecoded —
# see ``binary_stdout`` on `_run_git` for why the round-trip must not
# pass through UTF-8.
#
# `--unidiff-zero`: the capture's flag tail pins away every operator config
# that reshapes diff CONTENT, but hunk WIDTH still leaks through — a user
# `diff.context=0` (or `GIT_DIFF_OPTS=--unified=0` in the environment)
# makes `git diff` emit zero-context hunks, which `git apply` REJECTS by
# default, hard-blocking an ordinary textual candidate before any test
# runs. The flag accepts zero-context hunks and is a no-op for hunks that
# carry context, so it covers both the config and the env route without
# scrubbing either.
if not diff_text.strip():
return
proc = _run_git(
worktree,
["apply", "--whitespace=nowarn", "--binary"],
["apply", "--whitespace=nowarn", "--binary", "--unidiff-zero"],
input_text=diff_text,
timeout=60,
)
@ -344,11 +372,17 @@ def _apply_diff(worktree: pathlib.Path, diff_text: str) -> None:
def _copy_untracked(repo_dir: pathlib.Path, worktree: pathlib.Path) -> None:
listed = _run_git(repo_dir, ["ls-files", "--others", "--exclude-standard", "-z"])
# The NAMES arrive as bytes and are decoded with the filesystem's own codec
# (surrogateescape on POSIX), not utf-8/replace: a filename carrying a raw
# non-UTF-8 byte would otherwise become a U+FFFD name that no longer exists on
# disk, `is_file()` would answer False, and the file would drop out of the
# candidate silently — an inexact candidate with no assembly failure raised.
listed = _run_git(
repo_dir, ["ls-files", "--others", "--exclude-standard", "-z"], binary_stdout=True
)
if listed.returncode != 0:
raise RuntimeError(listed.stderr.strip() or "git ls-files failed")
raw = listed.stdout or ""
for rel in [part for part in raw.split("\0") if part]:
for rel in [os.fsdecode(part) for part in (listed.stdout or b"").split(b"\0") if part]:
src = (repo_dir / rel).resolve()
dst = (worktree / rel).resolve()
try:
@ -997,6 +1031,19 @@ def run_hermetic_pytest(
the pre-commit review passes ``PRE_COMMIT_PHASE`` so it compares against HEAD
only, while the default post-commit verification also consults HEAD~1,
because by then the deletion it is looking for is already in HEAD.
The candidate is assembled as ONE hardened ``git diff --binary HEAD``
capture (external drivers, textconv, colour and operator prefix configs
pinned off; payload kept as raw bytes end to end) applied to a clean
worktree at HEAD, plus a copy of the untracked files: an exact tracked
projection of the live worktree plus its safe non-ignored untracked
entries for every source-index state, including a merge in progress,
whose unmerged entries the former staged+unstaged diff pair rendered as
stubs and ``--cc`` hunks that ``git apply`` dropped or rejected. The
untracked side keeps ``_copy_untracked``'s long-standing boundaries:
ignored files are absent, and untracked symlinks are dereferenced to
regular files (non-file entries skipped), so the candidate is not
literally byte-equal to the worktree in those corners.
"""
timeout = _resolve_preflight_timeout(timeout)
# Checked BEFORE anything runs. `_diagnosis` renders inside this budget, so a
@ -1057,17 +1104,59 @@ def run_hermetic_pytest(
return f"⚠️ PRE_PUSH_TEST_ERROR: could not create hermetic worktree: {add.stderr.strip()}"
worktree_added = True
staged_proc = _run_git(repo, ["diff", "--cached", "--binary"])
unstaged_proc = _run_git(repo, ["diff", "--binary"])
if staged_proc.returncode != 0:
raise RuntimeError(staged_proc.stderr.strip() or "git diff --cached failed")
if unstaged_proc.returncode != 0:
raise RuntimeError(unstaged_proc.stderr.strip() or "git diff failed")
staged = staged_proc.stdout or ""
unstaged = unstaged_proc.stdout or ""
_apply_diff(worktree, staged)
_apply_diff(worktree, unstaged)
_copy_untracked(repo, worktree)
# ONE capture for every repository state: the tracked delta between
# HEAD and the live worktree, assembled identically whether the source
# index is clean, dirty, or mid-merge. The staged+unstaged diff pair
# this replaces could not represent an unmerged index at all: `git diff
# --cached` renders each conflicted path as a literal "* Unmerged path"
# stub and `git diff` as a combined `--cc` hunk — which `git apply`
# REJECTS when the payload holds nothing else (rc=128, the gate died
# before running a test) and silently DROPS when ordinary hunks
# accompany it (the gate then ran against a candidate MISSING the
# resolutions, so its verdict described a tree nobody has). The two-way
# HEAD form has no such rendering: staged-only files, resolutions and
# conflict markers all arrive as plain content.
#
# The flag tail pins away every operator config that reshapes diff
# output into something `git apply` cannot re-apply: external diff
# drivers (`--no-ext-diff`), textconv filters (`--no-textconv`), colour
# escapes (`--no-color`), and prefix rewrites (`--src-prefix=a/
# --dst-prefix=b/` — the explicit CLI prefixes win over diff.noprefix
# AND diff.srcPrefix/dstPrefix, which `-c diff.noprefix=false` alone
# would not). Captured and applied as BYTES end to end (see
# ``binary_stdout``) so NUL-free non-UTF-8 text content is not
# U+FFFD-substituted in transit.
try:
combined_proc = _run_git(
repo,
["diff", "--binary", "--no-ext-diff", "--no-textconv", "--no-color",
"--src-prefix=a/", "--dst-prefix=b/", "HEAD"],
binary_stdout=True,
)
if combined_proc.returncode != 0:
raise RuntimeError(combined_proc.stderr.strip() or "git diff HEAD failed")
_apply_diff(worktree, combined_proc.stdout or b"")
_copy_untracked(repo, worktree)
# The assembly block owns EVERY way its own capture can fail, not only
# the RuntimeErrors it raises itself: `_run_git` can raise
# subprocess.TimeoutExpired (a SubprocessError subclass) and
# `_copy_untracked` can raise FileNotFoundError/PermissionError (OSError
# subclasses). Let through, those land in the OUTER handlers below and
# are misread as a pytest timeout, a missing pytest interpreter, or a
# generic preflight failure — all of which invite a retry against a
# candidate that was never assembled.
except (RuntimeError, subprocess.SubprocessError, OSError) as exc:
return _diagnosis(
"⚠️ PRE_PUSH_TEST_ERROR: PREFLIGHT_CANDIDATE_ASSEMBLY (hard block): "
"the candidate tree could not be assembled from the live worktree",
"The worktree-vs-HEAD capture could not be built or applied, so "
"there is no candidate worth testing and no pass was run. An "
"unmerged index is NOT the cause — a merge in progress is a "
"supported source state for this capture — so this block means "
"the capture or apply itself failed: read the git/filesystem "
"error in the body below. This is not a test failure.",
str(exc), max_output,
)
from ouroboros.platform_layer import kill_processes_referencing

View file

@ -1209,12 +1209,12 @@ def _fit_triad_prompt(api_models: list, assemble, current_files_section: str,
)
prompt, stable_prefix_len = assemble(fit_note, diff_text)
if input_limit and estimate_tokens(prompt) > input_limit:
try:
compact_diff = run_cmd(
["git", "diff", "--cached", "-U0"], cwd=target_repo
)
except Exception:
compact_diff = ""
from ouroboros.tools.review_binary_context import (
StagedDiffUnavailable, capture_staged_diff)
try: # the SAME hardened capture as the primary diff, at zero context
compact_diff = capture_staged_diff(target_repo, unified=0)
except StagedDiffUnavailable:
compact_diff = "" # keep the hardened full diff; the gate below blocks if it still overflows
if compact_diff.strip():
prompt, stable_prefix_len = assemble(fit_note, compact_diff)
prompt_tokens = estimate_tokens(prompt)
@ -1270,6 +1270,36 @@ def _triad_session_task(ctx: ToolContext, *, goal_section: str, scope_section: s
] if str(part or "").strip())
def _capture_triad_staged_diff(
ctx: ToolContext, target_repo, blocking_review: bool
) -> tuple[Optional[str], Optional[str]]:
"""Capture the triad's staged-diff evidence, or route a capture failure.
Returns ``(diff_text, None)`` on success and ``(None, block_result)`` on
failure the fail-closed message in blocking mode, ``None`` (advisory skip)
otherwise. The diff is the triad's primary change evidence, so it is taken
byte-exact and hardened against operator diff config (the same
``capture_staged_diff`` the scope reviewer uses); a genuine failure fails
closed rather than reviewing a placeholder that would yield authoritative
findings about a diff nobody has.
"""
from ouroboros.tools.review_binary_context import (
StagedDiffUnavailable, capture_staged_diff)
try:
return capture_staged_diff(target_repo), None
except StagedDiffUnavailable as exc:
ctx._last_review_block_reason = "infra_failure"
return None, _handle_review_block_or_warning(
ctx, blocking_review,
"⚠️ REVIEW_BLOCKED: Cannot capture the staged diff — commit cannot "
f"proceed.\nError: {exc}\n"
"Ensure git is available and the repository is in a valid state.",
"Review enforcement=Advisory: staged diff capture failed; triad "
"review skipped rather than run against a placeholder. ",
)
def _run_unified_review(ctx: ToolContext, commit_message: str,
review_rebuttal: str = "",
repo_dir=None,
@ -1286,11 +1316,9 @@ def _run_unified_review(ctx: ToolContext, commit_message: str,
review_enforcement = _cfg.get_review_enforcement()
blocking_review = review_enforcement == "blocking"
try:
diff_text = run_cmd(["git", "diff", "--cached"], cwd=target_repo)
except Exception:
diff_text = "(failed to get staged diff)"
diff_text, capture_block = _capture_triad_staged_diff(ctx, target_repo, blocking_review)
if diff_text is None: # capture failed: block (blocking) or advisory-skip (None)
return capture_block
if not diff_text.strip():
return None

View file

@ -7,6 +7,53 @@ import pathlib
import subprocess
class StagedDiffUnavailable(RuntimeError):
"""The canonical staged diff could not be captured.
A RuntimeError so it lands in the prompt-assembly fail-closed path: a review
whose only evidence of a degraded file is the staged diff must not run
authoritatively on a placeholder string that says the capture failed.
"""
def capture_staged_diff(repo_dir: pathlib.Path, *, unified: int = 3) -> str:
"""The staged diff exactly as the reviewer must see it.
ONE capture for both ladder rungs (full context and ``-U0``). The flag tail
pins away operator config that rewrites diff output into something that no
longer describes the staged bytes: external diff drivers, textconv filters,
colour escapes and prefix rewrites; GIT_DIFF_OPTS leaves the environment
because it overrides the context width from outside the argv. Output is
taken as BYTES and decoded strictly; non-UTF-8 staged text is rendered with
``backslashreplace`` plus an explicit note, so it reaches the reviewer in a
readable form instead of raising or being flattened into U+FFFD.
"""
env = {k: v for k, v in os.environ.items() if k != "GIT_DIFF_OPTS"}
try:
result = subprocess.run(
["git", "diff", "--cached", "--no-ext-diff", "--no-textconv", "--no-color",
"--src-prefix=a/", "--dst-prefix=b/", f"--unified={int(unified)}"],
cwd=repo_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=300, env=env,
)
except (OSError, subprocess.SubprocessError) as exc:
raise StagedDiffUnavailable(f"staged diff capture failed: {exc!r}") from exc
if result.returncode != 0:
detail = (result.stderr or b"").decode("utf-8", "replace").strip()
raise StagedDiffUnavailable(
f"staged diff capture failed (rc {result.returncode}): {detail or 'no detail'}"
)
raw = result.stdout or b""
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
rendered = raw.decode("utf-8", "backslashreplace")
return (
f"{rendered}\n\n*(staged diff contained non-UTF-8 bytes; they are "
"rendered above as backslash escapes)*\n"
)
def _git_bytes(repo_dir: pathlib.Path, args: list[str]) -> bytes:
try:
result = subprocess.run(

View file

@ -1470,9 +1470,10 @@ wider repository pack as omission.
## Current touched files (post-change — what the file looks like NOW)
Files deleted by this diff appear here with an explicit `DELETED` marker and
their HEAD content inlined; other removed lines are visible via the staged
diff below. HEAD versions of modified files are not sent as a separate
section the staged diff below already shows every `-` line.
their HEAD content inlined unless a typed marker states otherwise (suppressed
content, or a budget-degraded snapshot); other removed lines are visible via
the staged diff below. HEAD versions of modified files are not sent as a
separate section the staged diff below already shows every `-` line.
{current_files_section}

View file

@ -39,6 +39,8 @@ from ouroboros.tools.scope_review_contract import (
ladder_terminal_cause as _ladder_terminal_cause,
normalize_scope_items as _normalize_scope_items,
)
from ouroboros.tools.review_binary_context import (
StagedDiffUnavailable, capture_staged_diff, staged_path_is_binary)
from ouroboros.tools.review_synthesis import build_scope_review_prompt
from ouroboros.tools.review_helpers import (
build_goal_section,
@ -71,27 +73,21 @@ from ouroboros.utils import (
log = logging.getLogger(__name__)
_SCOPE_REQUIRED_ITEMS = SCOPE_REQUIRED_ITEMS # compatibility export used by tests/review tooling
# Shipped designated scope reviewer (v6.82.0). Window evidence, checked 2026-07-29:
# OpenAI's own model guide AND OpenRouter /models both state gpt-5.6-terra
# context_length=1,050,000 — a MODEL property documented by the provider itself, not
# inferred from one router — so the >=1M BIBLE P3 floor holds on the direct and the
# routed spelling alike, exactly as the sentinel spanned spellings for the previous
# designated default (v6.55.0-v6.81: anthropic/claude-fable-5). The sentinel still
# grants only the conservative 1M figure, and a real probe/owner-ack supersedes it.
# Shipped designated scope reviewer (v6.82.0). Window evidence checked 2026-07-29:
# provider docs AND OpenRouter /models both state gpt-5.6-terra context_length
# 1,050,000 — a documented MODEL property, so the >=1M BIBLE P3 floor holds on both
# spellings; the sentinel grants only 1M, a real probe/owner-ack supersedes.
from ouroboros.tools.scope_window import SCOPE_MODEL_DEFAULT as _SCOPE_MODEL_DEFAULT # noqa: E402
_SCOPE_MAX_TOKENS = 100_000 # 100K output tokens
_SCOPE_REVIEW_SLOT_TIMEOUT_SEC = 900
from ouroboros.tools.review_helpers import REVIEW_PROMPT_TOKEN_BUDGET as _SCOPE_BUDGET_TOKEN_LIMIT
# The shared prompt-size SSOT (920K) governs INPUT only, but the reviewer also
# reserves _SCOPE_MAX_TOKENS for OUTPUT inside that same 1M window. 920K input +
# 100K output exceeds 1M, and provider tokenizers can exceed estimate_tokens by
# tens of thousands of tokens on atlas-heavy prompts. Gate assembled INPUT on a
# conservative effective cap and retry once with a compact atlas prompt before
# applying the configured blocking/advisory scope authority.
# The 1M constitutional window, the conservative sub-floor for unevidenced
# routes, and the shipped default reviewer identity live in `tools/scope_window`
# (the window-authority SSOT); imported here under the historical names.
# The shared prompt-size SSOT (920K) governs INPUT only; the reviewer also reserves
# _SCOPE_MAX_TOKENS of OUTPUT inside the same 1M window, and provider tokenizers can
# exceed estimate_tokens on atlas-heavy prompts — so gate assembled INPUT on a
# conservative effective cap and retry once with a compact atlas before applying the
# blocking/advisory scope authority. The 1M constitutional window, unevidenced-route
# sub-floor, and default reviewer identity live in `tools/scope_window` (the SSOT).
from ouroboros.tools.scope_window import ( # noqa: E402
SCOPE_FAILCLOSED_WINDOW as _SCOPE_FAILCLOSED_WINDOW,
SCOPE_MODEL_CONTEXT_WINDOW as _SCOPE_MODEL_CONTEXT_WINDOW,
@ -102,13 +98,10 @@ _SCOPE_INPUT_TOKEN_LIMIT = min(
_SCOPE_MODEL_CONTEXT_WINDOW - _SCOPE_MAX_TOKENS - _SCOPE_OUTPUT_MARGIN_TOKENS,
)
# Tokenizer-density calibration (rationale + SSOT in
# review_helpers.calibrated_input_token_limit + the capability_evidence
# ``token_density`` namespace). The density is MEASURED per model, so the limit is
# computed PER CALL — an import-time constant froze the pre-measurement value for the
# whole process and no observation could ever reach it. The calibration shrinks the
# PROMPT for the same pinned reviewer — never the reviewer model or the >=1M window
# floor (BIBLE P3).
# Tokenizer-density calibration (SSOT: review_helpers.calibrated_input_token_limit +
# capability_evidence ``token_density``). Density is MEASURED per model, so the limit
# is computed PER CALL (an import-time constant froze the pre-measurement value). The
# calibration shrinks the PROMPT — never the reviewer or the >=1M floor (BIBLE P3).
from ouroboros.reviewer_window import (
ReviewerWindow,
window_scaled_reserves as _shared_window_scaled_reserves,
@ -134,9 +127,8 @@ def _scope_review_skipped_in_low_context() -> bool:
return False
# Window authority moved to `tools/scope_window.py` (module-size gate at
# synthesis); re-imported under the old private aliases so every caller and
# test keeps one patch point on THIS module.
# Window authority moved to `tools/scope_window.py` (module-size gate); re-imported
# under the old private aliases so callers/tests keep one patch point on THIS module.
from ouroboros.tools.scope_window import ( # noqa: E402
WINDOW_ASSERTED as _WINDOW_ASSERTED, # noqa: F401 (test-read re-export)
WINDOW_CONFIRMED as _WINDOW_CONFIRMED, # noqa: F401 (test-read re-export)
@ -239,9 +231,8 @@ _DELETED_INLINE_MAX_BYTES = 1_048_576 # 1 MB
_SCOPE_CONTEXT_MANIFEST = contextvars.ContextVar("scope_context_manifest", default={})
# Stable-prefix boundary (chars) of the last assembled scope prompt: everything
# before it (instructions + checklist + canonical docs) is byte-stable across
# commits and carries the provider cache marker at dispatch. A contextvar keeps
# the existing (prompt, status) builder contract intact for all callers.
# before it (instructions + checklist + canonical docs) is byte-stable across commits
# and carries the provider cache marker at dispatch; contextvar keeps the builder contract.
_SCOPE_STABLE_PREFIX_LEN = contextvars.ContextVar("scope_stable_prefix_len", default=0)
@ -325,6 +316,9 @@ def _load_canonical_context_docs(repo_dir: pathlib.Path) -> str:
def _should_skip_current_touched_context(path: str) -> bool:
"""Touched paths whose full snapshots the fixed part omits by design: canonical
docs (injected whole elsewhere) and tests/ paths (changes ride the staged diff;
full atlas anchors, ladder-degradable but never canonical docs)."""
norm = str(path or "").replace("\\", "/").lstrip("./")
return (
norm in _CANONICAL_CONTEXT_DOCS
@ -377,10 +371,41 @@ def _classify_deleted_for_inline(path: str, repo_dir: pathlib.Path) -> Optional[
return "sensitive (env/credential/key)"
if suffix_lower in BINARY_EXTENSIONS:
return "binary extension"
from ouroboros.tools.review_binary_context import staged_path_is_binary
if staged_path_is_binary(repo_dir, path):
return "binary content"
return None
return "binary content" if staged_path_is_binary(repo_dir, path) else None
def _degradable_diff_only_paths(repo_dir: pathlib.Path, current: list,
skipped: list, deleted: list,
renamed: frozenset = frozenset()) -> list:
"""Touched paths the ladder may hand to the diff-only tier. Current paths join
freely, exactly as before (atlas-required ones degrade only after -U0). Touched
TESTS skipped-by-design current ones and deleted ones join the free tier too,
with cheap conservative guards: atlas-required tests never degrade; binary and
RENAMED paths keep their snapshot/metadata (the staged text diff may not carry
their change); an oversized/sensitive deletion keeps its suppression marker."""
def _degradable_test(p: str, is_deleted: bool) -> bool:
if atlas_required_beyond_diff(p.replace("\\", "/").lstrip("./")):
return False
if p in renamed or staged_path_is_binary(repo_dir, p):
return False
if is_deleted:
try:
head_bytes = int(run_cmd(["git", "cat-file", "-s", f"HEAD:{p}"], cwd=repo_dir))
except Exception:
return False
return (
head_bytes <= _DELETED_INLINE_MAX_BYTES
and _should_skip_current_touched_context(p)
and _classify_deleted_for_inline(p, repo_dir) is None
)
return True
return (
list(current)
+ [p for p in skipped if _degradable_test(p, False)]
+ [p for p in deleted if _degradable_test(p, True)]
)
def _inline_deleted_file_pack(
@ -389,14 +414,24 @@ def _inline_deleted_file_pack(
repo_dir: pathlib.Path,
*,
represent_binary: bool = False,
diff_only_paths: Optional[list] = None,
) -> str:
"""Append deleted-file HEAD content or explicit suppression markers."""
"""Append deleted-file HEAD content or explicit suppression markers;
``diff_only_paths`` members skip the HEAD inline (ladder-degraded): a text
deletion's complete content is the staged diff's own minus-lines."""
if not deleted_paths:
return current_files_section
notes: list[str] = []
for dp in deleted_paths:
suffix = pathlib.Path(dp).suffix.lstrip(".") or "text"
if dp in (diff_only_paths or ()):
notes.append(
f"### {dp}\n\n*(DELETED — full HEAD snapshot omitted to fit the "
"reviewer input budget; the complete removal is visible in the "
"staged diff below)*\n"
)
continue
suppress_reason = _classify_deleted_for_inline(dp, repo_dir)
if suppress_reason is not None:
if represent_binary and suppress_reason.startswith("binary"):
@ -404,9 +439,7 @@ def _inline_deleted_file_pack(
metadata = render_staged_binary_metadata(repo_dir, dp)
if metadata is None:
raise RuntimeError(
f"deleted binary {dp} has no exact staged Git metadata"
)
raise RuntimeError(f"deleted binary {dp} has no exact staged Git metadata")
notes.append(f"### {dp}\n\n{metadata}\n")
continue
notes.append(
@ -415,9 +448,7 @@ def _inline_deleted_file_pack(
continue
try:
head_content = run_cmd(
["git", "show", f"HEAD:{dp}"], cwd=repo_dir
)
head_content = run_cmd(["git", "show", f"HEAD:{dp}"], cwd=repo_dir)
except Exception:
head_content = ""
@ -458,13 +489,11 @@ def _gather_scope_packs(
snapshot_included_paths: Optional[frozenset] = None,
) -> str:
"""Collect the bounded wider repository atlas, failing closed on git errors."""
# WHICH snapshots the fixed part actually holds is the assembler's fact,
# never re-derived from the touched LIST: `all_touched_paths` also names
# files the fixed part omits by design (touched tests) or suppresses (a
# sensitive/oversized deletion), and claiming those as "included in fixed
# prompt context" is a false coverage claim (BIBLE P1) that also hides them
# from the atlas's own requiredness classification. Unclaimed paths are
# classified by the atlas; a canonical doc is claimed only if it exists.
# WHICH snapshots the fixed part holds is the assembler's fact, never re-derived
# from the touched LIST: `all_touched_paths` also names files the fixed part
# omits by design (touched tests) or suppresses (sensitive/oversized deletion) —
# claiming those would be a false coverage claim (BIBLE P1) that also hides them
# from requiredness classification. A canonical doc is claimed only if it exists.
already_included = frozenset(
set(snapshot_included_paths or frozenset())
| {doc for doc in _CANONICAL_CONTEXT_DOCS if (repo_dir / doc).is_file()}
@ -486,8 +515,7 @@ def _gather_scope_packs(
compact_manifest=compact,
)
)
# Set the manifest FIRST: disclosure accompanies the refusal below, it
# never replaces it (BIBLE P3).
# Set the manifest FIRST: disclosure accompanies the refusal, never replaces it (P3).
_SCOPE_CONTEXT_MANIFEST.set(atlas.manifest)
if atlas_assembly_failed(atlas):
raise _ScopeAtlasNotAssembled(atlas.manifest, atlas_assembly_failure_reason(atlas))
@ -520,52 +548,49 @@ def _render_touched_section(
) -> tuple:
"""Build the touched-files prompt section.
``diff_only_paths`` are degraded to an explicit disclosed note (their
changes stay fully visible in the staged diff) the guaranteed-fit
ladder's step for oversized fixed parts.
Returns ``(section, pack_omitted, snapshot_included)``. ``snapshot_included``
is the CONSERVATIVE set of paths whose full snapshot this section really
carries the atlas is told that and nothing more, so no coverage row can
claim content the pack does not hold (BIBLE P1).
"""
``diff_only_paths`` are degraded to an explicit disclosed note (changes stay
fully visible in the staged diff) the guaranteed-fit ladder's step.
Returns ``(section, pack_omitted, snapshot_included)``; the latter is the
CONSERVATIVE set of paths whose full snapshot this section really carries, so
no coverage row can claim content the pack does not hold (BIBLE P1)."""
kept = [path for path in current_context_paths if path not in diff_only_paths]
section, pack_omitted = build_touched_file_pack(
repo_dir, kept, represent_binary=represent_binary
)
section = _inline_deleted_file_pack(
section,
deleted_paths,
repo_dir,
represent_binary=represent_binary,
section, deleted_paths, repo_dir,
represent_binary=represent_binary, diff_only_paths=diff_only_paths,
)
if skipped_by_design:
# A ladder-degraded touched test moves to the degradation note below; listing
# it HERE too would claim an atlas snapshot the pack no longer holds.
skip_listed = [p for p in skipped_by_design if p not in diff_only_paths]
if skip_listed:
skip_note = (
"## CURRENT FILE CONTEXT DEDUPLICATION NOTE\n"
"The following touched files are not duplicated as full current-file "
"snapshots HERE because they are either canonical docs injected above "
"or tests whose exact changes are visible in the staged diff below. "
"A touched test is an atlas anchor, so its full snapshot appears once "
"in the generated atlas when the atlas selects it:\n"
+ "\n".join(f"- {path}" for path in skipped_by_design)
"A touched test listed here is delegated to the generated atlas (full "
"snapshot, or a typed binary/oversize row); tests degraded to diff-only "
"under budget pressure move to the degradation note instead:\n"
+ "\n".join(f"- {path}" for path in skip_listed)
+ "\n"
)
section = section + "\n\n" + skip_note if section.strip() else skip_note
if diff_only_paths:
degrade_note = (
"## TOUCHED FILE BUDGET DEGRADATION NOTE\n"
"The full post-change snapshots of the following touched files were "
"OMITTED to fit the budget (freely degradable first, largest per tier). "
"Their complete changes are still visible in the staged diff below; "
"treat this as an explicit, disclosed omission of unchanged "
"surrounding context, not a hidden gap:\n"
"The full snapshots (post-change; HEAD content for deletions) of the "
"following touched files were OMITTED to fit the budget (freely "
"degradable first, largest per tier). Their complete changes are still "
"visible in the staged diff below; treat this as an explicit, disclosed "
"omission of unchanged surrounding context, not a hidden gap:\n"
+ "\n".join(f"- {path}" for path in diff_only_paths)
+ "\n"
)
section = section + "\n\n" + degrade_note if section.strip() else degrade_note
# Only paths that CANNOT be absent: kept, not omitted by the pack builder,
# and a real file on disk (the builder can emit nothing else). Deleted paths
# are never claimed — they leave the index, so the atlas has no row for them.
# Only paths that CANNOT be absent: kept, not omitted by the pack builder, and a
# real file on disk. Deleted paths are never claimed — they leave the index.
snapshot_included = frozenset(
path for path in kept
if path not in set(pack_omitted) and (repo_dir / path).is_file()
@ -675,33 +700,27 @@ def _build_scope_prompt(
else ""
) + f"**IMPORTANT: {_CONVERGENCE_RULE_TEXT}**\n"
try:
diff_text = run_cmd(["git", "diff", "--cached"], cwd=repo_dir)
except Exception:
diff_text = "(failed to get staged diff)"
# Hardened, byte-exact, fail-closed: it raises rather than yield a placeholder.
diff_text = capture_staged_diff(repo_dir)
touched_entries = _parse_staged_name_status(repo_dir)
current_paths = [ep[1] for ep in touched_entries if ep[0] != "D"]
deleted_paths = [ep[1] for ep in touched_entries if ep[0] == "D"]
all_touched_paths = [ep[1] for ep in touched_entries]
renamed_paths = frozenset(
ep[1] for ep in touched_entries if str(ep[0]).upper().startswith("R"))
current_context_paths = [
path for path in current_paths
if not _should_skip_current_touched_context(path)
p for p in current_paths if not _should_skip_current_touched_context(p)
]
current_skipped_by_design = [
path for path in current_paths
if _should_skip_current_touched_context(path)
p for p in current_paths if _should_skip_current_touched_context(p)
]
def _render_current_section(diff_only_paths: list) -> tuple:
return _render_touched_section(
repo_dir,
current_context_paths,
deleted_paths,
current_skipped_by_design,
diff_only_paths,
represent_binary=represent_binary,
repo_dir, current_context_paths, deleted_paths,
current_skipped_by_design, diff_only_paths, represent_binary=represent_binary,
)
current_files_section, omitted, snapshot_included = _render_current_section([])
@ -731,45 +750,43 @@ def _build_scope_prompt(
gather_signature = inspect.signature(_gather_scope_packs)
gather_accepts_kwargs = any(
param.kind is inspect.Parameter.VAR_KEYWORD
for param in gather_signature.parameters.values()
p.kind is inspect.Parameter.VAR_KEYWORD for p in gather_signature.parameters.values()
)
gather_accepted = set(gather_signature.parameters)
def _atlas_section(fixed_tokens: int, compact: bool) -> str:
gather_kwargs = {
"fixed_prompt_tokens": fixed_tokens,
"drive_root": drive_root,
"scope_model": scope_model,
"compact": compact,
"fixed_prompt_tokens": fixed_tokens, "drive_root": drive_root,
"scope_model": scope_model, "compact": compact,
# The ladder owns which snapshots survived; the atlas is TOLD.
"diff_only_paths": list(diff_only_paths),
"snapshot_included_paths": snapshot_included,
}
return _gather_scope_packs(
repo_dir,
all_touched_paths,
**(
gather_kwargs
if gather_accepts_kwargs
else {key: value for key, value in gather_kwargs.items() if key in gather_accepted}
),
repo_dir, all_touched_paths,
**(gather_kwargs if gather_accepts_kwargs
else {k: v for k, v in gather_kwargs.items() if k in gather_accepted}),
)
def _touched_token_estimate(path: str) -> int:
try:
return int((repo_dir / path).stat().st_size) // 4 + 64
except OSError:
return 0
except OSError: # deleted: the fixed part inlines the HEAD blob instead
try:
return int(run_cmd(["git", "cat-file", "-s", f"HEAD:{path}"], cwd=repo_dir)) // 4 + 64
except Exception:
return 0
# Guaranteed-fit ladder: 1) full atlas; 2) compact atlas; 3) degrade freely degradable touched
# files to diff-only, largest first (disclosed; changes stay visible in the staged diff);
# 4) drop unchanged diff context; 5) artifacts owed in full, last resort. Else fails CLOSED.
# Guaranteed-fit ladder: full atlas; compact atlas; degrade degradable touched files
# to diff-only (largest first); drop unchanged diff context; artifacts last. Else CLOSED.
input_limit = _effective_scope_input_limit(scope_model=scope_model)
_atlas_min_allowance = 35_000 # manifest reserve + hard headroom, see review_context_atlas
diff_only_paths: list = []
# FREE tier includes touched tests and eligible deletions (guards in the helper).
degradable = sorted(
current_context_paths,
_degradable_diff_only_paths(
repo_dir, current_context_paths, current_skipped_by_design, deleted_paths,
renamed_paths),
key=lambda path: (atlas_required_beyond_diff(path), -_touched_token_estimate(path)),
)
compact = False
@ -777,9 +794,7 @@ def _build_scope_prompt(
last_known_tokens = 0
unassembled_required: list = []
atlas_overflowed = False
# One AGGREGATED record of the guaranteed-fit ladder (RS5): a per-step event
# stream would be noise, but a silent ladder makes an oversized pack
# unexplainable after the fact (BIBLE P1).
# One AGGREGATED ladder record (RS5); a silent ladder is unexplainable (BIBLE P1).
ladder_steps: list = []
while True:
prompt = _assemble_prompt(current_files_section)
@ -797,14 +812,11 @@ def _build_scope_prompt(
refusal = compact_exc
if atlas_text is None:
last_known_tokens = int(refusal.manifest.get("estimated_total_tokens") or 0)
# The atlas manifest stays the ONE carrier of what did not assemble,
# and a refusal is a ladder STEP with a trace row exactly like the
# assembly branch below (an empty trace explains nothing — P1).
# The atlas manifest is the ONE carrier of what did not assemble; a
# refusal is a ladder STEP (P1) that can carry TWO causes — capture both.
unassembled_required = [
str(row.get("path") or "?") for row in atlas_unassembled_required(refusal.manifest)
]
# The refusal can carry TWO causes at once (missing required
# artifact AND hard-budget overflow) — capture both facts.
atlas_overflowed = atlas_hard_budget_overflowed(refusal.manifest)
ladder_steps.append({
"step": "atlas_refused", "compact": compact, "reason": str(refusal),
@ -812,6 +824,7 @@ def _build_scope_prompt(
"atlas_overflowed": atlas_overflowed,
"tokens_after": last_known_tokens,
"diff_only_files": len(diff_only_paths),
"diff_only_paths": list(diff_only_paths),
"zero_context_diff": compact_diff_attempted,
})
@ -829,6 +842,7 @@ def _build_scope_prompt(
"tokens_before": last_known_tokens,
"tokens_after": prompt_tokens,
"diff_only_files": len(diff_only_paths),
"diff_only_paths": list(diff_only_paths),
"zero_context_diff": compact_diff_attempted,
"deficit": max(0, prompt_tokens - input_limit),
})
@ -842,8 +856,7 @@ def _build_scope_prompt(
continue
deficit = prompt_tokens - input_limit
else:
# Even the atlas manifest cannot fit beside the fixed part: shrink
# the fixed part enough to give the manifest its minimum room.
# Even the manifest cannot fit beside the fixed part: shrink it for room.
deficit = max(50_000, fixed_prompt_tokens + _atlas_min_allowance - input_limit)
def can_degrade() -> bool: # required tier only after -U0
@ -853,19 +866,16 @@ def _build_scope_prompt(
if not compact_diff_attempted: # every +/- line, no unchanged context
compact_diff_attempted = True
try:
compact_diff = run_cmd(["git", "diff", "--cached", "-U0"], cwd=repo_dir)
except Exception:
compact_diff = ""
compact_diff = capture_staged_diff(repo_dir, unified=0)
except StagedDiffUnavailable:
compact_diff = "" # the full capture above stays the evidence
if compact_diff.strip() and compact_diff != diff_text:
diff_text = compact_diff
continue
if can_degrade(): # -U0 gave nothing, but the required tier is open now
continue
# Terminal pack status: >=1M authority is fixed_overflow; a sub-floor
# pack is budget_exceeded here and the authority policy turns it into
# a block unless the owner explicitly selected advisory scope. The
# CAUSE travels separately — both branches report the real one. The
# window is the evidence-resolved sizing window, never a hardcoded table.
# Terminal pack status: >=1M authority is fixed_overflow; a sub-floor pack is
# budget_exceeded (blocked unless owner advisory). CAUSE travels separately.
_record_ladder_steps(ladder_steps)
known = _scope_window(
scope_model or _get_scope_model()
@ -881,9 +891,8 @@ def _build_scope_prompt(
path = degradable.pop(0)
diff_only_paths.append(path)
freed += _touched_token_estimate(path)
# Re-render AND re-read what the shrunken section now holds: a path just
# degraded to diff-only has stopped being a survivor, so the next atlas
# build must not be told otherwise.
# Re-render AND re-read what the shrunken section now holds: a freshly
# degraded path is no survivor, and the next atlas build must know that.
current_files_section, _, snapshot_included = _render_current_section(diff_only_paths)
@ -959,9 +968,8 @@ def _call_scope_llm(
if delegated:
messages: Any = []
else:
# Split at the recorded stable/dynamic boundary so the byte-stable prefix
# (instructions + checklist + canonical docs) carries the provider cache
# marker while the per-commit tail (diff/atlas/history) stays unmarked.
# Split at the recorded stable/dynamic boundary: the byte-stable prefix
# carries the provider cache marker, the per-commit tail stays unmarked.
from ouroboros.tools.review_helpers import cached_prompt_blocks
_stable_len = int(_SCOPE_STABLE_PREFIX_LEN.get() or 0)
@ -994,9 +1002,8 @@ def _call_scope_llm(
no_proxy=True,
session_task=session_task if delegated else "",
session_root=session_root if delegated else "",
# The extraction fallback canonicalizes to the SCOPE contract: the
# required-matrix shape with the eight verbatim item ids (D19 — the
# light model follows the review's own contract, never a looser one).
# The extraction fallback canonicalizes to the SCOPE contract: required-
# matrix shape, eight verbatim item ids (D19 — never a looser contract).
policy=(
{
"output_contract": (
@ -1018,9 +1025,8 @@ def _call_scope_llm(
max_tokens=_scope_output_tokens,
temperature=0.2,
# ROUTE is CARRIED, never re-derived: the one-element slot list above
# re-reads ROUTES **row 1**, which sent a mixed config's api row as
# agent_session (pack, no task) — the caller's fanned-out route is the
# authority (p5x XG fix, kept through the 6.1 threading).
# re-reads ROUTES row 1, which sent a mixed config's api row as
# agent_session — the caller's fanned-out route is the authority (p5x XG).
route=ReviewRouteKind.AGENT_SESSION if delegated else ReviewRouteKind.API_CHAT,
# The fanned-out row's own session target (6.1); '' keeps the
# shared session-route fallback.
@ -1166,9 +1172,8 @@ def _handle_prompt_signals(
"scope gate has no authoritative verdict."
),
status="sub_floor",
# No prompt string exists on this path (the fit ladder returned a
# sentinel), so the char count is DERIVED from the token estimate and
# labelled as such instead of masquerading as a measurement.
# No prompt string exists on this path (ladder sentinel): the char count
# is DERIVED from the token estimate and labelled as such.
prompt_chars=token_count * 4,
prompt_chars_source="estimated_from_tokens",
advisory_findings=[{
@ -1184,11 +1189,10 @@ def _handle_prompt_signals(
)
if context_status.status == "fixed_overflow":
# The guaranteed-fit ladder exhausted every degradation step. TWO failures
# land here — an irreducible prompt that overflows, and a REQUIRED artifact
# that never assembled — and they can COINCIDE, so the cause(s) are READ
# from the status, not assumed, and every one that applies is rendered.
# Either way: a structural condition the owner must see, failing CLOSED.
# The ladder exhausted every degradation step. TWO failures land here — an
# irreducible overflowing prompt, and a REQUIRED artifact that never
# assembled — and they can COINCIDE, so the cause(s) are READ from the
# status and every one that applies is rendered. Fails CLOSED either way.
token_count = context_status.token_count
cause, remedy = _ladder_terminal_cause(context_status, input_limit)
return ScopeReviewResult(
@ -1277,9 +1281,8 @@ def _apply_scope_authority(
# EVIDENCE, never the sizing fallback: the session floor is gated on SOURCED
# provenance, and a fail-closed sizing number handed over as a window would
# read as evidence for exactly the number the session floor sits at. A STALE
# record sizes a prompt but authorises nothing (same rule as the api row), so
# its provenance is blanked before the session predicate reads it.
# read as evidence for exactly the session-floor number. A STALE record sizes
# a prompt but authorises nothing (api-row rule): provenance blanked first.
return session_scope_authority(
critical_findings, advisory_findings, scope_model=scope_model_id,
window=int(resolved.window_tokens or 0),
@ -1426,10 +1429,9 @@ def run_scope_review(
_cost_usd = float(_usage.get("cost", 0.0) or 0.0)
if llm_error:
if _is_provider_oversize_error(llm_error):
# The estimate-based budget gate passed but the provider's REAL
# tokenizer rejected the prompt as oversize: there is no authoritative
# verdict, so the >=1M gate fails CLOSED (since v6.80.0 no setting can
# make this non-blocking; the owner's only control is the context mode).
# The estimate-based gate passed but the provider's REAL tokenizer called
# the prompt oversize: no authoritative verdict, so the >=1M gate fails
# CLOSED (v6.80.0: not configurable; owner controls only context mode).
log.warning(
"Scope reviewer rejected the prompt as oversize "
"(estimate-gate passed; real tokenizer denser). Failing the "
@ -1457,19 +1459,16 @@ def run_scope_review(
response_ref=_response_ref,
)
# Usage emission happens ONCE, inside the shared review substrate
# (source="review_substrate:scope_review", carrying ledger_attempt_ids).
# The former job-level re-emit here duplicated every scope call in the
# llm_usage telemetry without attempt ids, so the pair could not be
# deduplicated against the monetary ledger (v6.69.0).
# (source="review_substrate:scope_review", carrying ledger_attempt_ids). The old
# job-level re-emit duplicated every scope call without attempt ids, so the pair
# could not be deduplicated against the monetary ledger (v6.69.0).
if _provider_error_is_oversize(_usage, _prompt_tokens_est, scope_model_id):
# Gateway route (openai-compatible/OpenRouter): a real oversize 400 arrives as
# an EMPTY body + usage['provider_error']{code:400}, NOT a raised error carrying
# the "prompt is too long" text — so the llm_error oversize branch above never
# fires and the empty body would otherwise hard-block as empty_response. With
# INDEPENDENT size evidence (see _provider_error_is_oversize), route through
# the same fail-closed oversize result as the raised-error path. A
# non-size 400 (auth/param/policy) stays blocking below.
# an EMPTY body + usage['provider_error']{code:400}, not a raised "prompt is
# too long" error — the llm_error branch above never fires and the empty body
# would hard-block as empty_response. With INDEPENDENT size evidence, route
# through the same fail-closed oversize result; non-size 400 stays blocking.
_pe_msg = str((_usage.get("provider_error") or {}).get("message") or "")
log.warning(
"Scope reviewer hit provider_error code=400 oversize (empty body; "

View file

@ -521,8 +521,15 @@ def _copy_untracked_for_rescue(dst_root: pathlib.Path, max_files: int = 200,
return out
def _atomic_write_bytes(path: pathlib.Path, data: bytes) -> None:
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
tmp.write_bytes(data)
tmp.replace(path)
def _create_rescue_snapshot(branch: str, reason: str,
repo_state: Dict[str, Any]) -> Dict[str, Any]:
repo_state: Dict[str, Any], *,
link_evolution: bool = True) -> Dict[str, Any]:
now = datetime.datetime.now(datetime.timezone.utc)
ts = now.strftime("%Y%m%d_%H%M%S")
rescue_dir = DRIVE_ROOT / "archive" / "rescue" / f"{ts}_{uuid.uuid4().hex[:8]}"
@ -544,12 +551,30 @@ def _create_rescue_snapshot(branch: str, reason: str,
atomic_write_text(rescue_dir / "status.porcelain.txt",
status_txt + ("\n" if status_txt else ""))
rc_diff, diff_txt, diff_err = git_capture(["git", "diff", "--binary", "HEAD"])
if rc_diff == 0:
atomic_write_text(rescue_dir / "changes.diff",
diff_txt + ("\n" if diff_txt else ""))
else:
info["diff_error"] = diff_err or "git diff failed"
# changes.diff must survive BYTES end-to-end: on an unmerged index it is the
# ONLY carrier of in-progress resolutions, and text-mode capture would corrupt
# non-UTF-8 content into U+FFFD. The flag tail pins away operator config that
# reshapes diff output into something `git apply` cannot re-apply: external
# diff drivers (--no-ext-diff), textconv filters (--no-textconv), colour
# escapes (--no-color) and prefix rewrites (--src-prefix/--dst-prefix beat
# diff.noprefix). GIT_DIFF_OPTS is dropped from the environment because it
# can carry a context-width override that beats the flags.
try:
capture_env = {k: v for k, v in os.environ.items() if k != "GIT_DIFF_OPTS"}
capture_env.update({"LC_ALL": "C", "LANG": "C"})
diff_proc = subprocess.run(
["git", "diff", "--binary", "--no-ext-diff", "--no-textconv", "--no-color",
"--src-prefix=a/", "--dst-prefix=b/", "HEAD"],
cwd=str(REPO_DIR), capture_output=True, env=capture_env,
)
if diff_proc.returncode == 0:
_atomic_write_bytes(rescue_dir / "changes.diff", diff_proc.stdout or b"")
else:
info["diff_error"] = ((diff_proc.stderr or b"").decode("utf-8", "replace").strip()
or "git diff failed")
except Exception as diff_exc:
log.warning("Rescue diff capture failed", exc_info=True)
info["diff_error"] = repr(diff_exc)
# Also capture tracked changes as a real, recoverable git object so recovery
# is `git stash apply <sha>` / `git checkout <ref> -- .` rather than only a
@ -557,9 +582,14 @@ def _create_rescue_snapshot(branch: str, reason: str,
# changes (it omits untracked files, which the copy below preserves). Purely
# additive: failure here never blocks the reset and the diff/untracked copy
# remain the primary recovery artifacts.
rc_stash, stash_sha, _ = git_capture(["git", "stash", "create", f"rescue:{reason}"])
rc_stash, stash_sha, stash_err = git_capture(["git", "stash", "create", f"rescue:{reason}"])
stash_sha = stash_sha.strip()
if rc_stash == 0 and stash_sha:
if rc_stash != 0:
# rc==0 with an empty sha is LEGITIMATE (nothing to stash / untracked-only
# dirt); a nonzero rc — e.g. "needs merge" on an unmerged index — is
# disclosed instead of silently omitting rescue_ref.
info["rescue_stash_error"] = stash_err or "git stash create failed"
elif stash_sha:
ref_name = f"refs/rescue/{rescue_dir.name}"
rc_ref, _, ref_err = git_capture(["git", "update-ref", ref_name, stash_sha])
if rc_ref == 0:
@ -568,6 +598,35 @@ def _create_rescue_snapshot(branch: str, reason: str,
else:
info["rescue_ref_error"] = ref_err or "git update-ref failed"
# Merge topology (best-effort): an in-progress merge cannot be stash-captured,
# so record MERGE_HEAD, the unmerged index entries, and the merge message —
# together with changes.diff (a plain worktree-vs-HEAD diff that DOES carry
# in-progress resolutions) they make the merge state operator-recoverable.
try:
rc_mh, merge_head, _mh_err = git_capture(
["git", "rev-parse", "-q", "--verify", "MERGE_HEAD"]
)
if rc_mh == 0 and merge_head.strip():
info["merge_head"] = merge_head.strip()
rc_u, unmerged_txt, _u_err = git_capture(["git", "ls-files", "-u"])
if rc_u == 0 and unmerged_txt:
atomic_write_text(rescue_dir / "unmerged.txt", unmerged_txt + "\n")
# Unique conflicted PATHS (stage 1/2/3 rows collapse to one path).
info["unmerged_count"] = len({
ln.split("\t", 1)[-1] for ln in unmerged_txt.splitlines() if ln.strip()
})
# --git-path: in a linked worktree .git is a FILE, so a naive
# .git/MERGE_MSG probe would silently drop the message.
rc_p, msg_rel, _p_err = git_capture(["git", "rev-parse", "--git-path", "MERGE_MSG"])
merge_msg_path = (REPO_DIR / msg_rel) if rc_p == 0 and msg_rel else (
_git_dir() / "MERGE_MSG"
)
if merge_msg_path.is_file():
atomic_write_text(rescue_dir / "merge_msg.txt",
merge_msg_path.read_text(encoding="utf-8", errors="replace"))
except Exception:
log.warning("Failed to capture merge topology into rescue snapshot", exc_info=True)
untracked_meta = _copy_untracked_for_rescue(rescue_dir / "untracked")
info["untracked"] = untracked_meta
@ -578,7 +637,8 @@ def _create_rescue_snapshot(branch: str, reason: str,
atomic_write_text(rescue_dir / "rescue_meta.json",
json.dumps(info, ensure_ascii=False, indent=2))
_link_rescue_to_evolution_transaction(info, reason)
if link_evolution:
_link_rescue_to_evolution_transaction(info, reason)
return info
@ -620,6 +680,98 @@ def _rescue_untracked_incomplete(rescue_info: Dict[str, Any]) -> str:
return ""
def rescue_before_destructive_rollback(reason: str, *, context: str = "rollback") -> Dict[str, Any]:
"""Best-effort rescue snapshot before a destructive managed-update step.
Returns a pointer ``{path, ref, ts}`` on capture, ``{}`` when the tree is
clean and no merge is in progress nothing to rescue, so a replayed
``rolling_back`` boot stays idempotent and ``{"error": ...}`` on failure.
A git-status failure counts as a DIRTY tree: an unreadable tree is rescued,
not skipped. ``context`` only labels the durable reason (``rollback``
``managed_update_rollback:*``, anything else ``managed_update_rescue:*``,
e.g. the boot re-materialization path). FAIL-OPEN by owner decision
(2026-08-10, 4=A): failures never block the rollback they are logged and
returned as the typed ``error`` marker. One durable supervisor.jsonl line
records the capture (or its failure) before the destructive step; that
write itself never branches the flow. The snapshot is NOT linked to the
active evolution transaction it documents a managed-update rollback, and
the link would flip a live evolution cycle to "abandoned". Transaction
bookkeeping stays with the caller (update_merge); this helper only talks to
git and the supervisor log."""
try:
rc_status, dirty, _status_err = git_capture(["git", "status", "--porcelain"])
rc_mh, merge_head, _mh_err = git_capture(
["git", "rev-parse", "-q", "--verify", "MERGE_HEAD"]
)
merge_in_progress = rc_mh == 0 and bool(merge_head.strip())
if rc_status == 0 and not dirty.strip() and not merge_in_progress:
return {}
repo_state = _collect_repo_sync_state()
branch = str(repo_state.get("current_branch") or BRANCH_DEV)
prefix = "managed_update_rollback" if context == "rollback" else "managed_update_rescue"
info = _create_rescue_snapshot(
branch, f"{prefix}:{reason}", repo_state, link_evolution=False,
)
result: Dict[str, Any] = {
"path": str(info.get("path") or ""),
"ref": str(info.get("rescue_ref") or ""),
"ts": str(info.get("ts") or ""),
}
event = {
"ts": utc_now_iso(), "type": "managed_update_rescue_captured",
"reason": reason, "rescue_path": result["path"],
**({"rescue_ref": result["ref"]} if result["ref"] else {}),
}
except Exception as exc:
log.warning(
"rescue before destructive rollback failed (rollback continues)", exc_info=True
)
result = {"error": repr(exc)}
event = {"ts": utc_now_iso(), "type": "managed_update_rescue_failed",
"reason": reason, "error": repr(exc)}
try:
if not append_jsonl(DRIVE_ROOT / "logs" / "supervisor.jsonl", event):
log.warning(
"rescue disclosure could not be written to supervisor.jsonl "
"(rescue itself is at %s)", result.get("path") or "<none>",
)
except Exception:
log.warning("rescue disclosure raised (continuing)", exc_info=True)
return result
def rescue_into_tx(tx: Dict[str, Any], *, key: str, reason: str, context: str,
writer) -> Dict[str, Any]:
"""Take a pre-destructive rescue and record its outcome in the update tx.
A captured pointer lands under *key* as ``{path, ref?, ts, reason, count}``
and is persisted via *writer* (``update_merge.write_update_tx``) BEFORE the
caller's destructive step — the persisted pointer doubles as the replay
guard against duplicate rescues. ``count`` increments when a previous
pointer is overwritten (each re-materialization takes a fresh rescue), so
the objective renderer can honestly say "latest of N". A capture failure is
recorded in-memory under ``<key>_error`` for the caller's terminal event and
is NOT persisted, so a retried rollback re-attempts the rescue. Fail-open
throughout: a failed tx write is logged and never blocks the caller."""
rescue_info = rescue_before_destructive_rollback(reason, context=context)
if rescue_info.get("path"):
prior = tx.get(key)
count = (int(prior.get("count") or 1) + 1) if isinstance(prior, dict) else 1
pointer = {"path": rescue_info["path"], "ts": rescue_info.get("ts") or "",
"reason": reason, "count": count}
if rescue_info.get("ref"):
pointer["ref"] = rescue_info["ref"]
tx[key] = pointer
try:
writer(tx)
except Exception:
log.warning("could not persist the %s rescue pointer into the update tx",
key, exc_info=True)
elif rescue_info.get("error"):
tx[f"{key}_error"] = str(rescue_info["error"])
return rescue_info
def _compute_ref_ahead_count(ref: str, target_ref: str) -> Tuple[bool, int, str]:
"""Return whether *ref* is ahead of *target_ref*, failing closed on errors."""
if not ref or not target_ref:

View file

@ -857,24 +857,38 @@ def rollback_managed_update(
return False, "rollback could not clear update intent before restoring the repository"
if resume_with_restored_work:
return _finish_rollback(tx, pre, branch, reason, "", reopen_writer_admission)
# Fresh rescue BEFORE the destructive reset; the persisted pointer is the replay guard.
if not tx.get("rollback_rescue"):
_g.rescue_into_tx(tx, key="rollback_rescue", reason=str(reason),
context="rollback", writer=write_update_tx)
def _fail(message: str) -> Tuple[bool, str]:
# Drop the marker (best-effort) so a RETRY re-rescues the tree it actually finds.
if tx.pop("rollback_rescue", None) is not None:
try:
write_update_tx(tx)
except Exception:
_g.log.warning("could not drop the stale rollback_rescue marker", exc_info=True)
return False, message
rc_h, cur_head, _he = _g.git_capture(["git", "rev-parse", "--short", "HEAD"])
if rc_h == 0 and cur_head:
_g.git_capture(["git", "branch", "-f", f"failed-update-{cur_head}", "HEAD"])
rc0, _o0, e0 = _g.git_capture(["git", "reset", "--hard", "HEAD"])
if rc0 != 0:
return False, f"rollback reset failed before checkout: {e0}"
return _fail(f"rollback reset failed before checkout: {e0}")
rc_clean0, _co0, ce0 = _g.git_capture(["git", "clean", "-fd"])
if rc_clean0 != 0:
return False, f"rollback clean failed before checkout: {ce0}"
return _fail(f"rollback clean failed before checkout: {ce0}")
rc1, _o1, e1 = _g.git_capture(["git", "checkout", "-B", branch, pre])
if rc1 != 0:
return False, f"rollback checkout -B {branch} {pre[:12]} failed: {e1}"
return _fail(f"rollback checkout -B {branch} {pre[:12]} failed: {e1}")
rc2, _o2, e2 = _g.git_capture(["git", "reset", "--hard", pre])
if rc2 != 0:
return False, f"rollback reset --hard {pre[:12]} failed: {e2}"
return _fail(f"rollback reset --hard {pre[:12]} failed: {e2}")
rc_clean, _co, clean_error = _g.git_capture(["git", "clean", "-fd"])
if rc_clean != 0:
return False, f"rollback clean failed: {clean_error}"
return _fail(f"rollback clean failed: {clean_error}")
rc_h2, restored_head, head_error = _g.git_capture(["git", "rev-parse", "--verify", "HEAD"])
rc_b2, restored_branch, branch_error = _g.git_capture(
["git", "rev-parse", "--abbrev-ref", "HEAD"]
@ -891,7 +905,7 @@ def rollback_managed_update(
or not managed_update_constitution_present("HEAD")
):
detail = head_error or branch_error or status_error or "rollback verification mismatch"
return False, f"rollback could not be verified: {detail}"
return _fail(f"rollback could not be verified: {detail}")
stash_note = ""
stash_sha = str(tx.get("stash_sha") or "")
if stash_sha:
@ -924,10 +938,13 @@ def _finish_rollback(
return False, "rollback restored the repository but could not close writer admission"
if not clear_update_tx():
return False, "rollback restored the repository but could not clear update transaction"
rescue = tx.get("rollback_rescue") if isinstance(tx.get("rollback_rescue"), dict) else {}
append_jsonl(
_g.DRIVE_ROOT / "logs" / "supervisor.jsonl",
{"ts": utc_now_iso(), "type": "managed_update_rolled_back", "reason": reason,
"pre_update_sha": pre, "branch": branch},
"pre_update_sha": pre, "branch": branch,
**{f"rescue_{key}": rescue[key] for key in ("path", "ref", "ts") if rescue.get(key)},
**({"rescue_error": tx["rollback_rescue_error"]} if tx.get("rollback_rescue_error") else {})},
)
if reopen_writer_admission:
try:
@ -942,29 +959,6 @@ def _finish_rollback(
return True, message
def _assisted_objective(tx: Dict[str, Any]) -> str:
target = str(tx.get("target_sha") or "")[:12]
conflicts = list(tx.get("conflict_paths") or [])
if conflicts:
work = (
f"Resolve each conflicting file ({', '.join(conflicts)}), preserve both intents "
"where possible, and remove every conflict marker (<<<<<<<, =======, >>>>>>>)."
)
else:
work = (
"The merge itself is clean, but it combines local and official history and therefore "
"requires review. Inspect the staged combination and correct it if needed."
)
return (
f"A managed Ouroboros update (target {target}) has been merged into your working tree by the "
"supervisor: MERGE_HEAD is set and the combined tree is staged for review. Do NOT run any git "
"command (fetch/merge/commit/checkout are blocked) — the merge is already staged for you. "
f"{work} Do not discard either side merely because a file is normally restricted. When ready, "
"run `advisory_review` with the commit message, then `commit_reviewed` (it will create the reviewed "
"2-parent merge commit), then `request_restart` to finish landing the update."
)
def ensure_assisted_resolver_ready(expected_sha: str, timeout_sec: float = 90.0) -> bool:
"""Prove a fresh resolver imported the exact clean tree before conflicts go live."""
from supervisor import workers
@ -1011,11 +1005,12 @@ def enqueue_assisted_resolution_task(tx: Dict[str, Any]) -> str:
structured metadata stay in one place. Returns the task id."""
from supervisor import workers
from supervisor.queue import _queue_lock, enqueue_task
from supervisor.update_merge_policy import assisted_objective
task_id = str(tx.get("task_id") or "")
task = {
"id": task_id,
"text": _assisted_objective(tx),
"text": assisted_objective(tx),
"type": "task",
"chat_id": int(tx.get("owner_chat_id") or 0),
"metadata": {
@ -1248,7 +1243,13 @@ def _recover_assisted_on_boot(tx: Dict[str, Any], supervisor_ready: bool) -> Dic
# progress when MERGE_HEAD + a dirty tree already survived.
rc_d, dirty, _de = _g.git_capture(["git", "status", "--porcelain"])
has_progress = bool(_merge_head_sha()) and rc_d == 0 and bool(dirty.strip())
rescue_info: Dict[str, Any] = {}
if not has_progress:
# Re-materialization hard-resets the tree: rescue surviving dirty work; the
# pointer persists BEFORE materialize and reaches the objective via enqueue.
rescue_info = _g.rescue_into_tx(
tx, key="progress_rescue", reason="assisted_rematerialize",
context="rematerialize", writer=write_update_tx)
ok, msg = materialize_assisted_merge_live(
str(tx.get("pre_update_branch") or _g.BRANCH_DEV),
str(tx.get("local_snapshot") or ""),
@ -1268,7 +1269,8 @@ def _recover_assisted_on_boot(tx: Dict[str, Any], supervisor_ready: bool) -> Dic
write_update_tx(tx)
enqueue_assisted_resolution_task(tx)
_log_supervisor({"type": "managed_update_assisted_resumed",
"resolution_attempts": attempts, "preserved_progress": has_progress})
"resolution_attempts": attempts, "preserved_progress": has_progress,
**({"progress_rescue_error": rescue_info["error"]} if rescue_info.get("error") else {})})
return {"finalized": False, "resumed": True, "resolution_attempts": attempts}
# unknown: do not touch the tree; leave the tx for the owner / a later boot.
_log_supervisor({"type": "managed_update_assisted_unknown_state"})

View file

@ -1,14 +1,16 @@
"""Presentation-only labels for managed-update conflicts.
"""Presentation-only labels and text for managed-update conflicts.
Git decides whether a merge is clean. Every conflict, regardless of pathname,
goes through the same reviewed assisted resolver. The doc/code/hot split only
helps the resolver and UI describe the plan; it grants or blocks nothing.
``assisted_objective`` renders the resolver task's objective text — presentation
only as well; the authority lives in the tx marker and its fingerprint.
"""
from __future__ import annotations
import posixpath
from typing import Dict, List
from typing import Any, Dict, List
DOCUMENT_EXACT = frozenset({"README.md"})
DOCUMENT_PREFIXES = ("docs/",)
@ -49,3 +51,47 @@ def classify_conflicts(conflict_paths: List[str]) -> Dict[str, object]:
"code_conflict_paths": code,
"hot_code_paths": [path for path in code if is_hot_code(path)],
}
def rescue_pointer_note(tx: Dict[str, Any]) -> str:
"""One plain sentence pointing the resolver at rescued uncommitted work.
Reads the latest rescue pointer (``progress_rescue``, falling back to
``rollback_rescue``); when several rescues were taken, only the latest is
named plus a count no history rendering. Returns "" when there is nothing
to point at."""
pointer = tx.get("progress_rescue") or tx.get("rollback_rescue")
if not isinstance(pointer, dict) or not pointer.get("path"):
return ""
count = int(pointer.get("count") or 1)
tally = f" ({count} rescues were taken; this is the latest)" if count > 1 else ""
return (
f" A previous attempt's uncommitted work was rescued to {pointer['path']}{tally}; "
"changes.diff there is a plain diff against the reviewed base. Read the rescued "
"files to re-apply prior resolutions — do not run git commands."
)
def assisted_objective(tx: Dict[str, Any]) -> str:
"""Objective text for the single authorized assisted-resolution task."""
target = str(tx.get("target_sha") or "")[:12]
conflicts = list(tx.get("conflict_paths") or [])
if conflicts:
work = (
f"Resolve each conflicting file ({', '.join(conflicts)}), preserve both intents "
"where possible, and remove every conflict marker (<<<<<<<, =======, >>>>>>>)."
)
else:
work = (
"The merge itself is clean, but it combines local and official history and therefore "
"requires review. Inspect the staged combination and correct it if needed."
)
return (
f"A managed Ouroboros update (target {target}) has been merged into your working tree by the "
"supervisor: MERGE_HEAD is set and the combined tree is staged for review. Do NOT run any git "
"command (fetch/merge/commit/checkout are blocked) — the merge is already staged for you. "
f"{work} Do not discard either side merely because a file is normally restricted. When ready, "
"run `advisory_review` with the commit message, then `commit_reviewed` (it will create the reviewed "
"2-parent merge commit), then `request_restart` to finish landing the update."
f"{rescue_pointer_note(tx)}"
)

View file

@ -1,3 +1,4 @@
import json
import os
import subprocess
import time
@ -1278,6 +1279,163 @@ def test_create_rescue_snapshot_writes_recoverable_ref(monkeypatch, tmp_path):
assert (repo / "f.txt").read_text(encoding="utf-8") == "v2-uncommitted\n"
def _rescue_fixture_repo(tmp_path):
repo = tmp_path / "repo"; repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "t@example.com"], cwd=repo, check=True)
subprocess.run(["git", "config", "user.name", "T"], cwd=repo, check=True)
subprocess.run(["git", "config", "commit.gpgsign", "false"], cwd=repo, check=True)
(repo / "f.txt").write_text("base\n", encoding="utf-8")
subprocess.run(["git", "add", "f.txt"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", "base"], cwd=repo, check=True, capture_output=True)
branch = subprocess.run(
["git", "symbolic-ref", "--short", "HEAD"], cwd=repo, capture_output=True, text=True
).stdout.strip()
return repo, branch
def _conflicted_rescue_repo(tmp_path):
"""A fixture repo parked on a real conflicted merge (MERGE_HEAD + unmerged index)."""
repo, branch = _rescue_fixture_repo(tmp_path)
subprocess.run(["git", "checkout", "-q", "-b", "theirs"], cwd=repo, check=True,
capture_output=True)
(repo / "f.txt").write_text("theirs\n", encoding="utf-8")
subprocess.run(["git", "commit", "-am", "theirs"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "checkout", "-q", branch], cwd=repo, check=True, capture_output=True)
(repo / "f.txt").write_text("ours\n", encoding="utf-8")
subprocess.run(["git", "commit", "-am", "ours"], cwd=repo, check=True, capture_output=True)
assert subprocess.run(["git", "merge", "theirs"], cwd=repo,
capture_output=True).returncode != 0
return repo, branch
def test_create_rescue_snapshot_captures_merge_topology_on_unmerged_index(monkeypatch, tmp_path):
"""On an in-progress conflicted merge the snapshot must keep the uncommitted
resolution content, disclose the stash failure, and record the merge topology."""
import pathlib
repo, branch = _conflicted_rescue_repo(tmp_path)
(repo / "f.txt").write_text("agent resolution\n", encoding="utf-8") # uncommitted resolution
monkeypatch.setattr(git_ops, "REPO_DIR", repo)
monkeypatch.setattr(git_ops, "DRIVE_ROOT", tmp_path / "data")
info = git_ops._create_rescue_snapshot(
branch, "merge-test",
{"current_branch": branch, "dirty_lines": ["UU f.txt"], "unpushed_lines": [], "warnings": []},
)
rescue_dir = pathlib.Path(info["path"])
assert "agent resolution" in (rescue_dir / "changes.diff").read_text(encoding="utf-8")
# The stash failure ("needs merge") is disclosed instead of silently dropped.
assert info.get("rescue_stash_error")
assert "rescue_ref" not in info
merge_head = subprocess.run(
["git", "rev-parse", "MERGE_HEAD"], cwd=repo, capture_output=True, text=True
).stdout.strip()
assert info.get("merge_head") == merge_head
# Unique conflicted PATHS (one file), not its three stage-1/2/3 index rows.
assert int(info.get("unmerged_count") or 0) == 1
assert (rescue_dir / "unmerged.txt").read_text(encoding="utf-8").strip()
assert (rescue_dir / "merge_msg.txt").exists()
def test_rescue_changes_diff_preserves_non_utf8_bytes(monkeypatch, tmp_path):
"""changes.diff must survive BYTES end-to-end: on an unmerged index it is the only
carrier of resolutions, and a text-mode decode would corrupt latin-1 content into
U+FFFD replacement characters."""
import pathlib
repo, branch = _conflicted_rescue_repo(tmp_path)
# The agent's resolution carries a latin-1 byte (0xE9) — NOT valid UTF-8.
(repo / "f.txt").write_bytes(b"agent r\xe9solution\n")
monkeypatch.setattr(git_ops, "REPO_DIR", repo)
monkeypatch.setattr(git_ops, "DRIVE_ROOT", tmp_path / "data")
info = git_ops._create_rescue_snapshot(
branch, "bytes-test",
{"current_branch": branch, "dirty_lines": ["UU f.txt"], "unpushed_lines": [], "warnings": []},
)
data = (pathlib.Path(info["path"]) / "changes.diff").read_bytes()
assert b"r\xe9solution" in data # raw byte preserved
assert b"\xef\xbf\xbd" not in data # no U+FFFD replacement corruption
def test_create_rescue_snapshot_untracked_only_has_no_stash_error(monkeypatch, tmp_path):
"""rc==0 with an empty stash sha (nothing tracked to stash) is legitimate, not an error."""
import pathlib
repo, branch = _rescue_fixture_repo(tmp_path)
(repo / "loose.txt").write_text("untracked only\n", encoding="utf-8")
monkeypatch.setattr(git_ops, "REPO_DIR", repo)
monkeypatch.setattr(git_ops, "DRIVE_ROOT", tmp_path / "data")
info = git_ops._create_rescue_snapshot(
branch, "untracked-test",
{"current_branch": branch, "dirty_lines": ["?? loose.txt"], "unpushed_lines": [], "warnings": []},
)
assert "rescue_stash_error" not in info
assert "rescue_ref" not in info # nothing stashable — and no error either
assert "merge_head" not in info
assert info["untracked"]["copied_files"] == 1
copied = pathlib.Path(info["path"]) / "untracked" / "loose.txt"
assert copied.read_text(encoding="utf-8") == "untracked only\n"
def test_rescue_hook_treats_unreadable_status_as_dirty(monkeypatch, tmp_path):
"""A failing `git status` must be treated as a DIRTY tree: the hook attempts the
rescue (a clean-shortcut on an unreadable tree would silently skip work it could
not even see), takes the snapshot WITHOUT the evolution link, and writes the
durable supervisor.jsonl line before returning the pointer."""
calls = []
def fake_git_capture(cmd):
if cmd == ["git", "status", "--porcelain"]:
return 128, "", "fatal: unreadable index"
if cmd[:3] == ["git", "rev-parse", "-q"]:
return 1, "", ""
raise AssertionError(cmd)
monkeypatch.setattr(git_ops, "git_capture", fake_git_capture)
monkeypatch.setattr(git_ops, "_collect_repo_sync_state", lambda: {"current_branch": "b"})
monkeypatch.setattr(
git_ops, "_create_rescue_snapshot",
lambda branch, reason, state, link_evolution=True: calls.append(
(branch, reason, link_evolution)
) or {"path": "/r", "ts": "T", "untracked": {}},
)
monkeypatch.setattr(git_ops, "DRIVE_ROOT", tmp_path / "data")
result = git_ops.rescue_before_destructive_rollback("status_unreadable")
assert calls == [("b", "managed_update_rollback:status_unreadable", False)]
assert result == {"path": "/r", "ref": "", "ts": "T"}
log_lines = (tmp_path / "data" / "logs" / "supervisor.jsonl").read_text(
encoding="utf-8"
).splitlines()
rows = [json.loads(line) for line in log_lines if line.strip()]
assert rows[-1]["type"] == "managed_update_rescue_captured"
assert rows[-1]["rescue_path"] == "/r"
def test_rescue_hook_clean_tree_without_merge_returns_empty(monkeypatch, tmp_path):
"""Clean tree + no MERGE_HEAD → nothing to rescue: no snapshot, no durable line,
so a replayed rolling_back boot stays idempotent."""
repo, _branch = _rescue_fixture_repo(tmp_path)
monkeypatch.setattr(git_ops, "REPO_DIR", repo)
monkeypatch.setattr(git_ops, "DRIVE_ROOT", tmp_path / "data")
monkeypatch.setattr(
git_ops, "_create_rescue_snapshot",
lambda *_a, **_k: (_ for _ in ()).throw(
AssertionError("a clean tree must not be snapshotted")
),
)
assert git_ops.rescue_before_destructive_rollback("clean") == {}
assert not (tmp_path / "data" / "logs" / "supervisor.jsonl").exists()
def test_ensure_local_version_tag_accepts_rc_versions(monkeypatch, tmp_path):
(tmp_path / "VERSION").write_text("4.50.0-rc.2\n", encoding="utf-8")
monkeypatch.setattr(git_ops, "REPO_DIR", tmp_path)

View file

@ -432,6 +432,11 @@ class TestReviewEnforcementModes:
return diff_text
return ""
monkeypatch.setattr(review_mod, "run_cmd", _fake_run_cmd)
# The triad now reads its change evidence through the hardened
# capture_staged_diff seam (imported function-locally), not run_cmd.
import ouroboros.tools.review_binary_context as _rbc
monkeypatch.setattr(_rbc, "capture_staged_diff",
lambda _repo, *, unified=3: diff_text)
def test_blocking_mode_blocks_critical_findings(self, review_ctx, monkeypatch):
review, ctx = review_ctx
@ -476,6 +481,73 @@ class TestReviewEnforcementModes:
# findings: repeats on the next attempt must still be recognized.
assert ctx._review_iteration_count == 1
@pytest.mark.parametrize("failure", ["nonzero_rc", "non_utf8_rc"])
def test_uncapturable_staged_diff_blocks_instead_of_reviewing_a_placeholder(
self, review_ctx, monkeypatch, failure
):
"""The triad's change evidence is the staged diff, and the old ``run_cmd``
capture fell back to a ``(failed to get staged diff)`` STRING that a full,
authoritative review then ran against findings about a diff nobody has.
It now goes through the hardened ``capture_staged_diff``; when git cannot
produce the diff the review fails closed in blocking mode (no reviewer is
dispatched), exactly like a checklist-load or reviewer-config infra
failure."""
review, ctx = review_ctx
# name-status / name-only still answer so we reach the diff capture, but
# the content capture is what fails.
self._mock_staged(monkeypatch, review, changed_files="x.py")
import ouroboros.tools.review_binary_context as rbc
def broken(_repo, *, unified=3):
detail = "fatal: bad object" if failure == "nonzero_rc" else "fatal: \udcffbad"
raise rbc.StagedDiffUnavailable(f"staged diff capture failed (rc 128): {detail}")
monkeypatch.setattr(rbc, "capture_staged_diff", broken)
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
dispatched = []
monkeypatch.setattr(
review, "_handle_multi_model_review",
lambda *a, **k: dispatched.append(True) or self._fake_result("[]", "[]"),
)
result = review._run_unified_review(ctx, "test commit", repo_dir=ctx.repo_dir)
assert result is not None and "REVIEW_BLOCKED" in result
assert "staged diff" in result.lower()
assert "failed to get staged diff" not in result # no placeholder anywhere
assert ctx._last_review_block_reason == "infra_failure"
assert dispatched == [], "no reviewer may run without the staged diff"
def test_uncapturable_staged_diff_is_advisory_skip_not_placeholder_review(
self, review_ctx, monkeypatch
):
"""Advisory counterpart: review is non-blocking, so an infra failure to
capture the diff skips the triad with a durable warning instead of feeding
a placeholder into it. The commit proceeds (``None``) and the skip is
recorded, never a review of ``(failed to get staged diff)``."""
review, ctx = review_ctx
self._mock_staged(monkeypatch, review, changed_files="x.py")
import ouroboros.tools.review_binary_context as rbc
monkeypatch.setattr(
review, "_handle_multi_model_review",
lambda *a, **k: (_ for _ in ()).throw(AssertionError("triad must not run")),
)
def broken(_repo, *, unified=3):
raise rbc.StagedDiffUnavailable("staged diff capture failed: boom")
monkeypatch.setattr(rbc, "capture_staged_diff", broken)
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "advisory")
result = review._run_unified_review(ctx, "test commit", repo_dir=ctx.repo_dir)
assert result is None
assert ctx._last_review_block_reason == "infra_failure"
assert any(
isinstance(w, str) and "staged diff capture failed" in w.lower()
for w in ctx._review_advisory
)
def test_triad_one_pass_fit_removes_only_duplicated_context(self, review_ctx, monkeypatch):
"""Oversized triad evidence is compacted before its single dispatch."""
review, ctx = review_ctx
@ -496,6 +568,11 @@ class TestReviewEnforcementModes:
captured = {}
monkeypatch.setattr(review, "run_cmd", fake_run_cmd)
import ouroboros.tools.review_binary_context as _rbc
monkeypatch.setattr(
_rbc, "capture_staged_diff",
lambda _repo, *, unified=3: compact_diff if unified == 0 else huge_diff,
)
monkeypatch.setattr(
review, "build_touched_file_pack",
lambda *_a, **_k: ("FULL SNAPSHOT\n" + ("x = 1\n" * 400_000), []),
@ -527,6 +604,58 @@ class TestReviewEnforcementModes:
budget_cap=review.REVIEW_PROMPT_TOKEN_BUDGET,
)
def test_triad_compact_rung_uses_hardened_capture_not_raw_run_cmd(self, review_ctx, monkeypatch):
"""The oversized ladder's compact rung called a RAW ``run_cmd(git diff
--cached -U0)`` that inherits diff config/env and text decode, while only
the primary diff used the hardened capture. The compact rung must use
``capture_staged_diff(unified=0)`` and never issue the raw ``-U0``
command."""
review, ctx = review_ctx
huge_diff = "diff --git a/x.py b/x.py\n" + ("+changed line\n" * 190_000)
compact_diff = "diff --git a/x.py b/x.py\n@@ -1 +1 @@\n-old\n+new\n"
run_cmd_calls = []
def fake_run_cmd(cmd, cwd=None):
run_cmd_calls.append(list(cmd))
cmd = list(cmd)
if cmd == ["git", "diff", "--cached", "--name-status"]:
return "M\tx.py"
if cmd == ["git", "diff", "--cached", "--name-only"]:
return "x.py"
return ""
monkeypatch.setattr(review, "run_cmd", fake_run_cmd)
capture_calls = []
import ouroboros.tools.review_binary_context as _rbc
def fake_capture(_repo, *, unified=3):
capture_calls.append(unified)
return compact_diff if unified == 0 else huge_diff
monkeypatch.setattr(_rbc, "capture_staged_diff", fake_capture)
monkeypatch.setattr(
review, "build_touched_file_pack",
lambda *_a, **_k: ("FULL SNAPSHOT\n" + ("x = 1\n" * 400_000), []))
monkeypatch.setattr(review._cfg, "get_review_models", lambda: [
"openai/gpt-5.5", "google/gemini-3.5-flash", "anthropic/claude-fable-5"])
captured = {}
def fake_review(*_args, **kwargs):
captured["prompt"] = kwargs["prompt"]
return self._fake_result(
'[{"item":"code_quality","verdict":"PASS","severity":"advisory","reason":"ok"}]',
'[{"item":"code_quality","verdict":"PASS","severity":"advisory","reason":"ok"}]')
monkeypatch.setattr(review, "_handle_multi_model_review", fake_review)
assert review._run_unified_review(ctx, "test commit", repo_dir=ctx.repo_dir) is None
assert 0 in capture_calls, "compact rung must call capture_staged_diff(unified=0)"
assert ["git", "diff", "--cached", "-U0"] not in run_cmd_calls, run_cmd_calls
assert compact_diff in captured["prompt"]
def test_advisory_mode_downgrades_quorum_failure(self, review_ctx, monkeypatch):
review, ctx = review_ctx
self._mock_staged(monkeypatch, review, changed_files="x.py")

View file

@ -1363,6 +1363,564 @@ def test_the_production_entry_points_do_not_short_circuit_a_deleted_suite(
assert [event[0] for event in events].count("pass") == 0
# ── Candidate assembly (universal one-diff capture) ───────────────────
#
# The candidate is assembled the same way for EVERY source-index state: one
# hardened `git diff --binary … HEAD` capture (config-pinning flag tail, raw
# bytes end to end — the exact argv is pinned in the failed-capture test) plus
# the untracked-file copy. The staged/unstaged diff pair it replaced could not
# express an
# unmerged index — the state an assisted managed-update resolver (or any
# merge_pr flow) is in when the advisory preflight runs: `git diff --cached`
# renders each conflicted path as a literal "* Unmerged path" stub and
# `git diff` as a combined `--cc` hunk, which `git apply` REJECTS when the
# payload holds nothing else (rc=128) and silently DROPS when ordinary hunks
# accompany it. These tests pin both halves of the universal scheme: the
# unmerged shapes the old pair corrupted, and the ordinary merged shapes the
# old pair handled — which the one-diff capture must keep handling.
def _start_conflicted_merge(
repo: pathlib.Path, incoming: dict[str, str], ours: dict[str, str]
) -> None:
"""Drive `repo` into an in-progress merge whose index holds unmerged entries.
Two real branches, a real `git merge` that stops on the conflict no mocked
git anywhere, because the subject under test is git's own rendering of an
unmerged index. Asserts the fixture really produced unmerged entries so a
test can never silently pin the ordinary merged path instead.
"""
_git(repo, "checkout", "-b", "incoming")
for rel, body in incoming.items():
(repo / rel).write_text(textwrap.dedent(body), encoding="utf-8")
_commit_all(repo)
_git(repo, "checkout", "ouroboros")
for rel, body in ours.items():
(repo / rel).write_text(textwrap.dedent(body), encoding="utf-8")
_commit_all(repo)
merge = subprocess.run(
["git", "-c", "user.name=Test", "-c", "user.email=test@example.com",
"merge", "incoming"],
cwd=str(repo), capture_output=True, text=True,
)
assert merge.returncode != 0, (
f"fixture precondition: the merge must conflict, got rc=0:\n{merge.stdout}{merge.stderr}"
)
unmerged = subprocess.run(
["git", "ls-files", "-u"], cwd=str(repo),
capture_output=True, text=True, check=True,
)
assert unmerged.stdout.strip(), "fixture precondition: no unmerged index entries"
def _spy_on_candidate(monkeypatch, rel_paths):
"""Replace the pytest spawn with a spy that records candidate-file contents.
Returns the dict the spy fills: relative path -> file text, or None when the
path is absent from the candidate worktree. Complements ``stub_passes``
(whose fixture setup still neutralises the plugin/worker seams): the
recorder it installs is replaced, because these tests need the WORKTREE
argument the one thing the recorder drops.
"""
from ouroboros import preflight_runner
seen: dict[str, object] = {}
def _spy(agent_python, worktree, temp_root, args, timeout):
wt = pathlib.Path(worktree)
for rel in rel_paths:
target = wt / rel
seen[rel] = target.read_text(encoding="utf-8") if target.is_file() else None
return (0, "", "")
monkeypatch.setattr(preflight_runner, "_execute_pytest_pass", _spy)
return seen
def test_a_purely_conflicted_merge_runs_against_the_worktree_resolution(
tmp_path, two_pass_env, stub_passes, monkeypatch
):
"""A merge whose ONLY change is the conflicted file used to kill the gate
outright: the staged diff is nothing but the "* Unmerged path" stub and the
unstaged diff nothing but the `--cc` hunk, so `git apply` returned rc=128
("No valid patches in input") and the whole preflight died as "hermetic
preflight failed" before running a single test. The one-diff capture has no
such rendering the conflicted path arrives as plain worktree content so
the gate runs, and runs against the RESOLUTION the resolver typed, not
against HEAD."""
from ouroboros.preflight_runner import run_hermetic_pytest
repo = _make_repo(tmp_path, {
"tests/test_plain.py": "def test_ok():\n assert True\n",
"conflict.txt": "base\n",
})
_start_conflicted_merge(
repo, incoming={"conflict.txt": "incoming\n"}, ours={"conflict.txt": "ours\n"}
)
(repo / "conflict.txt").write_text("resolved\n", encoding="utf-8") # no `git add`
stub_passes([]) # seam neutralisation only; the spy below replaces the recorder
seen = _spy_on_candidate(monkeypatch, ["conflict.txt"])
assert run_hermetic_pytest(repo, timeout=120) is None
assert seen["conflict.txt"] == "resolved\n", (
f"candidate does not carry the worktree resolution: {seen!r}"
)
def test_a_mixed_unmerged_index_drops_neither_staged_nor_conflicted_changes(
tmp_path, two_pass_env, stub_passes, monkeypatch
):
"""The SILENT failure mode, worse than the rc=128 one: with an ordinary hunk
in each diff stream (an auto-merged staged file, an unstaged edit) alongside
the conflict, `git apply` exits 0 and just DROPS the `--cc` hunk. The
candidate then carried the ordinary changes but NOT the resolution a
chimera tree nobody has, whose green or red verdict is equally meaningless.
On the unfixed base this test fails on the conflict-file assertion."""
from ouroboros.preflight_runner import run_hermetic_pytest
repo = _make_repo(tmp_path, {
"tests/test_plain.py": "def test_ok():\n assert True\n",
"conflict.txt": "base\n",
"auto.txt": "base auto\n",
"notes.txt": "base notes\n",
})
_start_conflicted_merge(
repo,
incoming={"conflict.txt": "incoming\n", "auto.txt": "incoming auto\n"},
ours={"conflict.txt": "ours\n"},
)
# Resolve the conflict and touch an unrelated tracked file — both WITHOUT
# `git add`, exactly how a resolver's tree looks mid-work.
(repo / "conflict.txt").write_text("resolved\n", encoding="utf-8")
(repo / "notes.txt").write_text("edited notes\n", encoding="utf-8")
stub_passes([])
seen = _spy_on_candidate(monkeypatch, ["conflict.txt", "auto.txt", "notes.txt"])
assert run_hermetic_pytest(repo, timeout=120) is None
assert seen["auto.txt"] == "incoming auto\n", "staged auto-merged change lost"
assert seen["notes.txt"] == "edited notes\n", "unstaged ordinary change lost"
assert seen["conflict.txt"] == "resolved\n", (
"the conflicted file's resolution was silently dropped — the candidate "
f"is a chimera: {seen!r}"
)
def test_an_unmerged_resolution_by_deletion_is_absent_from_the_candidate(
tmp_path, two_pass_env, stub_passes, monkeypatch
):
"""Deleting the conflicted file (plain `rm`, no `git rm`) is a legitimate
resolution. `git diff --binary HEAD` renders it as an ordinary deletion
hunk, so the candidate must NOT carry the file a candidate that resurrects
it from HEAD would test a tree the resolver explicitly deleted from."""
from ouroboros.preflight_runner import run_hermetic_pytest
repo = _make_repo(tmp_path, {
"tests/test_plain.py": "def test_ok():\n assert True\n",
"conflict.txt": "base\n",
})
_start_conflicted_merge(
repo, incoming={"conflict.txt": "incoming\n"}, ours={"conflict.txt": "ours\n"}
)
(repo / "conflict.txt").unlink() # resolution by deletion, no `git rm`
stub_passes([])
seen = _spy_on_candidate(monkeypatch, ["conflict.txt"])
assert run_hermetic_pytest(repo, timeout=120) is None
assert seen["conflict.txt"] is None, (
f"a file deleted as the conflict resolution reappeared in the candidate: {seen!r}"
)
def test_a_staged_delete_with_a_recreated_untracked_file_mirrors_the_live_worktree(
tmp_path, two_pass_env, stub_passes, monkeypatch
):
"""Classification edge: `git rm` stages a deletion, and a NEW same-named file
written afterwards is untracked (`ls-files --others` lists it). The one-diff
capture deletes the path from the candidate and the untracked copy then
restores the reborn content net effect, the candidate equals the live
worktree, which is the whole equivalence the one-diff capture promises."""
from ouroboros.preflight_runner import run_hermetic_pytest
repo = _make_repo(tmp_path, {
"tests/test_plain.py": "def test_ok():\n assert True\n",
"conflict.txt": "base\n",
"victim.txt": "victim base\n",
})
_start_conflicted_merge(
repo, incoming={"conflict.txt": "incoming\n"}, ours={"conflict.txt": "ours\n"}
)
(repo / "conflict.txt").write_text("resolved\n", encoding="utf-8")
_git(repo, "rm", "-q", "victim.txt")
(repo / "victim.txt").write_text("reborn\n", encoding="utf-8") # untracked now
stub_passes([])
seen = _spy_on_candidate(monkeypatch, ["conflict.txt", "victim.txt"])
assert run_hermetic_pytest(repo, timeout=120) is None
assert seen["victim.txt"] == "reborn\n", (
f"candidate diverged from the live worktree on the recreated path: {seen!r}"
)
assert seen["conflict.txt"] == "resolved\n"
def test_a_failed_capture_is_a_named_hard_block_not_a_test_failure(
tmp_path, two_pass_env, stub_passes, monkeypatch
):
"""When the one-diff capture itself fails, the verdict must say so in the
gate's named-hard-block vocabulary — PREFLIGHT_CANDIDATE_ASSEMBLY, with a
remediation that owns the failure itself and does NOT blame the merge in
progress (an unmerged index is a supported source state for this capture,
per the function's own docstring) — and no pass may run, because there is
no candidate worth running it against. A bare "hermetic preflight failed"
here reads as an infrastructure flake and invites a retry that cannot
succeed. The interception below pins the EXACT capture argv, the whole
config-pinning tail included (`--no-ext-diff --no-textconv --no-color
--src-prefix=a/ --dst-prefix=b/`): dropping any of those flags re-opens
the door to an operator git config a diff driver, textconv filter,
colour escapes, or diff.noprefix/srcPrefix that reshapes the payload
into something `git apply` cannot re-apply."""
from ouroboros import preflight_runner
from ouroboros.preflight_runner import run_hermetic_pytest
repo = _make_repo(tmp_path, {
"tests/test_plain.py": "def test_ok():\n assert True\n",
"conflict.txt": "base\n",
})
_start_conflicted_merge(
repo, incoming={"conflict.txt": "incoming\n"}, ours={"conflict.txt": "ours\n"}
)
events = stub_passes([])
real_run_git = preflight_runner._run_git
capture_argv = [
"diff", "--binary", "--no-ext-diff", "--no-textconv", "--no-color",
"--src-prefix=a/", "--dst-prefix=b/", "HEAD",
]
def _broken_capture(repo_dir, args, **kwargs):
if list(args) == capture_argv:
return subprocess.CompletedProcess(
["git", *args], 1, "", "synthetic capture failure"
)
return real_run_git(repo_dir, args, **kwargs)
monkeypatch.setattr(preflight_runner, "_run_git", _broken_capture)
result = run_hermetic_pytest(repo, timeout=120)
assert result is not None
assert "PREFLIGHT_CANDIDATE_ASSEMBLY" in result, result
assert "hard block" in result, result
assert "is not a test failure" in result, result
assert "synthetic capture failure" in result, result
# The remediation must not send the operator off to "finish the merge":
# an unmerged index is a state this capture supports, so the block means
# the capture/apply ITSELF failed and the text says exactly that.
assert "supported source state" in result, result
assert "mid-merge" not in result, result
assert [event[0] for event in events].count("pass") == 0, (
"a pass ran against a candidate whose capture failed"
)
@pytest.mark.parametrize(
"failure_mode, misread",
[
pytest.param(
"capture_timeout", "pytest timed out",
id="git-diff-timeout-is-not-a-pytest-timeout",
),
pytest.param(
"untracked_permission", "hermetic preflight failed",
id="untracked-copy-permission-error-is-not-a-generic-failure",
),
],
)
def test_a_raised_assembly_exception_is_owned_by_the_assembly_block(
tmp_path, two_pass_env, stub_passes, monkeypatch, failure_mode, misread
):
"""The assembly block must own RAISED exceptions, not only the rc!=0 path
the failed-capture test above pins. `_run_git` raises
subprocess.TimeoutExpired when the diff capture outruns its budget, and
`_copy_untracked` raises OSErrors (PermissionError, FileNotFoundError) from
the filesystem copy. On the unfixed base the block caught RuntimeError
alone, so these flew past it into the OUTER handlers and were misread as a
pytest timeout ("pytest timed out after N seconds") or a generic "hermetic
preflight failed" — retryable-looking verdicts for a candidate that was
never assembled. Both cases raise REAL exceptions through the real code
path; neither returns a CompletedProcess(rc=1)."""
from ouroboros import preflight_runner
from ouroboros.preflight_runner import run_hermetic_pytest
repo = _make_repo(tmp_path, {
"tests/test_plain.py": "def test_ok():\n assert True\n",
})
events = stub_passes([])
if failure_mode == "capture_timeout":
real_run_git = preflight_runner._run_git
capture_argv = [
"diff", "--binary", "--no-ext-diff", "--no-textconv", "--no-color",
"--src-prefix=a/", "--dst-prefix=b/", "HEAD",
]
def _timing_out_capture(repo_dir, args, **kwargs):
if list(args) == capture_argv:
raise subprocess.TimeoutExpired(cmd=["git", *args], timeout=30)
return real_run_git(repo_dir, args, **kwargs)
monkeypatch.setattr(preflight_runner, "_run_git", _timing_out_capture)
else:
def _denied_copy(repo_dir, worktree):
raise PermissionError(13, "Permission denied", str(worktree))
monkeypatch.setattr(preflight_runner, "_copy_untracked", _denied_copy)
result = run_hermetic_pytest(repo, timeout=120)
assert result is not None
assert "PREFLIGHT_CANDIDATE_ASSEMBLY" in result, result
assert "hard block" in result, result
assert "is not a test failure" in result, result
assert "supported source state" in result, result
assert misread not in result, result
assert [event[0] for event in events].count("pass") == 0, (
"a pass ran against a candidate whose assembly raised"
)
def test_a_zero_context_diff_config_still_assembles_the_candidate(
tmp_path, two_pass_env, stub_passes, monkeypatch
):
"""Hunk WIDTH is the config axis the capture's flag tail cannot pin: a user
`diff.context=0` (equivalently `GIT_DIFF_OPTS=--unified=0` in the
environment) makes `git diff` emit zero-context hunks, which `git apply`
REJECTS by default so on the unfixed base an ORDINARY tracked edit died
as PREFLIGHT_CANDIDATE_ASSEMBLY before any test ran. `--unidiff-zero` on
the apply accepts zero-context hunks and is a no-op for hunks that carry
context, so one flag covers both the config and the env route. The
repo-local config below is the real reviewer reproduction, not a mock."""
from ouroboros.preflight_runner import run_hermetic_pytest
repo = _make_repo(tmp_path, {
"tests/test_plain.py": "def test_ok():\n assert True\n",
"victim.txt": "a\nb\nc\nd\n",
})
_git(repo, "config", "diff.context", "0")
(repo / "victim.txt").write_text("a\nb\nEDITED\nd\n", encoding="utf-8")
stub_passes([])
seen = _spy_on_candidate(monkeypatch, ["victim.txt"])
assert run_hermetic_pytest(repo, timeout=120) is None
assert seen["victim.txt"] == "a\nb\nEDITED\nd\n", (
f"zero-context capture blocked or corrupted the candidate: {seen!r}"
)
def test_a_staged_change_reverted_in_the_worktree_lands_as_head_content(
tmp_path, two_pass_env, stub_passes, monkeypatch
):
"""Merged-path regression for the one-diff capture: a change that is staged
but reverted in the worktree must land as HEAD content. Both schemes model
the WORKTREE, not the index the old pair replayed stage(AB) then
unstage(BA) and netted out, the one-diff capture simply emits no hunk
so this pins that dropping the two-step replay did not silently start
honouring the index's intermediate bookkeeping."""
from ouroboros.preflight_runner import run_hermetic_pytest
repo = _make_repo(tmp_path, {
"tests/test_plain.py": "def test_ok():\n assert True\n",
"reverted.txt": "base\n",
})
(repo / "reverted.txt").write_text("changed\n", encoding="utf-8")
_git(repo, "add", "reverted.txt")
(repo / "reverted.txt").write_text("base\n", encoding="utf-8") # back to HEAD
stub_passes([])
seen = _spy_on_candidate(monkeypatch, ["reverted.txt"])
assert run_hermetic_pytest(repo, timeout=120) is None
assert seen["reverted.txt"] == "base\n", (
f"candidate honoured the staged intermediate, not the worktree: {seen!r}"
)
def test_a_chmod_only_change_reaches_the_candidate(
tmp_path, two_pass_env, stub_passes, monkeypatch
):
"""A mode flip with identical content is a real change (a script that lost
its executable bit fails differently under test). The capture must carry the
old mode/new mode header and `git apply` must apply it in the candidate.
Gated on what `git init` actually PROBED for this filesystem (core.filemode)
rather than on the OS name: an `os.name` skip is wrong in both directions
a FAT/exFAT volume on POSIX cannot track the bit either, and the probe is
the same signal git itself trusts when deciding whether to emit mode
hunks."""
from ouroboros import preflight_runner
from ouroboros.preflight_runner import run_hermetic_pytest
repo = _make_repo(tmp_path, {
"tests/test_plain.py": "def test_ok():\n assert True\n",
"tool.sh": "#!/bin/sh\necho hi\n",
})
filemode = subprocess.run(
["git", "config", "--get", "core.filemode"], cwd=str(repo),
capture_output=True, text=True,
).stdout.strip().lower()
if filemode != "true":
pytest.skip(f"this filesystem does not track the executable bit (core.filemode={filemode or 'unset'})")
os.chmod(repo / "tool.sh", 0o755) # unstaged mode-only change
stub_passes([])
seen: dict[str, int] = {}
def _spy(agent_python, worktree, temp_root, args, timeout):
seen["mode"] = (pathlib.Path(worktree) / "tool.sh").stat().st_mode & 0o111
return (0, "", "")
monkeypatch.setattr(preflight_runner, "_execute_pytest_pass", _spy)
assert run_hermetic_pytest(repo, timeout=120) is None
assert seen["mode"], "the executable bit never reached the candidate"
def test_crlf_content_survives_the_capture_byte_for_byte(
tmp_path, two_pass_env, stub_passes, monkeypatch
):
"""The capture travels through `_run_git`'s binary pipes and `_apply_diff`'s
UTF-8 re-encode; CRLF line endings are the classic casualty of a text-mode
hop (a translated diff stops matching the LF worktree and `git apply`
rejects it wholesale). Pinned as raw bytes `read_text` would translate the
very characters under test."""
from ouroboros import preflight_runner
from ouroboros.preflight_runner import run_hermetic_pytest
repo = _make_repo(tmp_path, {
"tests/test_plain.py": "def test_ok():\n assert True\n",
"crlf.txt": "one\n",
})
# Pinned, not assumed: an operator/global autocrlf=true would rewrite the
# very bytes this test is about at add/checkout time and test nothing.
_git(repo, "config", "core.autocrlf", "false")
(repo / "crlf.txt").write_bytes(b"one\r\ntwo\r\n") # unstaged CRLF edit
stub_passes([])
seen: dict[str, bytes] = {}
def _spy(agent_python, worktree, temp_root, args, timeout):
seen["crlf.txt"] = (pathlib.Path(worktree) / "crlf.txt").read_bytes()
return (0, "", "")
monkeypatch.setattr(preflight_runner, "_execute_pytest_pass", _spy)
assert run_hermetic_pytest(repo, timeout=120) is None
assert seen["crlf.txt"] == b"one\r\ntwo\r\n", (
f"CRLF bytes were translated in transit: {seen!r}"
)
def test_a_staged_binary_change_reaches_the_candidate(
tmp_path, two_pass_env, stub_passes, monkeypatch
):
"""Non-UTF-8 binary content travels as a base85 "GIT binary patch" section —
which only exists because the capture passes `--binary`. Dropping the flag
would degrade the hunk to "Binary files differ", which `git apply` cannot
replay, so a staged icon/fixture change would kill the whole gate."""
from ouroboros import preflight_runner
from ouroboros.preflight_runner import run_hermetic_pytest
repo = _make_repo(tmp_path, {
"tests/test_plain.py": "def test_ok():\n assert True\n",
})
(repo / "blob.bin").write_bytes(b"\x00\x01\x02")
_commit_all(repo)
(repo / "blob.bin").write_bytes(b"\x00\xff\xfe\x00")
_git(repo, "add", "blob.bin")
stub_passes([])
seen: dict[str, bytes] = {}
def _spy(agent_python, worktree, temp_root, args, timeout):
seen["blob.bin"] = (pathlib.Path(worktree) / "blob.bin").read_bytes()
return (0, "", "")
monkeypatch.setattr(preflight_runner, "_execute_pytest_pass", _spy)
assert run_hermetic_pytest(repo, timeout=120) is None
assert seen["blob.bin"] == b"\x00\xff\xfe\x00", (
f"staged binary content did not reach the candidate: {seen!r}"
)
def test_non_utf8_text_content_survives_the_capture_byte_for_byte(
tmp_path, two_pass_env, stub_passes, monkeypatch
):
"""Git classifies NUL-free content as TEXT even when its bytes are not
valid UTF-8 (latin-1 logs, cp1251 fixtures), so those bytes travel on plain
diff lines never inside a base85 binary section that the previous test
already covers. The captureapply hop used to decode the payload with
errors="replace" and re-encode it: every non-UTF-8 byte on an added line
became U+FFFD, the apply still succeeded, and the candidate SILENTLY
diverged from the worktree while the gate stayed green. The payload now
travels as raw bytes end to end; pinned with `read_bytes`, since a text
read would mask the very substitution under test."""
from ouroboros import preflight_runner
from ouroboros.preflight_runner import run_hermetic_pytest
repo = _make_repo(tmp_path, {
"tests/test_plain.py": "def test_ok():\n assert True\n",
"latin.txt": "plain\n",
})
(repo / "latin.txt").write_bytes(b"plain\ncaf\xe9 au lait\n") # unstaged latin-1 edit
stub_passes([])
seen: dict[str, bytes] = {}
def _spy(agent_python, worktree, temp_root, args, timeout):
seen["latin.txt"] = (pathlib.Path(worktree) / "latin.txt").read_bytes()
return (0, "", "")
monkeypatch.setattr(preflight_runner, "_execute_pytest_pass", _spy)
assert run_hermetic_pytest(repo, timeout=120) is None
assert seen["latin.txt"] == b"plain\ncaf\xe9 au lait\n", (
f"non-UTF-8 text bytes were substituted in transit: {seen!r}"
)
def test_a_staged_add_removed_from_the_worktree_is_absent_from_the_candidate(
tmp_path, two_pass_env, stub_passes, monkeypatch
):
"""A file `git add`ed and then deleted from the worktree exists only in the
index. The worktree-vs-HEAD capture emits no hunk for it (absent on both
sides) and the untracked copy cannot see it (it is IN the index, so
`ls-files --others` skips it) the candidate must not resurrect it. The
old pair reached the same absence the long way round: staged add, then
unstaged delete."""
from ouroboros.preflight_runner import run_hermetic_pytest
repo = _make_repo(tmp_path, {
"tests/test_plain.py": "def test_ok():\n assert True\n",
})
(repo / "ghost.txt").write_text("ghost\n", encoding="utf-8")
_git(repo, "add", "ghost.txt")
(repo / "ghost.txt").unlink()
stub_passes([])
seen = _spy_on_candidate(monkeypatch, ["ghost.txt"])
assert run_hermetic_pytest(repo, timeout=120) is None
assert seen["ghost.txt"] is None, (
f"an index-only file was resurrected in the candidate: {seen!r}"
)
def test_a_failing_post_commit_gate_stops_publication(monkeypatch):
"""A hard block the MANAGED commit path converts to a warning is not a block.
@ -3391,3 +3949,62 @@ def test_hermetic_pytest_timeout_reaps_detached_session_child(tmp_path, two_pass
if alive: # cleanup so a reaping regression does not leak a 180s sleeper
force_kill_pid(child_pid)
assert not alive, f"detached child {child_pid} survived preflight timeout reaping"
def test_an_untracked_file_with_a_non_utf8_name_reaches_the_candidate(
tmp_path, two_pass_env, stub_passes, monkeypatch
):
"""POSIX filenames are BYTES, and Git lists them as such. Decoding that list
as UTF-8 with `errors="replace"` turned a raw non-UTF-8 byte into U+FFFD, so
the reconstructed path did not exist, `is_file()` said False, and the file was
skipped in silence an inexact candidate with no PREFLIGHT_CANDIDATE_ASSEMBLY
block. The names are now decoded with the filesystem codec (surrogateescape),
which round-trips the original bytes."""
from ouroboros.preflight_runner import run_hermetic_pytest
if os.name != "posix":
pytest.skip("byte filenames are a POSIX property")
repo = _make_repo(tmp_path, {"tests/test_plain.py": "def test_ok():\n assert True\n"})
raw_name = b"fixture_\xff.dat"
try:
with open(os.path.join(os.fsencode(str(repo)), raw_name), "wb") as handle:
handle.write(b"untracked payload\n")
except (OSError, UnicodeError): # APFS and friends enforce UTF-8 names
pytest.skip("this filesystem rejects non-UTF-8 filenames")
decoded_name = os.fsdecode(raw_name)
stub_passes([])
seen = _spy_on_candidate(monkeypatch, [decoded_name])
assert run_hermetic_pytest(repo, timeout=120) is None
assert seen[decoded_name] == "untracked payload\n", (
f"an untracked file vanished from the candidate on a byte filename: {seen!r}"
)
def test_untracked_listing_is_decoded_with_the_filesystem_codec(tmp_path, monkeypatch):
"""Filesystem-independent pin for the same defect (the test above can only run
where non-UTF-8 names are creatable): the listing is read as BYTES and each
name goes through `os.fsdecode`, so the original bytes reach the copy instead
of a U+FFFD name that matches no file on disk."""
from ouroboros import preflight_runner
seen_kwargs = {}
def fake_run_git(_repo_dir, args, **kwargs):
# Mirrors the real seam: bytes only when the caller asks for them, the old
# utf-8/replace decode otherwise — so this pins the decision, not the stub.
seen_kwargs.update(kwargs)
raw = b"fixture_\xff.dat\x00"
out = raw if kwargs.get("binary_stdout") else raw.decode("utf-8", "replace")
return subprocess.CompletedProcess(list(args), 0, out, "")
copied = []
monkeypatch.setattr(preflight_runner, "_run_git", fake_run_git)
monkeypatch.setattr(preflight_runner.shutil, "copy2", lambda src, dst: copied.append(src))
monkeypatch.setattr(pathlib.Path, "is_file", lambda _self: True)
preflight_runner._copy_untracked(tmp_path, tmp_path / "candidate")
assert seen_kwargs.get("binary_stdout") is True, "the names must not be decoded by _run_git"
assert os.fsencode(str(copied[0])).endswith(b"fixture_\xff.dat"), copied

View file

@ -291,6 +291,8 @@ def test_run_unified_review_obligation_loading_uses_drive_root_and_make_repo_key
review_mod, "run_cmd",
lambda cmd, *args, **kw: ("M\tfile.py" if "--name-only" in cmd else "+ change"),
)
import ouroboros.tools.review_binary_context as _rbc
monkeypatch.setattr(_rbc, "capture_staged_diff", lambda _repo, *, unified=3: "+ change")
monkeypatch.setattr(review_mod, "build_touched_file_pack", lambda *a, **k: ("(pack)", []))
monkeypatch.setattr(review_mod, "_load_checklist_section", lambda: "(checklists)")
monkeypatch.setattr(

View file

@ -0,0 +1,83 @@
"""Hardened staged-diff capture (``capture_staged_diff``) — the one shared
evidence source for the scope reviewer and the triad."""
import subprocess
import pytest
from ouroboros.tools.review_binary_context import (
StagedDiffUnavailable,
capture_staged_diff,
)
def _repo(tmp_path):
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "t@t"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.name", "T"], cwd=tmp_path, check=True)
(tmp_path / "f.py").write_text("a\nb\nc\nd\ne\n", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=tmp_path, check=True, capture_output=True)
subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=tmp_path, check=True,
capture_output=True)
return tmp_path
def test_capture_returns_the_staged_diff(tmp_path):
repo = _repo(tmp_path)
(repo / "f.py").write_text("a\nb\nCHANGED\nd\ne\n", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
diff = capture_staged_diff(repo)
assert "+CHANGED" in diff
assert "a/f.py" in diff and "b/f.py" in diff # pinned prefixes
def test_capture_unified_zero_drops_unchanged_context(tmp_path):
repo = _repo(tmp_path)
(repo / "f.py").write_text("a\nb\nCHANGED\nd\ne\n", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
compact = capture_staged_diff(repo, unified=0)
assert "+CHANGED" in compact
assert " a\n" not in compact # zero context: no unchanged surrounding lines
def test_non_utf8_staged_text_is_escaped_not_flattened(tmp_path):
"""Git treats NUL-free non-UTF-8 content as TEXT, so the bytes ride ordinary
diff lines. They must reach the reviewer readably escaped never U+FFFD,
never an exception, never a placeholder."""
repo = _repo(tmp_path)
(repo / "f.py").write_bytes(b"a\nb\ncaf\xe9\nd\ne\n") # latin-1 0xE9
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
diff = capture_staged_diff(repo)
assert "\\xe9" in diff
assert "<EFBFBD>" not in diff
assert "staged diff contained non-UTF-8 bytes" in diff
def test_capture_failure_raises_typed_runtime_error(tmp_path):
(tmp_path / "not_a_repo").mkdir()
with pytest.raises(StagedDiffUnavailable):
capture_staged_diff(tmp_path / "not_a_repo")
# The type is a RuntimeError so existing fail-closed paths catch it.
assert issubclass(StagedDiffUnavailable, RuntimeError)
def test_git_diff_opts_env_cannot_reshape_the_capture(tmp_path, monkeypatch):
"""GIT_DIFF_OPTS overrides the context width from outside the argv; the
capture drops it so the requested width survives."""
repo = _repo(tmp_path)
(repo / "f.py").write_text("a\nb\nCHANGED\nd\ne\n", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
monkeypatch.setenv("GIT_DIFF_OPTS", "--unified=0")
diff = capture_staged_diff(repo) # default width, env override dropped
assert "+CHANGED" in diff
assert " a\n" in diff # context lines survived the hostile env override

View file

@ -115,6 +115,10 @@ class TestScopeOnlyRetryPath:
mod, "run_cmd",
lambda *args, **kwargs: "diff --git a/f.py b/f.py\n+x = 1\n",
)
monkeypatch.setattr(
mod, "capture_staged_diff",
lambda *args, **kwargs: "diff --git a/f.py b/f.py\n+x = 1\n",
)
monkeypatch.setattr(mod, "load_governance_doc", lambda rd, rel, **_kw: "(dev guide)")
monkeypatch.setattr(
mod, "_gather_scope_packs",

View file

@ -156,6 +156,9 @@ def test_triad_prompt_keeps_distinct_goal_and_scope_in_production_wiring(
return json.dumps({"results": []})
monkeypatch.setattr(review, "run_cmd", fake_run_cmd)
import ouroboros.tools.review_binary_context as _rbc
monkeypatch.setattr(_rbc, "capture_staged_diff",
lambda _repo, *, unified=3: "diff --git a/x.py b/x.py\n+x = 1")
monkeypatch.setattr(review, "_preflight_check", lambda *_args, **_kwargs: None)
monkeypatch.setattr(review, "_load_checklist_section", lambda: "checklist")
monkeypatch.setattr(review, "load_governance_doc", lambda *_args, **_kwargs: "governance")

View file

@ -553,6 +553,8 @@ def test_last_triad_raw_results_reset_at_start_of_run_unified_review(tmp_path):
})
with patch.object(review_mod, "run_cmd", return_value="some diff content"), \
patch("ouroboros.tools.review_binary_context.capture_staged_diff",
return_value="some diff content"), \
patch.object(review_mod, "_handle_multi_model_review", return_value=mock_review_output), \
patch.object(review_mod, "_load_checklist_section", return_value="## checklist"), \
patch.object(review_mod, "_preflight_check", return_value=None), \

View file

@ -614,20 +614,17 @@ class TestRunScopeReviewFailClosed:
subprocess.run(["git", "add", "."], cwd=str(tmp_path), capture_output=True)
mod = _get_module("ouroboros.tools.scope_review")
original_run_cmd = mod.run_cmd
large_diff = "diff --git a/tiny.py b/tiny.py\n" + (" unchanged context\n" * 30_000)
compact_diff = "diff --git a/tiny.py b/tiny.py\n@@ -1 +1 @@\n-old\n+new\n"
compact_calls = []
def fake_run_cmd(cmd, cwd=None):
if list(cmd) == ["git", "diff", "--cached"]:
return large_diff
if list(cmd) == ["git", "diff", "--cached", "-U0"]:
def fake_capture(_repo_dir, *, unified=3):
if unified == 0:
compact_calls.append(True)
return compact_diff
return original_run_cmd(cmd, cwd=cwd)
return large_diff
monkeypatch.setattr(mod, "run_cmd", fake_run_cmd)
monkeypatch.setattr(mod, "capture_staged_diff", fake_capture)
monkeypatch.setattr(mod, "_effective_scope_input_limit", lambda **_kw: 100_000)
monkeypatch.setattr(mod, "_gather_scope_packs", lambda *_a, **_k: "COMPACT ATLAS")
@ -2492,6 +2489,8 @@ def test_ladder_steps_are_recorded_once_aggregated(tmp_path, monkeypatch):
monkeypatch.setattr(sr, "run_cmd", lambda cmd, cwd=None: (
"M\ta.py" if "--name-status" in cmd else "diff --git a/a.py b/a.py\n+x = 1\n"
))
monkeypatch.setattr(sr, "capture_staged_diff",
lambda _repo, **_k: "diff --git a/a.py b/a.py\n+x = 1\n")
monkeypatch.setattr(sr, "_gather_scope_packs", lambda *a, **k: "ATLAS")
monkeypatch.setattr(sr, "_effective_scope_input_limit", lambda **_k: 900_000)
@ -2702,8 +2701,12 @@ def test_design_skipped_touched_test_is_not_claimed_as_fully_included(tmp_path):
`already_included` is now the CONSERVATIVE set the fixed part really carries,
so the touched test falls through to the atlas, where (being an anchor, hence
related to the change and not excludable under BIBLE P3) it is supplied in
FULL exactly once. The invariant under test is the general one: a coverage row
may never claim content the pack does not contain."""
FULL exactly once. FULL is the spacious-budget DEFAULT, not a guarantee:
under budget pressure the guaranteed-fit ladder may degrade a touched test
to diff-only (the constrained sibling below) constitutionally sound
because the test's complete changes ride the staged diff. The invariant
under test is the general one: a coverage row may never claim content the
pack does not contain."""
import subprocess
from ouroboros.tools import scope_review as sr
@ -2751,6 +2754,487 @@ def test_design_skipped_touched_test_is_not_claimed_as_fully_included(tmp_path):
assert "DEDUPLICATION NOTE" in prompt and "- tests/test_thing.py" in prompt
# The touched non-test keeps its true `already_included` claim.
assert rows["mod.py"]["reason"] == "included in fixed prompt context"
# Spacious budget: the ladder never reached for the test — no degradation.
assert "TOUCHED FILE BUDGET DEGRADATION NOTE" not in prompt
def _ladder_repo(tmp_path, files: dict, changes: dict):
"""Init a git repo with ``files``, then stage ``changes``.
Values: str -> text file, bytes -> binary file, None -> delete the path
(``git add .`` stages removals too)."""
import subprocess
def _put(rel, content):
path = tmp_path / rel
path.parent.mkdir(parents=True, exist_ok=True)
if content is None:
path.unlink()
elif isinstance(content, bytes):
path.write_bytes(content)
else:
path.write_text(content, encoding="utf-8")
(tmp_path / "docs").mkdir(exist_ok=True)
(tmp_path / "docs" / "CHECKLISTS.md").write_text(
"## Intent / Scope Review Checklist\n\nplaceholder\n", encoding="utf-8",
)
for rel, content in files.items():
_put(rel, content)
subprocess.run(["git", "init"], cwd=str(tmp_path), capture_output=True)
subprocess.run(["git", "add", "."], cwd=str(tmp_path), capture_output=True)
subprocess.run(
["git", "-c", "user.email=t@t", "-c", "user.name=T", "commit", "-m", "init"],
cwd=str(tmp_path), capture_output=True,
)
for rel, content in changes.items():
_put(rel, content)
subprocess.run(["git", "add", "."], cwd=str(tmp_path), capture_output=True)
# ~90K estimated tokens of test body; the INDENTED marker is unreachable through
# the staged diff (outside any -U3 hunk of an end-of-file change, and never a
# hunk-header funcname), so its absence from the prompt proves the full
# snapshot is really gone from the whole pack.
_BIG_TEST_BODY = "\n".join(
["def test_big():", " UNCHANGED_BIG_TEST_MARKER_QQQ = 1"]
+ [" filler = 1"] * 24_000
+ [" assert True"]
) + "\n"
_BIG_TEST_CHANGED = _BIG_TEST_BODY + "\n\ndef test_added():\n assert True\n"
def test_constrained_budget_degrades_touched_test_to_diff_only(tmp_path, monkeypatch):
"""Phase L, the constrained sibling of the spacious pin above. A touched
test is filtered out of the fixed part's snippets by design, yet the atlas
is owed it as a FULL anchor and the ladder built its degradable set only
from `current_context_paths`, so one oversized touched test structurally
sank pack assembly with `required_artifact_omitted` although its complete
changes sat in the staged diff. Touched tests now ride the ladder's free
tier: under pressure they degrade to diff-only via the existing
`diff_only_included` mechanism, assembly SUCCEEDS, and the manifest row
carries the disclosure."""
from ouroboros.tools import scope_review as sr
_ladder_repo(
tmp_path,
files={"tests/test_big.py": _BIG_TEST_BODY, "mod.py": "x = 1\n"},
changes={"tests/test_big.py": _BIG_TEST_CHANGED, "mod.py": "x = 2\n"},
)
monkeypatch.setattr(sr, "_effective_scope_input_limit", lambda **_kw: 45_000)
prompt, status = sr._build_scope_prompt(tmp_path, "test commit")
# Assembly SUCCEEDS — the oversized touched test no longer sinks the pack.
assert status is None, getattr(status, "unassembled_required", status)
assert prompt
# The ladder degraded the test, disclosed in the prompt…
note = prompt.split("## TOUCHED FILE BUDGET DEGRADATION NOTE", 1)[1].split("\n\n", 1)[0]
assert "- tests/test_big.py" in note
# …and the full snapshot is truly gone from the whole pack.
assert "UNCHANGED_BIG_TEST_MARKER_QQQ" not in prompt
# The dedup note no longer lists it: that would claim an atlas snapshot.
assert "CURRENT FILE CONTEXT DEDUPLICATION NOTE" not in prompt
# The durable coverage row carries the diff-only disclosure — not a false
# full-inclusion claim, not a required omission.
rows = {r["path"]: r for r in sr._current_scope_context_manifest()["coverage"]}
row = rows["tests/test_big.py"]
assert row["disposition"] == "already_included"
assert "changes included" in row["reason"]
assert "full snapshot omitted" in row["reason"]
# The small touched module keeps its true full-snapshot claim.
assert rows["mod.py"]["reason"] == "included in fixed prompt context"
def test_touched_test_degrades_before_the_required_tier_and_zero_context_diff(
tmp_path, monkeypatch,
):
"""Ordering. Touched tests join the FREE tier of the two-tier ladder sort
(`atlas_required_beyond_diff` is False for tests/), so under deficit the
big touched test degrades BEFORE the ladder reaches for the -U0 rung or
the required tier: the required-beyond-diff artifact keeps its full
snapshot in the fixed part and no zero-context-diff step is recorded."""
from ouroboros.tools import scope_review as sr
_ladder_repo(
tmp_path,
files={
"tests/test_big.py": _BIG_TEST_BODY,
# ~10K tokens: fits the fixed part; owed in full regardless of size.
"prompts/mini_prompt.md": "word here\n" * 4_000,
},
changes={
"tests/test_big.py": _BIG_TEST_CHANGED,
"prompts/mini_prompt.md": "CHANGED\n" + "word here\n" * 3_999,
},
)
monkeypatch.setattr(sr, "_effective_scope_input_limit", lambda **_kw: 45_000)
prompt, status = sr._build_scope_prompt(tmp_path, "test commit")
assert status is None, getattr(status, "unassembled_required", status)
assert prompt
# The required-beyond-diff artifact kept its full snapshot in the fixed part…
assert "### prompts/mini_prompt.md" in prompt
# …the degradation note names the test, and only the test…
note = prompt.split("## TOUCHED FILE BUDGET DEGRADATION NOTE", 1)[1].split("\n\n", 1)[0]
assert "- tests/test_big.py" in note
assert "prompts/mini_prompt.md" not in note
# …and the ladder never needed the -U0 rung: the free tier covered it.
steps = sr._current_scope_context_manifest().get("ladder_steps") or []
assert steps and not any(s.get("zero_context_diff") for s in steps), steps
rows = {r["path"]: r for r in sr._current_scope_context_manifest()["coverage"]}
assert rows["prompts/mini_prompt.md"]["reason"] == "included in fixed prompt context"
assert "full snapshot omitted" in rows["tests/test_big.py"]["reason"]
def test_canonical_doc_is_never_ladder_degraded_to_diff_only(tmp_path, monkeypatch):
"""The boundary of Phase L: only tests/ paths joined the degradable set. A
touched CANONICAL doc is owed in full through the fixed part's
canonical-docs section (`atlas_required_beyond_diff` is True), so when it
alone overflows the budget the ladder exhausts its free rungs (including
-U0) and fails CLOSED it never hands the doc to diff-only."""
from ouroboros.tools import scope_review as sr
_ladder_repo(
tmp_path,
files={
# ~84K tokens injected whole into the canonical-docs section.
"docs/ARCHITECTURE.md": "arch doc line\n" * 24_000,
"mod.py": "x = 1\n",
},
changes={
"docs/ARCHITECTURE.md": "CHANGED\n" + "arch doc line\n" * 23_999,
"mod.py": "x = 2\n",
},
)
monkeypatch.setattr(sr, "_effective_scope_input_limit", lambda **_kw: 45_000)
monkeypatch.setattr(sr, "_scope_window",
lambda _m, **_k: sr.ReviewerWindow(window_tokens=1_000_000, status="confirmed"))
prompt, status = sr._build_scope_prompt(tmp_path, "test commit")
assert prompt is None
assert status.status == "fixed_overflow"
# The canonical doc never became a required omission via diff-only…
assert status.unassembled_required == []
assert status.atlas_overflowed is True
# …its coverage row keeps the truthful fixed-part claim…
rows = {r["path"]: r for r in sr._current_scope_context_manifest()["coverage"]}
assert rows["docs/ARCHITECTURE.md"]["reason"] == "included in fixed prompt context"
# …and the ladder really exhausted the free rungs (-U0 attempted) first.
steps = sr._current_scope_context_manifest().get("ladder_steps") or []
assert any(s.get("zero_context_diff") for s in steps), steps
def test_binary_test_fixture_is_never_degraded_to_diff_only(tmp_path, monkeypatch):
"""A binary fixture under tests/ has NO changes in the staged text diff
(`git diff --cached` renders "Binary files differ"), so the diff-only
disclosure "changes included in the fixed staged diff" would be a false
claim. Binary staged test paths (`staged_path_is_binary`) stay out of the
degradable tier; the fixture remains the atlas's business (typed
`binary_media` row). The fixture makes the binary the LARGEST touched test,
so a candidates list without the binary filter would degrade the binary
first and fail the note assertions below."""
from ouroboros.tools import scope_review as sr
_ladder_repo(
tmp_path,
files={
"tests/fixture.bin": bytes(range(256)) * 1_600, # ~400KB, biggest
"tests/test_big.py": _BIG_TEST_BODY,
},
changes={
"tests/fixture.bin": b"\x00CHANGED" + bytes(range(256)) * 1_600,
"tests/test_big.py": _BIG_TEST_CHANGED,
},
)
monkeypatch.setattr(sr, "_effective_scope_input_limit", lambda **_kw: 45_000)
prompt, status = sr._build_scope_prompt(tmp_path, "test commit")
assert status is None, getattr(status, "unassembled_required", status)
assert prompt
# The TEXT test rode the diff-only rung; the binary fixture did not.
note = prompt.split("## TOUCHED FILE BUDGET DEGRADATION NOTE", 1)[1].split("\n\n", 1)[0]
assert "- tests/test_big.py" in note
assert "tests/fixture.bin" not in note
# The binary fixture stays honestly delegated to the atlas (typed row,
# never the diff-only "changes included" claim).
dedup = prompt.split("## CURRENT FILE CONTEXT DEDUPLICATION NOTE", 1)[1].split("\n\n", 1)[0]
assert "- tests/fixture.bin" in dedup
rows = {r["path"]: r for r in sr._current_scope_context_manifest()["coverage"]}
assert rows["tests/fixture.bin"]["disposition"] == "binary_media"
assert "full snapshot omitted" not in rows["tests/fixture.bin"]["reason"]
assert "full snapshot omitted" in rows["tests/test_big.py"]["reason"]
def test_deleted_text_test_degrades_to_diff_only_under_pressure(tmp_path, monkeypatch):
"""A deleted TEXT test inlines its whole HEAD snapshot into the fixed part
(`_inline_deleted_file_pack`) and the ladder had no rung for it pure
pressure with no relief, although a text deletion's complete content is
already the staged diff's own minus-lines. Deleted text tests now join the
same degradable tier: under pressure the HEAD inline is replaced by a
disclosed omission marker. Binary deletions never qualify (their content is
not in the text diff). NB: mod.py co-degrades here because the
refusal-branch deficit has a pre-existing 50K floor that outsizes the
deleted test a ladder property, not an effect of this fix."""
from ouroboros.tools import scope_review as sr
# ~30K tokens of deleted test: inline + its diff minus-lines both ride the
# fixed part (~65K total), overflowing the 45K budget; dropping the inline
# (~30K) brings the prompt back under it.
gone_body = "\n".join(
["def test_gone():", " DELETED_TEST_HEAD_MARKER_WWW = 1"]
+ [" filler = 1"] * 8_000
+ [" assert True"]
) + "\n"
_ladder_repo(
tmp_path,
files={
"tests/test_gone.py": gone_body,
"tests/gone.bin": bytes(range(256)) * 4, # small binary deletion
"mod.py": "x = 1\n",
},
changes={
"tests/test_gone.py": None,
"tests/gone.bin": None,
"mod.py": "x = 2\n",
},
)
monkeypatch.setattr(sr, "_effective_scope_input_limit", lambda **_kw: 45_000)
prompt, status = sr._build_scope_prompt(tmp_path, "test commit")
assert status is None, getattr(status, "unassembled_required", status)
assert prompt
# The deleted text test was degraded: no HEAD inline, disclosed marker
# instead, and the degradation note names it.
note = prompt.split("## TOUCHED FILE BUDGET DEGRADATION NOTE", 1)[1].split("\n\n", 1)[0]
assert "- tests/test_gone.py" in note
assert "full HEAD snapshot omitted" in prompt
assert "*(DELETED — content from HEAD)*" not in prompt
# The binary deletion did NOT ride the diff-only rung: it keeps its own
# typed suppression marker (its content is not in the text diff).
assert "tests/gone.bin" not in note
assert "### tests/gone.bin\n\n*(DELETED — " in prompt
assert "content suppressed" in prompt
def test_degraded_test_gets_no_false_atlas_delegation_phrase(tmp_path, monkeypatch):
"""The dedup note used to promise unconditionally that a touched test's
"full snapshot appears once in the generated atlas" false the moment the
ladder degrades that test to diff-only. The reviewer-facing phrase is now
conditional and per-file: full-delegated tests stay listed in the dedup
note, budget-degraded ones move to the degradation note, and the
unconditional phrase is gone."""
from ouroboros.tools import scope_review as sr
_ladder_repo(
tmp_path,
files={
"tests/test_huge.py": _BIG_TEST_BODY,
"tests/test_tiny.py": "def test_tiny():\n TINY_KEPT_MARKER_JJJ = 1\n",
},
changes={
"tests/test_huge.py": _BIG_TEST_CHANGED,
"tests/test_tiny.py": (
"def test_tiny():\n TINY_KEPT_MARKER_JJJ = 1\n assert True\n"
),
},
)
monkeypatch.setattr(sr, "_effective_scope_input_limit", lambda **_kw: 45_000)
prompt, status = sr._build_scope_prompt(tmp_path, "test commit")
assert status is None, getattr(status, "unassembled_required", status)
assert prompt
# The unconditional promise is gone from the reviewer-facing prompt…
assert "appears once in the generated atlas" not in prompt
# …the conditional wording rides the dedup note, which lists ONLY the
# test still delegated in full…
dedup = prompt.split("## CURRENT FILE CONTEXT DEDUPLICATION NOTE", 1)[1].split("\n\n", 1)[0]
assert "- tests/test_tiny.py" in dedup
assert "tests/test_huge.py" not in dedup
assert "move to the degradation note instead" in prompt
# …and each file's actual disposition backs the wording: tiny delegated in
# full by the atlas, huge disclosed as diff-only.
note = prompt.split("## TOUCHED FILE BUDGET DEGRADATION NOTE", 1)[1].split("\n\n", 1)[0]
assert "- tests/test_huge.py" in note
rows = {r["path"]: r for r in sr._current_scope_context_manifest()["coverage"]}
assert rows["tests/test_tiny.py"]["disposition"] == "full"
assert "full snapshot omitted" in rows["tests/test_huge.py"]["reason"]
def test_deleted_non_test_file_is_never_degraded(tmp_path, monkeypatch):
"""Boundary pin for the deleted branch: ONLY tests/ deletions join the
degradable tier. A deleted ordinary module keeps its HEAD inline even under
pressure (here it alone overflows the budget, so the ladder exhausts its
rungs and fails CLOSED) a mutation dropping the tests/-filter from the
deleted branch would degrade it and assemble, flipping every assert below.
The named `diff_only_paths` ladder trace proves it was never degraded."""
from ouroboros.tools import scope_review as sr
gone_body = "\n".join(["def helper():"] + [" filler = 1"] * 8_000) + "\n"
_ladder_repo(
tmp_path,
files={"mod_big.py": gone_body, "mod.py": "x = 1\n"},
changes={"mod_big.py": None, "mod.py": "x = 2\n"},
)
monkeypatch.setattr(sr, "_effective_scope_input_limit", lambda **_kw: 45_000)
monkeypatch.setattr(sr, "_scope_window",
lambda _m, **_k: sr.ReviewerWindow(window_tokens=1_000_000, status="confirmed"))
prompt, status = sr._build_scope_prompt(tmp_path, "test commit")
assert prompt is None
assert status.status == "fixed_overflow"
steps = sr._current_scope_context_manifest().get("ladder_steps") or []
assert steps
for step in steps:
assert "mod_big.py" not in (step.get("diff_only_paths") or []), step
def test_deleted_test_token_estimate_orders_largest_first(tmp_path, monkeypatch):
"""The cat-file size fallback in `_touched_token_estimate` is load-bearing:
deleted paths have no worktree stat, and a fallback that returned 0 would
(a) sort every deleted test LAST instead of largest-first and (b) count 0
freed tokens per pop, so the loop would drain the whole tier. With honest
estimates the LARGER deleted test alone covers the deficit and the smaller
one keeps its HEAD inline."""
from ouroboros.tools import scope_review as sr
big_gone = "\n".join(["def test_gone_big():"] + [" filler = 1"] * 16_000) + "\n"
small_gone = "\n".join(["def test_gone_small():"] + [" filler = 1"] * 2_100) + "\n"
_ladder_repo(
tmp_path,
files={
"tests/test_gone_big.py": big_gone,
"tests/test_gone_small.py": small_gone,
"mod.py": "x = 1\n",
},
changes={
"tests/test_gone_big.py": None,
"tests/test_gone_small.py": None,
"mod.py": "x = 2\n",
},
)
monkeypatch.setattr(sr, "_effective_scope_input_limit", lambda **_kw: 135_000)
prompt, status = sr._build_scope_prompt(tmp_path, "test commit")
assert status is None, getattr(status, "unassembled_required", status)
assert prompt
# Guarded extraction: a missing note must fail as an assert, not IndexError.
assert "## TOUCHED FILE BUDGET DEGRADATION NOTE" in prompt
note = prompt.split("## TOUCHED FILE BUDGET DEGRADATION NOTE", 1)[1].split("\n\n", 1)[0]
# Largest-first: the big deleted test degraded, the small one did not.
assert "- tests/test_gone_big.py" in note
assert "tests/test_gone_small.py" not in note
assert "### tests/test_gone_big.py\n\n*(DELETED — full HEAD snapshot omitted" in prompt
assert "### tests/test_gone_small.py\n\n*(DELETED — content from HEAD)*" in prompt
def test_oversized_deleted_test_keeps_suppressed_marker_not_diff_only(
tmp_path, monkeypatch,
):
"""A deleted test over `_DELETED_INLINE_MAX_BYTES` is ALREADY a suppressed
marker its inline never weighed on the budget, so degrading it would free
phantom tokens (the HEAD-blob estimate, ~277K here) and misattribute the
relief. The size guard keeps it out of the degradable tier: pressure is
relieved by the genuine candidate (the big CURRENT test), and the oversized
deletion keeps its own typed suppression marker."""
from ouroboros.tools import scope_review as sr
huge_gone = "\n".join(["def test_huge_gone():"] + [" filler = 1"] * 74_000) + "\n"
assert len(huge_gone.encode()) > 1_048_576 # over the inline cap
_ladder_repo(
tmp_path,
files={"tests/test_huge_gone.py": huge_gone, "tests/test_big.py": _BIG_TEST_BODY},
changes={"tests/test_huge_gone.py": None, "tests/test_big.py": _BIG_TEST_CHANGED},
)
monkeypatch.setattr(sr, "_effective_scope_input_limit", lambda **_kw: 330_000)
prompt, status = sr._build_scope_prompt(tmp_path, "test commit")
assert status is None, getattr(status, "unassembled_required", status)
assert prompt
# The oversized deletion kept its typed suppression marker…
assert "content > 1024 KB; suppressed" in prompt
assert "## TOUCHED FILE BUDGET DEGRADATION NOTE" in prompt
note = prompt.split("## TOUCHED FILE BUDGET DEGRADATION NOTE", 1)[1].split("\n\n", 1)[0]
# …the genuine candidate carried the degradation, not the phantom one.
assert "- tests/test_big.py" in note
assert "tests/test_huge_gone.py" not in note
steps = sr._current_scope_context_manifest().get("ladder_steps") or []
for step in steps:
assert "tests/test_huge_gone.py" not in (step.get("diff_only_paths") or []), step
def test_a_renamed_test_fixture_is_not_degraded(tmp_path, monkeypatch):
"""Conservative rename guard: a renamed path's staged diff may carry only a
rename header (no content hunks), so degrading it to diff-only could hide
its content entirely. Renamed touched tests keep their snapshot; the plain
modified test still rides the diff-only rung."""
from ouroboros.tools import scope_review as sr
_ladder_repo(
tmp_path,
files={"tests/old_name.bin": bytes(range(256)) * 1_600,
"tests/test_big.py": _BIG_TEST_BODY},
changes={"tests/test_big.py": _BIG_TEST_CHANGED},
)
subprocess.run(["git", "mv", "tests/old_name.bin", "tests/new_name.bin"],
cwd=str(tmp_path), capture_output=True)
subprocess.run(["git", "add", "."], cwd=str(tmp_path), capture_output=True)
monkeypatch.setattr(sr, "_effective_scope_input_limit", lambda **_kw: 45_000)
prompt, status = sr._build_scope_prompt(tmp_path, "test commit")
assert status is None, getattr(status, "unassembled_required", status)
note = prompt.split("## TOUCHED FILE BUDGET DEGRADATION NOTE", 1)[1].split("\n\n", 1)[0]
assert "- tests/test_big.py" in note
assert "new_name.bin" not in note and "old_name.bin" not in note
def test_staged_diff_capture_survives_non_utf8_text(tmp_path, monkeypatch):
"""Git calls NUL-free non-UTF-8 content TEXT, so those bytes ride ordinary diff
lines. The old strict-UTF-8 text capture raised on them and the review continued
on a "(failed to get staged diff)" placeholder with the ladder able to degrade
a touched test to diff-only, that placeholder can be a file's only evidence. The
bytes now arrive reversibly escaped and the pack assembles."""
from ouroboros.tools import scope_review as sr
_ladder_repo(tmp_path, files={"tests/test_bytes.py": "x = 1\n"}, changes={"mod.py": "y = 1\n"})
(tmp_path / "tests" / "test_bytes.py").write_bytes(b"x = 1 # latin caf\xe9\n")
subprocess.run(["git", "add", "."], cwd=str(tmp_path), capture_output=True)
prompt, status = sr._build_scope_prompt(tmp_path, "test commit")
assert status is None, getattr(status, "unassembled_required", status)
assert "\\xe9" in prompt # reversible escape, not a U+FFFD flattening
assert "<EFBFBD>" not in prompt
assert "failed to get staged diff" not in prompt
def test_unavailable_staged_diff_blocks_instead_of_reviewing_a_placeholder(tmp_path, monkeypatch):
"""When the canonical staged diff cannot be captured at all, prompt assembly
fails with a RuntimeError the type `scope_review`'s own caller already turns
into a blocked, fail-closed result instead of sending an authoritative review
a placeholder that says the evidence is missing."""
from ouroboros.tools import review_binary_context as rbc
from ouroboros.tools import scope_review as sr
_ladder_repo(tmp_path, files={"mod.py": "x = 1\n"}, changes={"mod.py": "x = 2\n"})
def broken(*_a, **_k):
raise rbc.StagedDiffUnavailable("staged diff capture failed (rc 128): fatal")
monkeypatch.setattr(sr, "capture_staged_diff", broken)
assert issubclass(rbc.StagedDiffUnavailable, RuntimeError)
with pytest.raises(RuntimeError):
sr._build_scope_prompt(tmp_path, "test commit")
def test_ladder_cannot_degrade_a_required_beyond_diff_artifact_to_diff_only(

View file

@ -96,6 +96,33 @@ def test_ordinary_restart_disarms_orphan_update_intent(monkeypatch):
assert calls == ["clear", "restart"]
def test_restart_deferred_while_assisted_merge_is_being_resolved(monkeypatch):
"""Regression pin for the restart guard: while an assisted managed-update merge is
mid-resolution (any assisted phase), _safe_restart_serialized must DEFER the restart
instead of running the checkout/reset that would wipe the resolver's worktree."""
import server
import supervisor.update_merge as update_merge
monkeypatch.setattr(update_merge, "acquire_update_lock", lambda: object())
monkeypatch.setattr(update_merge, "release_update_lock", lambda _lock: None)
for phase in ("materializing_assisted", "assisted_resolution", "committing_assisted"):
monkeypatch.setattr(
update_merge, "read_update_tx_strict",
lambda phase=phase: ("valid", {"phase": phase, "task_id": "resolver"}),
)
ok, message = server._safe_restart_serialized(
lambda **_kwargs: (_ for _ in ()).throw(
AssertionError("restart must be deferred during assisted resolution")
),
reason="owner_restart",
unsynced_policy="rescue_and_reset",
)
assert ok is False, phase
assert "deferred" in message.lower()
def test_supervisor_startup_restores_queue_before_worker_reset():
"""A fresh process must not overwrite the durable queue with its empty memory."""
import inspect

View file

@ -1,7 +1,8 @@
"""Tests for the AUTOMATED assisted managed-update merge (P2/SC2) — native MERGE_HEAD staged
in a real temp repo, the tx authorization gate, the conflict-marker gate, merge-state
classification, and non-destructive boot recovery."""
classification, non-destructive boot recovery, and the rescue-before-rollback hook."""
import json
import subprocess
from types import SimpleNamespace
@ -260,7 +261,9 @@ def test_cancelled_resolver_task_done_keeps_event_authority(tmp_path, monkeypatc
def test_assisted_objective_is_truthful_for_any_conflict_free_reviewed_merge():
objective = update_merge._assisted_objective({
from supervisor.update_merge_policy import assisted_objective
objective = assisted_objective({
"target_sha": "b" * 40,
"conflict_paths": [],
})
@ -269,6 +272,8 @@ def test_assisted_objective_is_truthful_for_any_conflict_free_reviewed_merge():
assert "combines local and official history" in objective
assert "conflicts are marked" not in objective
assert "see `git status` for unmerged paths" not in objective
# No prior rescue on the tx → the objective must not invent one.
assert "was rescued to" not in objective
def test_boot_resume_does_not_enqueue_a_duplicate_assisted_resolver(monkeypatch):
@ -689,3 +694,279 @@ def test_dirty_local_work_is_in_the_reviewed_diff(tmp_path, monkeypatch):
staged = _git(repo, "diff", "--cached", "--name-only", plan["base_sha"]).stdout.split()
assert "secret_local.txt" in staged, staged
assert "b.txt" in staged # the official change is in the same reviewed diff
def _stub_worker_gates(monkeypatch):
"""Neutral worker-pool/admission stubs for rollback paths (parallel-safe)."""
import supervisor.workers as workers
monkeypatch.setattr(workers, "ensure_worker_pool_started", lambda **_kwargs: True)
monkeypatch.setattr(workers, "close_repo_writer_admission", lambda reason: None)
monkeypatch.setattr(workers, "open_repo_writer_admission", lambda expected_reason="": None)
def _supervisor_events(tmp_path, event_type):
path = tmp_path / "data" / "logs" / "supervisor.jsonl"
if not path.is_file():
return []
rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
return [row for row in rows if row.get("type") == event_type]
def _materialized_conflict_tx(tmp_path, monkeypatch):
"""A live materialized assisted merge with an UNCOMMITTED resolution in the worktree."""
repo, head, plan = _conflict_repo(tmp_path, monkeypatch)
ok, msg = update_merge.materialize_assisted_merge_live(
head, plan["local_snapshot"], plan["target_sha"], plan["base_sha"]
)
assert ok, msg
(repo / "a.txt").write_text("the resolver's precious resolution\n")
tx = {
"phase": "assisted_resolution", "task_id": "resolver",
"pre_update_sha": plan["base_sha"], "pre_update_branch": head,
"local_snapshot": plan["local_snapshot"], "target_sha": plan["target_sha"],
}
update_merge.write_update_tx(tx)
return repo, head, plan, tx
def test_orphan_rollback_rescues_uncommitted_resolutions(tmp_path, monkeypatch):
repo, head, plan, tx = _materialized_conflict_tx(tmp_path, monkeypatch)
_stub_worker_gates(monkeypatch)
# A rollback rescue must never flip an active evolution transaction to "abandoned".
monkeypatch.setattr(
git_ops, "_link_rescue_to_evolution_transaction",
lambda *_a, **_k: (_ for _ in ()).throw(
AssertionError("rollback rescue must not link to the evolution tx")
),
)
result = update_merge.abort_orphaned_assisted_tx("resolver", _authority_metadata(tx))
assert result.get("rolled_back") is True, result
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == plan["base_sha"]
rescue_dirs = list((tmp_path / "data" / "archive" / "rescue").iterdir())
assert len(rescue_dirs) == 1
assert "the resolver's precious resolution" in (
rescue_dirs[0] / "changes.diff"
).read_text(encoding="utf-8")
meta = json.loads((rescue_dirs[0] / "rescue_meta.json").read_text(encoding="utf-8"))
assert meta["reason"] == "managed_update_rollback:assisted_resolution_orphaned"
assert meta["merge_head"] == plan["target_sha"]
assert int(meta["unmerged_count"]) > 0
assert meta["rescue_stash_error"] # stash create fails on an unmerged index — disclosed
assert (rescue_dirs[0] / "unmerged.txt").read_text(encoding="utf-8").strip()
# The hook writes its own durable line BEFORE the destructive reset — a crash
# between clear_update_tx and the terminal event cannot hide the rescue.
captured = _supervisor_events(tmp_path, "managed_update_rescue_captured")
assert captured and captured[-1]["rescue_path"] == str(rescue_dirs[0])
rolled = _supervisor_events(tmp_path, "managed_update_rolled_back")
assert rolled and rolled[-1]["rescue_path"] == str(rescue_dirs[0])
assert rolled[-1]["reason"] == "assisted_resolution_orphaned"
assert rolled[-1].get("rescue_ts")
def test_boot_cap_rollback_rescues_before_reset(tmp_path, monkeypatch):
repo, head, plan, tx = _materialized_conflict_tx(tmp_path, monkeypatch)
tx["resolution_attempts"] = 4 # past _ASSISTED_BOOT_ATTEMPT_CAP on the next boot
update_merge.write_update_tx(tx)
_stub_worker_gates(monkeypatch)
result = update_merge.finalize_managed_update_on_boot(supervisor_ready=True)
assert result.get("rolled_back") is True, result
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == plan["base_sha"]
rescue_dirs = list((tmp_path / "data" / "archive" / "rescue").iterdir())
assert len(rescue_dirs) == 1
assert "the resolver's precious resolution" in (
rescue_dirs[0] / "changes.diff"
).read_text(encoding="utf-8")
rolled = _supervisor_events(tmp_path, "managed_update_rolled_back")
assert rolled and rolled[-1]["rescue_path"] == str(rescue_dirs[0])
assert rolled[-1]["reason"] == "assisted_resolution_expired"
def test_rollback_on_clean_tree_creates_no_rescue(tmp_path, monkeypatch):
repo, head = _init_repo(tmp_path)
_point_at(monkeypatch, tmp_path, repo, head)
pre = _git(repo, "rev-parse", "HEAD").stdout.strip()
update_merge.write_update_tx({
"phase": "pending_boot_smoke", "pre_update_sha": pre, "pre_update_branch": head,
})
_stub_worker_gates(monkeypatch)
ok, _message = update_merge.rollback_managed_update("clean_tree_test")
assert ok is True
assert not (tmp_path / "data" / "archive" / "rescue").exists()
rolled = _supervisor_events(tmp_path, "managed_update_rolled_back")
assert rolled
assert "rescue_path" not in rolled[-1]
assert "rescue_error" not in rolled[-1]
def test_rollback_replay_does_not_duplicate_rescue(tmp_path, monkeypatch):
"""No second snapshot when the tx ALREADY CARRIES a written rollback_rescue marker.
Honest scope (accepted residual): the guarantee is at-least-once, not exactly-once
a crash in the window between creating the rescue dir and writing the tx marker
replays the rescue and can leave one extra rescue dir on disk. That duplicate is
cheap and durable; a two-phase planned/captured protocol was explicitly declined
(Proportionality). This test pins the replay-with-marker case only."""
repo, head = _init_repo(tmp_path)
_point_at(monkeypatch, tmp_path, repo, head)
pre = _git(repo, "rev-parse", "HEAD").stdout.strip()
(repo / "a.txt").write_text("dirt from the attempt that was already rescued\n")
update_merge.write_update_tx({
"phase": "rolling_back", "pre_update_sha": pre, "pre_update_branch": head,
"rollback_rescue": {"path": "/rescued/earlier", "ref": "refs/rescue/x", "reason": "first"},
})
_stub_worker_gates(monkeypatch)
monkeypatch.setattr(
git_ops, "rescue_before_destructive_rollback",
lambda reason, **_kw: (_ for _ in ()).throw(
AssertionError("a rollback replay must not take a second rescue")
),
)
ok, _message = update_merge.rollback_managed_update("replay")
assert ok is True
rolled = _supervisor_events(tmp_path, "managed_update_rolled_back")
assert rolled and rolled[-1]["rescue_path"] == "/rescued/earlier"
assert rolled[-1]["rescue_ref"] == "refs/rescue/x"
def test_rescue_failure_is_fail_open_and_disclosed(tmp_path, monkeypatch):
"""Owner decision 4=A: a failed rescue never blocks the rollback — it is disclosed."""
repo, head = _init_repo(tmp_path)
_point_at(monkeypatch, tmp_path, repo, head)
pre = _git(repo, "rev-parse", "HEAD").stdout.strip()
(repo / "a.txt").write_text("dirty work the rescue could not save\n")
update_merge.write_update_tx({
"phase": "pending_boot_smoke", "pre_update_sha": pre, "pre_update_branch": head,
})
_stub_worker_gates(monkeypatch)
monkeypatch.setattr(
git_ops, "_create_rescue_snapshot",
lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("disk full")),
)
ok, _message = update_merge.rollback_managed_update("rescue_fail")
assert ok is True
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == pre
rolled = _supervisor_events(tmp_path, "managed_update_rolled_back")
assert rolled and "disk full" in rolled[-1]["rescue_error"]
assert "rescue_path" not in rolled[-1]
# The hook also wrote its own durable failure line before the reset.
failed = _supervisor_events(tmp_path, "managed_update_rescue_failed")
assert failed and "disk full" in failed[-1]["error"]
assert failed[-1]["reason"] == "rescue_fail"
def test_failed_rollback_attempt_drops_marker_and_retry_rescues_fresh_tree(tmp_path, monkeypatch):
"""The rescue marker is per-ATTEMPT, not per-tx. A transient failure of the first
destructive step must drop the just-written marker so the retry re-rescues the
tree it actually finds including second-generation work written in between."""
import pathlib
repo, head = _init_repo(tmp_path)
_point_at(monkeypatch, tmp_path, repo, head)
pre = _git(repo, "rev-parse", "HEAD").stdout.strip()
(repo / "a.txt").write_text("first-generation resolution\n")
update_merge.write_update_tx({
"phase": "assisted_resolution", "task_id": "resolver",
"pre_update_sha": pre, "pre_update_branch": head,
})
_stub_worker_gates(monkeypatch)
real_git_capture = git_ops.git_capture
armed = {"on": True}
def flaky(cmd): # one transient failure (index.lock class) on the first reset
if armed["on"] and cmd == ["git", "reset", "--hard", "HEAD"]:
armed["on"] = False
return 1, "", "fatal: Unable to create '.git/index.lock': File exists."
return real_git_capture(cmd)
monkeypatch.setattr(git_ops, "git_capture", flaky)
ok1, msg1 = update_merge.rollback_managed_update("attempt_one")
assert ok1 is False and "reset failed" in msg1
assert len(list((tmp_path / "data" / "archive" / "rescue").iterdir())) == 1
# The stale first-attempt marker is gone — the retry re-runs the hook.
assert "rollback_rescue" not in update_merge.read_update_tx()
# The tree keeps moving before the retry (second-generation work).
(repo / "a.txt").write_text("SECOND-GENERATION resolution\n")
(repo / "brand_new_untracked.txt").write_text("also new\n")
ok2, _msg2 = update_merge.rollback_managed_update("attempt_two")
assert ok2 is True
rescue_dirs = sorted((tmp_path / "data" / "archive" / "rescue").iterdir())
assert len(rescue_dirs) == 2, "the retry must take a FRESH rescue of the moved tree"
rolled = _supervisor_events(tmp_path, "managed_update_rolled_back")
latest = pathlib.Path(rolled[-1]["rescue_path"])
assert "SECOND-GENERATION resolution" in (latest / "changes.diff").read_text(encoding="utf-8")
assert (latest / "untracked" / "brand_new_untracked.txt").exists()
def test_boot_rematerialize_rescues_dirty_work_and_points_resolver_at_it(tmp_path, monkeypatch):
"""The re-materialization reset (boot resume, has_progress=False) rescues surviving
dirty resolutions, persists the tx pointer BEFORE materialize runs (a crash inside
it must not lose the pointer), and the resumed resolver's objective points at it.
A further boot keeps that pointer, because `materialize_assisted_merge_live` sets
MERGE_HEAD and dirties the tree WITHOUT replaying the rescued edits dropping the
pointer on those two signals would lose the rescue nobody has read yet."""
import supervisor.queue as queue
import supervisor.workers as workers
repo, head, plan, _tx = _materialized_conflict_tx(tmp_path, monkeypatch)
(repo / "a.txt").write_text("half-finished resolution\n")
# The residual class: MERGE_HEAD lost while dirty resolution work survives.
(repo / ".git" / "MERGE_HEAD").unlink()
assert update_merge._merge_head_sha() == ""
_stub_worker_gates(monkeypatch)
monkeypatch.setattr(workers, "PENDING", [])
monkeypatch.setattr(workers, "RUNNING", {})
captured = []
monkeypatch.setattr(queue, "enqueue_task", lambda task, front=False: captured.append(task))
persisted_before_materialize = []
real_materialize = update_merge.materialize_assisted_merge_live
def spying_materialize(*args, **kwargs):
persisted_before_materialize.append(
(update_merge.read_update_tx().get("progress_rescue") or {}).get("path")
)
return real_materialize(*args, **kwargs)
monkeypatch.setattr(update_merge, "materialize_assisted_merge_live", spying_materialize)
result = update_merge.finalize_managed_update_on_boot(supervisor_ready=True)
assert result.get("resumed") is True, result
rescue_dirs = list((tmp_path / "data" / "archive" / "rescue").iterdir())
assert len(rescue_dirs) == 1
assert "half-finished resolution" in (
rescue_dirs[0] / "changes.diff"
).read_text(encoding="utf-8")
# The durable pointer was already on disk when materialize started.
assert persisted_before_materialize == [str(rescue_dirs[0])]
stored = update_merge.read_update_tx()
assert stored["progress_rescue"]["path"] == str(rescue_dirs[0])
meta = json.loads((rescue_dirs[0] / "rescue_meta.json").read_text(encoding="utf-8"))
assert meta["reason"] == "managed_update_rescue:assisted_rematerialize" # not rollback:*
assert captured, "the resumed resolver task must be enqueued"
assert str(rescue_dirs[0]) in captured[0]["text"]
assert "do not run git commands" in captured[0]["text"]
# Second boot with the merge state intact (has_progress=True). "MERGE_HEAD +
# dirty" is exactly what the materialize above just produced, and materialize
# never re-applies the rescued edits — so this state is NOT evidence that the
# work came back, and the pointer must survive into the next objective.
result2 = update_merge.finalize_managed_update_on_boot(supervisor_ready=True)
assert result2.get("resumed") is True, result2
assert update_merge.read_update_tx()["progress_rescue"]["path"] == str(rescue_dirs[0])
assert str(rescue_dirs[0]) in captured[-1]["text"]
assert "was rescued to" in captured[-1]["text"]

View file

@ -1,6 +1,8 @@
"""Presentation labels never create a second managed-update route."""
from supervisor.update_merge_policy import classify_conflicts, is_document_path, is_hot_code
from supervisor.update_merge_policy import (
assisted_objective, classify_conflicts, is_document_path, is_hot_code,
)
def test_clean_when_no_conflicts():
@ -25,3 +27,69 @@ def test_labels_normalize_paths_without_changing_route():
assert is_document_path("docs\\guide.md")
assert not is_document_path("docs/notes.txt")
assert is_hot_code("./supervisor/queue.py")
# ``assisted_objective`` renders the resolver task's objective text; authority lives
# in the tx marker + fingerprint, never in this presentation string.
def test_objective_lists_conflicts_and_has_no_rescue_phrase_without_progress_rescue():
objective = assisted_objective({
"target_sha": "a" * 40,
"conflict_paths": ["x.py", "docs/y.md"],
})
assert "Resolve each conflicting file (x.py, docs/y.md)" in objective
assert ("a" * 12) in objective # short target sha
assert "was rescued to" not in objective
assert "do not run git commands" not in objective
def test_objective_with_progress_rescue_points_at_the_rescue_path():
objective = assisted_objective({
"target_sha": "a" * 40,
"conflict_paths": ["x.py"],
"progress_rescue": {
"path": "/data/archive/rescue/20260810_abc",
"reason": "assisted_rematerialize",
},
})
assert "was rescued to /data/archive/rescue/20260810_abc" in objective
assert "changes.diff there is a plain diff against the reviewed base" in objective
assert "do not run git commands" in objective
# The rescue note extends — never replaces — the resolution instructions.
assert "Resolve each conflicting file (x.py)" in objective
def test_objective_falls_back_to_the_rollback_rescue_pointer():
objective = assisted_objective({
"target_sha": "a" * 40,
"rollback_rescue": {"path": "/data/archive/rescue/rb", "reason": "update_rollback"},
})
assert "was rescued to /data/archive/rescue/rb" in objective
def test_objective_names_only_the_latest_rescue_plus_a_count():
"""Several re-materializations overwrite the single pointer; the objective names
the LATEST rescue path plus an honest tally no history rendering."""
objective = assisted_objective({
"target_sha": "a" * 40,
"conflict_paths": ["x.py"],
"progress_rescue": {
"path": "/data/archive/rescue/P2",
"reason": "assisted_rematerialize",
"count": 2,
},
})
assert "was rescued to /data/archive/rescue/P2" in objective
assert "(2 rescues were taken; this is the latest)" in objective
def test_objective_ignores_malformed_or_pathless_progress_rescue():
for bad in ("corrupt-string", {}, {"reason": "no-path"}, None):
objective = assisted_objective({"target_sha": "a" * 40, "progress_rescue": bad})
assert "was rescued to" not in objective, bad