mirror of
https://github.com/razzant/ouroboros.git
synced 2026-08-29 21:41:54 +00:00
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:
commit
0bccde5696
22 changed files with 2505 additions and 256 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; "
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(A→B) then
|
||||
unstage(B→A) 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 capture→apply 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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
83
tests/test_review_binary_context.py
Normal file
83
tests/test_review_binary_context.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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), \
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue