v7next F2.4: return the D34 carrier engine and re-split the update planner

Owner decisions 5.12-5.14=A (carrier resolver mandatory, before the M0 pin,
one lane with the re-split).

- release_sync: span-SSOT re-cut to THIS tree's carrier inventory - 25
  descriptors: the 8 reference spans + the README direct-download reference
  block + 8 release-download anchors on each public install page (derived
  from RELEASE_ASSET_TEMPLATES; underscore-safe id class).
- supervisor/update_carriers.py returned whole: span substitution + ordinary
  3-way per file, byte-exact BOUNDED git plumbing (own process group,
  whole-tree kill on timeout), standalone-importable for the operator helper.
- Three insertion points re-derived against the redesigned bodies: planner
  merge (preview AND stash-first build=True replan) before classify_conflicts;
  base re-merge before write-tree; live materializer before the Q8 projection
  and the M0 baseline pin. A conflict confined to declared carrier spans
  stays on the clean lane; anything else degrades to assisted exactly as
  before.
- update_merge.py 1593 -> 1193: planner/builder/materializer re-split into
  supervisor/update_merge_plan.py from the redesign's two-module form
  (parent-facade re-exports; _um()/_uc module-handle patch surfaces).
- update_merge_policy.carrier_guidance reads the span SSOT and describes the
  degraded remainder; RELEASE_INVARIANT_PATHS additive closure for the new
  leaves with parity pins; _POPEN_ALLOWLIST mirror row.
- Tests: carrier corpus + rebase helper + owner facade returned and
  re-proven; the materializer projection test fixture re-derived (a clean
  token conflict is now resolved by the planner); size-ratchet manifest
  regenerated (update_merge.py entered the band by extraction).
- docs/v7next/LEDGER_CORRECTIONS.md: F2.4 lane section (12 entries).

(cherry picked from commit 06f481dfbc415602a967218b6427955cd3818613)
This commit is contained in:
Ouroboros 2026-08-31 09:57:32 +00:00
parent 1b4a8da957
commit 7f0a112443
14 changed files with 1808 additions and 425 deletions

View file

@ -1558,3 +1558,148 @@ with evidence, found lane by lane. Applied to the campaign's carried ledger at F
ouroboros/subagent_worktrees.py enters the band by the sanctioned delta
(1000->1082, rationale recorded). domains.toml untouched (coordinator
seam owns the map).
## From the F2.4 update-engine lane (base 2878560e, 2026-08-31)
D34 return + 1A re-split, per owner answers Ф-1=A / Ф-2=A / Ф-3=A
(= plan rows 5.12-5.14A). Every re-derived body below is justified as
reference-fact ↔ tip-fact ↔ result.
1. Span-SSOT re-cut (ouroboros/tools/release_sync.py, merged ATOP the tip
file, not a replacement). Reference: 8 descriptors (v7_wip
release_sync.py:65-148). Tip inventory is WIDER: sync_release_metadata
writes the two public install pages (tip :423-434) and the README
direct-download reference block (:100-113); version_carrier_desyncs /
update_candidate.py:697-698 check them. Result: 25 descriptors = the 8
reference spans + readme_download_refs (the contiguous
`[download-<id>]:` block) + 8 anchors per install page, derived from
RELEASE_ASSET_TEMPLATES (a new installer automatically gets a span);
macos-arm64 appears twice per page and is disambiguated by the
quick-start step's literal "Click " prefix (lookaround pair) — a page
restructure degrades to malformed/duplicate-anchor, never a guess.
Latent-trap fix proven by span inspection: proof ids carry `x86_64`, so
a `[a-z0-9-]` class matched the tip block ONCE but covered only its
first 3 lines (wrong-coverage, silent partial substitution) — the class
is `[a-z0-9_-]`, and the live-tree pin asserts full-block coverage
indirectly through exactly-once anchoring of every descriptor.
2. supervisor/update_carriers.py returned WHOLE (no upstream analog); two
bodies re-derived against the redesign train's bounded-plumbing rule
(4795a810/c404c056 class): _run_git and the merge-file runner now start
the child in its own process group and kill the WHOLE TREE on a 300s
timeout (constant mirrors update_candidate._GIT_RUN_TIMEOUT_SEC) —
insertion point 3 runs while the update lock is held. Byte-exact capture
(text=False semantics) preserved from the reference. Deliberately NOT
routed through git_ops._run_git_process_bounded: that helper imports
ouroboros.tools.shell at call time (tool-registry package init), which
would break the standalone operator rebase helper; the
_active_subprocesses shutdown-tracking nicety is therefore not carried
(short-lived waited children — disclosed residual). Docstring
re-derived: insertion host is the re-cut update_merge_plan.py; the
resolver never runs `git merge` (explicit index stages + `git
merge-file`), so it is rerere-neutral by construction, in line with the
train's _MERGE_NEUTRAL_FLAGS discipline; M0 note per Ф-2=A.
3. Three insertion points re-derived against the REWRITTEN tip bodies
(reference bodies were pre-redesign; matrix rows MIGRATION:3427-3429):
(a) point 1 (row 3428): reference update_merge_plan.py:334-344 ↔ tip
plan_managed_update_merge (stash-first; snapshot via
worktree_snapshot_tree instead of the temp-index) → resolution after
the merge/inventory consistency check, BEFORE classify_conflicts; the
single body serves both the preview plan and the authoritative
build=True replan (control.py replans on the clean tree through the
same function). `carrier_resolved_paths` restored to the ff-clean,
base-conflict and main returns (reference shape).
(b) point 2 (row 3427): reference :88-97 ↔ tip _build_clean_merge_commit
(fast_forwardable early return, Q8 projection before write-tree) →
resolution inside the rc_bm==1 branch BEFORE write-tree; the tip's
`if base_conflicts: return` inverted to the reference's
no-inventory-error + resolve + `if remaining: return` shape; the Q8
projection now runs AFTER span resolution, so its postcondition also
verifies the just-resolved carriers.
(c) point 3 (row 3429): reference :454-469 ↔ tip materializer
(rerere-off flags, mandatory Q8 projection, CAS re-parent, M0 pin) →
resolution after MERGE_HEAD validation and BEFORE the projection and
the M0 pin (Ф-2=A: span policy is part of the mechanical baseline;
reviewers diff an M0 already free of carrier markers); the tip 3-tuple
return (ok, message, m0_tree) preserved.
Handle idiom: the reference `_um()` handle is retained ONLY for
managed_update_constitution_present (monkeypatched on the parent facade
— test_update_merge_assisted.py:973); update_candidate members are read
through the `_uc` module object (test_update_hardening.py:99/125
patches update_candidate.worktree_snapshot_tree) — the D10-lane entry-7
patch-surface rule. The row-3426 verbatim `_git_run` relocation stays
SUPERSEDED (upstream re-homed it to update_candidate; the leaf reads
`_uc._git_run`).
4. Boot-recovery backfill window NOT extended (upstream recovery semantics
= floor): _recover_assisted_on_boot's M0 backfill re-runs only the Q8
projection; a carrier still conflicted through that crash window
degrades to the assisted lane (fail-safe, never fail-wrong). Disclosed
in the wiring pin's docstring; keeps the "3 resolver calls in the leaf,
0 in the parent" invariant intact.
5. 1A re-split executed per Ф-3=A from the two-module tip form:
update_merge.py 1593 → 1193 (tx/lock/rollback/boot-recovery facade,
re-exports both leaves), new supervisor/update_merge_plan.py (490 =
three tip bodies + the documented deltas). The reference leaf is the
THEME (same three owners), not bytes. Ratchet: update_merge.py entered
the 1001-1500 band by extraction with a rationale via the official
generator; `-m size_ratchet` = 5 passed.
6. update_merge_policy.py coordination (matrix row "согласовать"):
carrier_guidance's hand-list VERSION_CARRIER_PATHS (6 paths, already
narrower than the tip's own carrier inventory) replaced by a call-time
read of the span SSOT (CARRIER_SPAN_PATHS); prose re-derived — spans
resolved mechanically never reach the resolver's list (verified:
control.py:820 refreshes tx.conflict_paths from live_unmerged_paths
after materialization), so the guidance now describes exactly the
DEGRADED remainder and what degradation means.
7. Protection closure (the G1/D10 additive-literal precedent, coordinator
LEDGER entry 4 class): RELEASE_INVARIANT_PATHS +=
supervisor/update_merge_plan.py, supervisor/update_carriers.py —
the split moved planner/materializer bodies out of a release-invariant
file and the resolver rewrites worktree files under the update lock;
parity pinned in tests/test_update_merge_owner_facade.py. DISCLOSED
upstream inventory gap, NOT repaired (Q4=A, upstream owns protected
surfaces): supervisor/update_candidate.py carries bodies upstream's own
redesign moved out of the same protected parent, yet is absent from
RELEASE_INVARIANT_PATHS — owner/Ф3 material.
8. Tests: test_update_carriers.py ported with re-derivations (leaf import
path unchanged; materializer test unpacks the tip 3-tuple and pins that
M0 names the official VERSION blob; corpus README fixture extended with
the FULL 7-id download-refs block — the Q8 postcondition checks every
RELEASE_ASSET_TEMPLATES member once a README opts into the projection,
and the new span must anchor; SSOT pin re-cut to 25; an explicit
"conflicted carrier never routes to assisted" strategy pin added per
the work order). test_carrier_rebase_helper.py + the operator helper
returned (helper docstring's carrier list re-cut).
test_update_merge_owner_facade.py re-derived: owners = update_merge_plan
(3 bodies) + update_candidate (the redesign's own boundary, identity
now pinned); hot-code and release-invariant parity clauses.
_POPEN_ALLOWLIST (tests/test_process_custody.py) +=
supervisor/update_carriers.py (path-keyed mirror, D10 git_ops_reset row
class).
9. NAME COLLISION tests/test_update_merge_plan.py resolved as SUPERSEDED,
not transplanted: the oracle file's 13 test functions are
name-set-identical to the tip file and the tip bodies are the
upstream-evolved forms of the same assertions (stash status tuple
"ok"/sha, failed-update-<target12> forensics naming) — zero unique
oracle content; a rename-transplant would mint 13 AST-near-duplicates
(the D15 class the wave mandate bans). Tip bytes stand.
10. Upstream test re-derived (falsified-by-D34 fixture, the "test pinning
the gap" class): test_update_merge_assisted.py::
test_materialize_projects_version_to_target_and_pins_m0 used a clean
1.5.0-vs-2.0.0 VERSION token conflict, which the D34 planner now
resolves (plan turns clean — the scenario could no longer reach the
materializer's projection). The local token becomes a malformed anchor
("not-a-version"), so the span resolver degrades honestly and the Q8
projection clause the test pins stays reachable; docstring says why.
11. Ф3 joints named, untouched (report-only): the future N−1 shim surface
(finalize_managed_update_on_boot / _recover_assisted_on_boot /
_recover_replace_on_boot / _finalize_pending_boot_smoke /
apply_managed_merge_update / rollback_managed_update) stays WHOLE in
the parent — the re-split does not dissect ABI-7/F14 material; the RC
auditor's evidence surface (record_managed_tests_evidence /
managed_tests_evidence_covers) untouched in update_candidate;
git_ops.py:1031-1032 (D13) untouched — protected wave; Ф-4 derived
FAMILY_PATHS not executed (coordinator's tail item — the additive
entries in item 7 keep that door open).
12. Pre-existing at base, NOT this lane's defects (dup-scan receipts):
10 AST-identical test pairs across test_review_cycles_dispatch.py /
test_review_cycles_skill_dispatch.py (already named by the Ф2-plan) and
an in-file duplicate def test_ripgrep_download_script_verifies_checksum
in tests/test_build_scripts.py (the later def shadows the earlier —
D15-class latent, review-organ/F5 material).

View file

@ -62,6 +62,13 @@ RELEASE_INVARIANT_PATHS = frozenset({
"supervisor/git_ops_updates.py",
"supervisor/update_merge.py",
"supervisor/update_merge_policy.py",
# The F2.4 update-engine re-split moved the planner/materializer bodies —
# the carrier engine's three insertion points — out of the protected
# update_merge facade, and the D34 span resolver rewrites worktree files
# under the update lock; every inventory that protects the parent must
# cover them (label parity — same rule as the G1 block above).
"supervisor/update_merge_plan.py",
"supervisor/update_carriers.py",
})
PROTECTED_RUNTIME_PATH_PREFIXES = FROZEN_CONTRACT_PATH_PREFIXES

View file

@ -170,6 +170,7 @@ BAND_PATHS = {
"supervisor/queue.py": "v7next D08 partial split: 1587->1265 after the schedules family moved to supervisor/queue_schedules.py; the residue keeps the deferred snapshot/timeouts/evolution rows (cancel/custody-entangled, F2)",
"supervisor/task_reaper.py": "Entered the band from 907 lines: timeout-retry admission now serializes queue publication, reciprocal result lineage, cancellation-wins handoff, and failed-terminal-write custody in the existing off-loop reaper owner.",
"supervisor/terminal_delivery.py": None,
"supervisor/update_merge.py": "Entered the band from above (1593 lines) by extraction: the F2.4 update-engine re-split moved the planner, the clean-plan commit builder and the live materializer \u2014 the carrier engine's three insertion points \u2014 into supervisor/update_merge_plan.py (D34 return, owner answers 5.12-5.14=A); shrink-only.",
"tests/test_acting_subagents.py": None,
"tests/test_advisory_observability.py": None,
"tests/test_build_scripts.py": None,

View file

@ -10,7 +10,7 @@ from __future__ import annotations
import re
from pathlib import Path
from typing import List, Tuple
from typing import List, NamedTuple, Optional, Tuple
_MAX_MAJOR = 2
_MAX_MINOR = 5
@ -97,6 +97,132 @@ def release_asset_download_url(
)
class VersionCarrierSpan(NamedTuple):
"""One version-carrying span in one release-carrier file.
``pattern`` must match EXACTLY ONCE in a well-formed copy of ``path``:
zero matches is a malformed anchor, more than one is a duplicate anchor.
"""
carrier_id: str
path: str
pattern: "re.Pattern[str]"
def _install_page_spans(tag: str, path: str) -> Tuple[VersionCarrierSpan, ...]:
"""Carrier spans for one public install page: every anchor tag owned by
the release projection (``data-release-download``), derived from
``RELEASE_ASSET_TEMPLATES`` so a new installer automatically gets a span.
``macos-arm64`` appears twice by design (the platform button and the
quick-start step); the pair disambiguates on the step's literal ``Click ``
prefix. A page restructure that breaks either anchor degrades the file to
the ordinary assisted path (malformed/duplicate anchor) — never a guess."""
spans: List[VersionCarrierSpan] = []
for proof_id in RELEASE_ASSET_TEMPLATES:
if proof_id == "macos-arm64":
spans.append(VersionCarrierSpan(
f"{tag}_download_{proof_id}_button", path,
re.compile(r'(?<!Click )<a data-release-download="macos-arm64"[^>]*>'),
))
spans.append(VersionCarrierSpan(
f"{tag}_download_{proof_id}_step", path,
re.compile(r'(?<=Click )<a data-release-download="macos-arm64"[^>]*>'),
))
else:
spans.append(VersionCarrierSpan(
f"{tag}_download_{proof_id}", path,
re.compile(rf'<a data-release-download="{re.escape(proof_id)}"[^>]*>'),
))
return tuple(spans)
# Version-carrier span descriptors — the SSOT the carrier-aware update engine
# reads (owner-ratified: spec §1.9-10, batch №8 answer 6=A; mandatory v7next
# return, owner answers 5.12-5.14=A). The managed-update resolver
# (supervisor/update_carriers.py) and the tactical-rebase helper
# (scripts/carrier_rebase_helper.py) resolve merge conflicts INSIDE these spans
# by span substitution; a malformed or duplicate anchor degrades the file to
# the ordinary assisted-conflict path (never a crash, never silent adoption),
# and a conflict OUTSIDE a span keeps the file an ordinary conflict. The span
# set is cut from THIS tree's carrier inventory — everything
# ``sync_release_metadata`` writes and ``version_carrier_desyncs`` checks: the
# classic carriers, README's badge + Version History + direct-download
# reference block, uv.lock's editable root package, and the release-projection
# anchors of the two public install pages.
VERSION_CARRIER_SPANS: Tuple[VersionCarrierSpan, ...] = (
VersionCarrierSpan(
"version_file", "VERSION",
re.compile(r'\A\d+\.\d+\.\d+' + _PRE_SUFFIX + r'\n?\Z', re.IGNORECASE),
),
VersionCarrierSpan(
"pyproject_version", "pyproject.toml",
re.compile(r'^version\s*=\s*"[^"\n]*"', re.MULTILINE),
),
VersionCarrierSpan(
"web_package_version", "web/package.json",
re.compile(r'^\s*"version"\s*:\s*"[^"\n]*"', re.MULTILINE),
),
VersionCarrierSpan(
"gateway_contract_version", "web/modules/api_types.js",
re.compile(r"GATEWAY_CONTRACT_VERSION\s*=\s*'[^'\n]*'"),
),
VersionCarrierSpan("readme_badge", "README.md", _README_BADGE_RE),
VersionCarrierSpan(
"readme_history", "README.md",
re.compile(
r'(?:^\|\s*\d+\.\d+\.\d+' + _PRE_SUFFIX + r'\s*\|.*(?:\n|\Z))+',
re.MULTILINE | re.IGNORECASE,
),
),
# The contiguous named-reference block the direct-download projection
# rewrites ([download-<proof_id>]: <url>) — a release-owned span like the
# badge, so a version-bump conflict there resolves by span policy.
VersionCarrierSpan(
"readme_download_refs", "README.md",
re.compile(r'(?:^\[download-[a-z0-9_-]+\]:[^\n]*(?:\n|\Z))+', re.MULTILINE),
),
VersionCarrierSpan("architecture_header", "docs/ARCHITECTURE.md", _ARCH_HEADER_RE),
# uv.lock mirrors the editable root package version (ARCHITECTURE "Version
# carriers"); the descriptor rides the same structural regex sync_version
# already writes through, so a managed-update or tactical-rebase conflict in
# this section resolves by span policy instead of falling to assisted.
VersionCarrierSpan("uv_lock_root_package", "uv.lock", _UV_LOCK_ROOT_RE),
) + _install_page_spans(
"site_install", "site/install/index.html"
) + _install_page_spans(
"docs_install", "docs/install/index.html"
)
CARRIER_SPAN_PATHS = frozenset(span.path for span in VERSION_CARRIER_SPANS)
def carrier_spans_for(path: str) -> Tuple[VersionCarrierSpan, ...]:
"""Return every declared carrier span for a repo-relative path ('' -> none)."""
normalized = str(path or "").replace("\\", "/")
if normalized.startswith("./"):
normalized = normalized[2:]
return tuple(span for span in VERSION_CARRIER_SPANS if span.path == normalized)
def locate_carrier_span(
text: str, span: VersionCarrierSpan
) -> Tuple[str, Optional[Tuple[int, int]]]:
"""Locate one carrier span in *text*.
Returns ``("ok", (start, end))`` for exactly one match,
``("malformed_anchor", None)`` for zero and ``("duplicate_anchor", None)``
for several — the two degradation reasons the update engine surfaces.
"""
matches = span.pattern.finditer(str(text or ""))
first = next(matches, None)
if first is None:
return "malformed_anchor", None
if next(matches, None) is not None:
return "duplicate_anchor", None
return "ok", (first.start(), first.end())
def _sync_readme_download_urls(text: str, version: str) -> str:
"""Rewrite named Markdown references without touching historical links."""
updated = text

View file

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Span-substitution helper for version-carrier conflicts during tactical
rebases of the v7 branch (owner-ratified: spec §1.9-10, batch №8 answer 6=A).
Standalone operator tooling — NOT runtime. When a `git rebase` (or merge) of
the v7 branch stops on the release carriers (VERSION, pyproject.toml, uv.lock,
web/package.json, web/modules/api_types.js GATEWAY_CONTRACT_VERSION, the
README badge / Version History block / direct-download reference block, the
docs/ARCHITECTURE.md header, the release-download anchors of the two public
install pages), this helper resolves each conflicted carrier file by span
substitution: the preferred side — 'ours' by default, which during a rebase is
the side being rebased ONTO (index stage 2) — wins INSIDE the declared carrier
spans, and everything else in the file merges as an ordinary textual 3-way.
A file whose anchors are malformed or duplicated, or which conflicts OUTSIDE
its carrier spans, is left exactly as git left it, for manual resolution.
Non-carrier conflicted files are never touched.
The engine and the span descriptors are the SAME ones the managed-update
runtime uses: supervisor/update_carriers.py reading the SSOT in
ouroboros/tools/release_sync.py. The one liberty this launcher takes is
loading release_sync straight from its file and pre-registering it under its
canonical module name, so a standalone operator invocation never executes the
`ouroboros.tools` package __init__ (which drags in the full tool registry and
its runtime configuration).
Exit codes: 0 — every conflicted carrier file was resolved (non-carrier
conflicts may remain; they are the operator's ordinary rebase work);
1 — at least one carrier file degraded to manual resolution; 2 — git or
usage failure.
"""
from __future__ import annotations
import argparse
import importlib.util
import pathlib
import subprocess
import sys
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT))
def _load_engine():
"""Import the shared resolver without executing ouroboros.tools.__init__."""
spec = importlib.util.spec_from_file_location(
"ouroboros.tools.release_sync",
REPO_ROOT / "ouroboros" / "tools" / "release_sync.py",
)
assert spec is not None and spec.loader is not None
release_sync = importlib.util.module_from_spec(spec)
spec.loader.exec_module(release_sync)
sys.modules.setdefault("ouroboros.tools.release_sync", release_sync)
from supervisor.update_carriers import resolve_carrier_conflicts
return resolve_carrier_conflicts
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--worktree", default=".",
help="the mid-rebase checkout to operate on (default: current directory)",
)
parser.add_argument(
"--prefer", choices=("ours", "theirs"), default="ours",
help="which side wins INSIDE the carrier spans (default: ours — during "
"a rebase, the side being rebased onto)",
)
args = parser.parse_args(argv)
worktree = str(pathlib.Path(args.worktree).resolve())
listing = subprocess.run(
["git", "-C", worktree, "diff", "--name-only", "--diff-filter=U"],
capture_output=True, text=True,
)
if listing.returncode != 0:
print(f"error: could not list unmerged paths: {listing.stderr.strip()}",
file=sys.stderr)
return 2
conflicted = [line.strip() for line in listing.stdout.splitlines() if line.strip()]
if not conflicted:
print("nothing to do: no unmerged paths")
return 0
resolve_carrier_conflicts = _load_engine()
outcome = resolve_carrier_conflicts(worktree, conflicted, prefer=args.prefer)
resolved = list(outcome["resolved"])
kept = dict(outcome["kept"])
non_carrier = sorted(p for p, reason in kept.items() if reason == "not_a_carrier")
degraded = {p: reason for p, reason in kept.items() if reason != "not_a_carrier"}
if resolved:
print(f"resolved by span substitution ({args.prefer} inside the spans, "
f"3-way for the rest): {', '.join(sorted(resolved))}")
if degraded:
for path, reason in sorted(degraded.items()):
print(f"left for manual resolution: {path} ({reason})")
if non_carrier:
print(f"not carrier files — ordinary rebase work: {', '.join(non_carrier)}")
if not resolved and not degraded:
print("no carrier files among the conflicts")
return 1 if degraded else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,225 @@
"""Carrier-aware managed-update conflict resolution (owner-ratified: spec §1.9-10,
batch №8 answer 6=A; mandatory v7next return, owner answers 5.12-5.14=A).
ONE shared resolver serves all three managed-update insertion points in
``supervisor/update_merge_plan.py`` — the isolated-worktree planner merge, the
clean-plan base re-merge (both applied BEFORE write-tree) and the live assisted
materializer (applied BEFORE the mechanical M0 baseline is pinned, so reviewers
diff a baseline that already carries the span policy) — plus, with the opposite
preference, the operator rebase helper ``scripts/carrier_rebase_helper.py``.
A merge conflict in a release-carrier file is resolved ONLY when every conflict
in it sits inside a declared carrier span: each span in every stage is
substituted with the preferred side's span (the incoming official side for
managed updates), the remainder is re-merged as an ordinary textual 3-way, and
the file is staged iff that re-merge is clean. Anything else — a malformed or
duplicate span anchor, an unreadable, missing or non-UTF-8 stage, overlapping
spans, a conflict OUTSIDE the spans — leaves the file on the ordinary
assisted-conflict path: never a crash, never silent adoption, and never
whole-file theirs (only the spans themselves change sides).
The span descriptors are owned by ``ouroboros.tools.release_sync`` (the
release-carrier SSOT), imported at call time so importing the update machinery
never drags the tool package in. Every git invocation here is BOUNDED (the
update-flow redesign's plumbing rule): the live-materializer insertion point
runs while the update lock is held, and a hung git must die with its whole
process tree instead of wedging the update flow. The resolver itself never runs
``git merge`` — it substitutes explicit index stages and re-merges them with
``git merge-file`` — so the engine is neutral to rerere by construction, in
line with the update flow's ``_MERGE_NEUTRAL_FLAGS`` discipline. Honest frame:
the FIRST pre-v7 upgrade is driven by the OLD updater, which never calls this
module; the policy targets steady state (7.0.0 -> 7.0.1 and beyond).
"""
from __future__ import annotations
import os
import subprocess
import tempfile
from typing import Any, Dict, List, Optional, Tuple
# Index stages of a conflicted path: 1 = merge base, 2 = ours, 3 = theirs.
# During a managed update "theirs" is the official target in all three
# insertion points; during a rebase "ours" is the side being rebased onto.
_PREFER_STAGE = {"ours": 2, "theirs": 3}
_CARRIER_GIT_TIMEOUT_SEC = 300.0
"""Wall-clock bound for one carrier-resolution git invocation, mirroring
``supervisor.update_candidate._GIT_RUN_TIMEOUT_SEC``. Kept as a local constant
(and the runner below self-contained) so the operator rebase helper can import
this module standalone without dragging git_ops or the tool registry in."""
def _run_git(
worktree: str, args: List[str], *, input_bytes: Optional[bytes] = None
) -> Tuple[int, bytes, bytes]:
"""Run git in *worktree* with byte-exact capture (no newline translation).
BOUNDED per the update-flow plumbing rule: the process starts in its own
group and the WHOLE TREE is killed on timeout (``platform_layer`` helpers,
imported at call time to keep this module standalone-importable)."""
from ouroboros.platform_layer import kill_process_tree, subprocess_new_group_kwargs
cmd = ["git", "-C", str(worktree), *args]
try:
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE if input_bytes is not None else None,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
**subprocess_new_group_kwargs(),
)
except OSError as exc:
return 127, b"", str(exc).encode("utf-8", "replace")
try:
stdout, stderr = proc.communicate(input=input_bytes, timeout=_CARRIER_GIT_TIMEOUT_SEC)
except subprocess.TimeoutExpired:
kill_process_tree(proc)
try:
proc.communicate(timeout=10)
except Exception:
pass
message = (
f"git timed out after {_CARRIER_GIT_TIMEOUT_SEC:g}s and was terminated: "
+ " ".join(cmd)
)
return 124, b"", message.encode("utf-8", "replace")
return proc.returncode, stdout or b"", stderr or b""
def _stage_text(worktree: str, stage: int, path: str) -> Optional[str]:
"""UTF-8 text of one index stage, or None (missing stage / undecodable)."""
rc, out, _err = _run_git(worktree, ["show", f":{stage}:{path}"])
if rc != 0:
return None
try:
return out.decode("utf-8")
except UnicodeDecodeError:
return None
def _substitute_spans(
text: str, spans: Tuple[Any, ...], preferred_text: str
) -> Tuple[Optional[str], str]:
"""Replace every carrier span in *text* with the preferred side's span.
Returns ``(substituted_text, "")`` or ``(None, reason)`` when any anchor is
malformed/duplicate in either text or the spans overlap — the degradation
reasons that keep the file on the assisted path."""
from ouroboros.tools.release_sync import locate_carrier_span
replacements: List[Tuple[Tuple[int, int], str]] = []
for span in spans:
preferred_status, preferred_loc = locate_carrier_span(preferred_text, span)
if preferred_status != "ok" or preferred_loc is None:
return None, f"{preferred_status}:{span.carrier_id}:preferred_side"
status, loc = locate_carrier_span(text, span)
if status != "ok" or loc is None:
return None, f"{status}:{span.carrier_id}"
replacements.append((loc, preferred_text[preferred_loc[0]:preferred_loc[1]]))
ordered = sorted(replacements, key=lambda item: item[0][0], reverse=True)
previous_start: Optional[int] = None
for (start, end), _replacement in ordered:
if previous_start is not None and end > previous_start:
return None, "overlapping_spans"
previous_start = start
substituted = text
for (start, end), replacement in ordered:
substituted = substituted[:start] + replacement + substituted[end:]
return substituted, ""
def _merge_span_substituted_texts(
current: str, base: str, other: str
) -> Tuple[Optional[str], str]:
"""Ordinary textual 3-way over the span-substituted stages.
Clean merge -> ``(merged_text, "")``. Remaining conflicts mean a conflict
OUTSIDE the carrier spans -> ``(None, "conflict_outside_carrier_span")``."""
with tempfile.TemporaryDirectory(prefix="ouro-carrier-merge-") as tmp:
stage_paths: List[str] = []
for name, content in (("current", current), ("base", base), ("other", other)):
stage_path = os.path.join(tmp, name)
with open(stage_path, "wb") as handle:
handle.write(content.encode("utf-8"))
stage_paths.append(stage_path)
# merge-file needs no repository; -C pins the bounded runner to the
# temp dir and the absolute stage paths stay valid either way.
rc, out, _err = _run_git(tmp, ["merge-file", "-p", "--", *stage_paths])
if rc == 0:
try:
return out.decode("utf-8"), ""
except UnicodeDecodeError:
return None, "merge_result_undecodable"
# Positive exit = number of remaining conflicts; anything else = error.
if 0 < rc <= 127:
return None, "conflict_outside_carrier_span"
return None, "merge_file_failed"
def resolve_carrier_conflict_file(
worktree: str, path: str, prefer: str
) -> Tuple[bool, str]:
"""Resolve ONE conflicted carrier file in *worktree*; (resolved, reason)."""
from ouroboros.tools.release_sync import carrier_spans_for
spans = carrier_spans_for(path)
if not spans:
return False, "not_a_carrier"
stage_texts: Dict[int, str] = {}
for stage in (1, 2, 3):
text = _stage_text(worktree, stage, path)
if text is None:
return False, f"stage_{stage}_unavailable"
stage_texts[stage] = text
preferred_text = stage_texts[_PREFER_STAGE[prefer]]
substituted: Dict[int, str] = {}
for stage in (1, 2, 3):
text, reason = _substitute_spans(stage_texts[stage], spans, preferred_text)
if text is None:
return False, reason
substituted[stage] = text
merged, reason = _merge_span_substituted_texts(
substituted[2], substituted[1], substituted[3]
)
if merged is None:
return False, reason
absolute = os.path.join(str(worktree), path.replace("/", os.sep))
try:
with open(absolute, "wb") as handle:
handle.write(merged.encode("utf-8"))
except OSError:
return False, "worktree_write_failed"
rc_add, _out, _err = _run_git(worktree, ["add", "--", path])
if rc_add != 0:
return False, "stage_failed"
return True, ""
def resolve_carrier_conflicts(
worktree: str, conflict_paths: List[str], *, prefer: str = "theirs"
) -> Dict[str, Any]:
"""Resolve carrier-span conflicts among *conflict_paths* in *worktree*.
Returns ``{"resolved": [paths staged here], "kept": {path: reason}}``.
``prefer`` picks the winning side INSIDE the spans only: ``"theirs"`` for
managed updates (the official target), ``"ours"`` for tactical rebases.
Per-file failures degrade that file to the assisted path — this function
never raises for a file it cannot resolve."""
if prefer not in _PREFER_STAGE:
raise ValueError(f"unsupported carrier preference: {prefer!r}")
resolved: List[str] = []
kept: Dict[str, str] = {}
for raw_path in conflict_paths:
path = str(raw_path).strip()
if not path:
continue
try:
ok, reason = resolve_carrier_conflict_file(worktree, path, prefer)
except Exception: # degrade, never crash the update machinery
ok, reason = False, "resolver_error"
if ok:
resolved.append(path)
else:
kept[path] = reason
return {"resolved": resolved, "kept": kept}

View file

@ -12,7 +12,6 @@ from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
import time
@ -33,6 +32,13 @@ from supervisor.update_candidate import ( # noqa: F401
stash_local_changes_for_update, lookup_update_stash,
destructive_apply_guard, project_version_carriers,
)
# The planner, the clean-plan commit builder and the live materializer — the
# carrier engine's three insertion points — live in supervisor.update_merge_plan
# (module-size split, re-cut from the redesign's two-module form); re-exported:
# every caller and test reaches them via this module (F401 intended).
from supervisor.update_merge_plan import ( # noqa: F401
_build_clean_merge_commit, materialize_assisted_merge_live, plan_managed_update_merge,
)
UPDATE_TX_MARKER_NAME = "ouroboros-update-tx.json"
@ -44,338 +50,6 @@ def managed_update_constitution_present(ref: str = "HEAD") -> bool:
return official_ref_has_constitution(ref, repo_dir=_g.REPO_DIR)
def _build_clean_merge_commit(
tmp_wt: str,
base_sha: str,
target_sha: str,
*,
fast_forwardable: bool,
local_dirty_count: int,
) -> Tuple[str, Optional[Dict[str, Any]]]:
"""Build the durable merge commit for a CLEAN plan inside the temp worktree.
Owner decision (2026-08, Q1=C): local dirty work NEVER enters committed
history on a clean auto-update. The commit merges the reviewed HEAD (base)
and the official target only; the apply path stashes dirty work and
restores it as uncommitted content after the update. clean(snapshot,
target) implies clean(base, target) for ordinary hunk overlaps, but
file/directory-type collisions CAN break the implication — a conflicting
base re-merge is returned as ``{"base_conflicts": [...]}`` so the caller
routes it to the assisted lane. Returns (merge_commit, failure|None)."""
if local_dirty_count:
if fast_forwardable:
# Base is an ancestor of the target: pure official history,
# no merge commit needed at all.
return target_sha, None
rc_r, _ro, reset_error = _git_run(["git", "-C", tmp_wt, "reset", "--hard", base_sha])
if rc_r != 0:
return "", {"error": reset_error or "could not reset plan worktree to base"}
rc_bm, _bo, base_merge_error = _git_run(
["git", *_MERGE_NEUTRAL_FLAGS, "-C", tmp_wt, "merge", "--no-commit", "--no-ff", target_sha]
)
if rc_bm == 1:
rc_u, unmerged_out, _ue = _git_run(
["git", "-C", tmp_wt, "diff", "--name-only", "--diff-filter=U"]
)
base_conflicts = (
[ln.strip() for ln in unmerged_out.splitlines() if ln.strip()]
if rc_u == 0 else []
)
if base_conflicts:
return "", {"base_conflicts": base_conflicts}
return "", {"error": base_merge_error or "base merge failed without an inventory"}
if rc_bm != 0:
return "", {"error": base_merge_error or "clean base merge unexpectedly failed"}
# Q8 is unconditional on BOTH lanes: project VERSION + mechanical carrier
# tokens inside the temp worktree before serializing the merge commit, so a
# clean divergence can never ship the fork's version token. Typed failure —
# never a silently half-projected commit.
ok_p, _p_note, p_error = project_version_carriers(target_sha, cwd=tmp_wt)
if not ok_p:
return "", {"error": f"carrier projection failed: {p_error}"}
rc_mt, merged_tree, _mte = _git_run(["git", "-C", tmp_wt, "write-tree"])
if rc_mt != 0 or not merged_tree:
return "", {"error": "could not build merged tree"}
rc_mc, built, commit_error = _git_run([
"git", "commit-tree", merged_tree,
"-p", base_sha, "-p", target_sha,
"-m", f"Merge official Ouroboros update {target_sha[:12]} (auto)",
])
if rc_mc != 0 or not built:
return "", {"error": commit_error or "could not build merge commit"}
return built, None
def plan_managed_update_merge(
fetch: bool = False, branch: Optional[str] = None, build: bool = False
) -> Dict[str, Any]:
"""Dry-run the managed update as a REAL 3-way merge in an ISOLATED temp worktree and
classify the result (P2). NEVER touches the live worktree or index. Returns a
``merge_plan`` dict: available/kind/auto_mergeable, the doc/code conflict labels,
target_sha/base_sha, local_dirty_count, recommended_strategy. Best-effort:
always cleans up the temp index + worktree; classification uses update_merge_policy.
When ``build=True`` AND the merge is clean, the merged tree is committed as a real
merge commit (parents = [reviewed HEAD, target]; a fast-forwardable base lands the
official target itself) whose sha is returned as ``merge_commit`` — a durable
object in the shared DB that survives temp-worktree removal, ready for
``apply_managed_merge_update`` to land on the live repo. Dirty local work is used
only to CLASSIFY conflicts (via the synthetic snapshot); it never enters the built
commit — the apply path stashes and restores it (owner decision Q1=C)."""
import shutil
import tempfile
from ouroboros.update_channels import get_update_channel
from supervisor.update_merge_policy import classify_conflicts
branch_dev = branch or _g.BRANCH_DEV
remote_name, remote_branch, branch_ref = _g._managed_update_target()
update_channel = get_update_channel()
identity = {
"remote": remote_name,
"remote_branch": remote_branch,
"target_ref": branch_ref,
"update_channel": update_channel,
}
rc_b, current_branch, branch_error = _g.git_capture(
["git", "rev-parse", "--abbrev-ref", "HEAD"]
)
if rc_b != 0 or current_branch != branch_dev:
return {
"available": False,
"kind": "unavailable",
"error": branch_error or f"managed update requires local branch {branch_dev}",
"current_branch": current_branch if rc_b == 0 else "unknown",
**identity,
}
if not branch_ref:
return {
"available": False,
"kind": "unavailable",
"error": "no managed update remote",
**identity,
}
if fetch and remote_name:
remote_ok, remote_error = _g.ensure_official_update_remote()
if not remote_ok:
return {
"available": False,
"kind": "unavailable",
"error": remote_error or "could not configure official update remote",
**identity,
}
fetch_rc, _fetch_out, fetch_error = _g.git_fetch_bounded(remote_name)
if fetch_rc != 0:
return {
"available": False,
"kind": "unavailable",
"error": fetch_error or f"git fetch {remote_name} failed",
**identity,
}
target_ref, target_sha, target_error = _g._resolve_managed_update_target(
remote_name, remote_branch, branch_ref, update_channel
)
identity["target_ref"] = target_ref or branch_ref
if not target_ref or not target_sha:
return {
"available": False,
"kind": "unavailable",
"error": target_error or "could not resolve managed update target",
**identity,
}
rc_h, base_sha, head_error = _g.git_capture(
["git", "rev-parse", "--verify", "HEAD"]
)
pins = {"target_sha": target_sha, "base_sha": base_sha, **identity}
if rc_h != 0 or not base_sha:
return {
"available": False,
"kind": "unavailable",
"error": target_error or head_error or "could not resolve target/HEAD",
**pins,
}
if not managed_update_constitution_present(target_sha):
return {
"available": False,
"kind": "unavailable",
"error": "official update target does not preserve BIBLE.md",
**pins,
}
status_rc, dirty_out, status_error = _g.git_capture(["git", "status", "--porcelain"])
if status_rc != 0:
return {
"available": target_sha != base_sha,
"kind": "unknown",
"error": status_error or "git status failed",
**pins,
}
local_dirty_count = len([ln for ln in dirty_out.splitlines() if ln.strip()])
pins["local_dirty_count"] = local_dirty_count
if target_sha == base_sha:
return {"available": False, "kind": "current", **pins}
ancestor_rc, _ancestor_out, ancestor_error = _g.git_capture(
["git", "merge-base", "--is-ancestor", target_sha, base_sha]
)
if ancestor_rc == 0:
return {"available": False, "kind": "current", **pins}
if ancestor_rc not in (0, 1):
return {
"available": False,
"kind": "unknown",
"error": ancestor_error or "could not compare target with HEAD",
**pins,
}
fast_forward_rc, _ff_out, fast_forward_error = _g.git_capture(
["git", "merge-base", "--is-ancestor", base_sha, target_sha]
)
if fast_forward_rc not in (0, 1):
return {
"available": True,
"kind": "unknown",
"error": fast_forward_error or "could not compare HEAD with target",
**pins,
}
if fast_forward_rc == 0 and local_dirty_count == 0:
return {
"available": True,
"kind": "clean",
"auto_mergeable": True,
"doc_conflict_paths": [],
"code_conflict_paths": [],
"hot_code_paths": [],
"local_snapshot": base_sha,
"merge_commit": target_sha if build else "",
"recommended_strategy": "auto_merge",
**pins,
}
tmp_wt = None
try:
# A clean, diverged branch can merge directly from HEAD. A synthetic
# snapshot commit is needed only when it is the sole durable carrier of
# dirty/untracked local work (the informational/preview path; the apply
# path stashes first and re-plans from a clean tree).
local_snapshot = base_sha
if local_dirty_count:
local_tree, snapshot_error = worktree_snapshot_tree("HEAD")
if not local_tree:
return {
"available": True,
"kind": "unknown",
"error": snapshot_error or "worktree snapshot failed",
**pins,
}
rc_ct, local_snapshot, _ce = _git_run(
["git", "commit-tree", local_tree, "-p", base_sha,
"-m", "ouroboros local snapshot (update merge plan)"],
)
if rc_ct != 0 or not local_snapshot:
return {"available": True, "kind": "unknown", "error": "commit-tree failed", **pins}
# 2. Isolated temp worktree at the snapshot; merge the target THERE (never live).
# Use a NON-existent child path (git worktree add refuses an existing dir).
tmp_wt = os.path.join(tempfile.mkdtemp(prefix="ouro-update-wt-"), "wt")
rc_add, _ao, add_err = _g.git_capture(["git", "worktree", "add", "--detach", tmp_wt, local_snapshot])
if rc_add != 0:
return {
"available": True,
"kind": "unknown",
"error": f"worktree add failed: {add_err}",
**pins,
}
# --no-commit --no-ff: leave the merged/conflicted index in place to inspect.
merge_rc, _merge_out, merge_error = _git_run(
["git", *_MERGE_NEUTRAL_FLAGS, "-C", tmp_wt, "merge", "--no-commit", "--no-ff", target_sha]
)
if merge_rc not in (0, 1):
return {
"available": True,
"kind": "unknown",
"error": merge_error or f"git merge failed with exit {merge_rc}",
**pins,
}
rc_u, unmerged_out, unmerged_error = _git_run(
["git", "-C", tmp_wt, "diff", "--name-only", "--diff-filter=U"]
)
if rc_u != 0:
return {
"available": True,
"kind": "unknown",
"error": unmerged_error or "could not inspect merge conflicts",
**pins,
}
unmerged = [ln.strip() for ln in unmerged_out.splitlines() if ln.strip()]
if (merge_rc == 0 and unmerged) or (merge_rc == 1 and not unmerged):
return {
"available": True,
"kind": "unknown",
"error": "git merge result and conflict inventory disagree",
**pins,
}
plan = classify_conflicts(unmerged)
kind = str(plan["kind"])
merge_commit = ""
if build and kind == "clean":
built, failure = _build_clean_merge_commit(
tmp_wt, base_sha, target_sha,
fast_forwardable=(fast_forward_rc == 0),
local_dirty_count=local_dirty_count,
)
if failure is not None:
if failure.get("base_conflicts"):
# Exotic but real (e.g. file/directory collisions): the local
# snapshot merged cleanly while the committed base does not.
# Route to the assisted lane with the BASE conflict inventory
# instead of refusing forever with kind=unknown.
base_plan = classify_conflicts(failure["base_conflicts"])
return {
"available": True,
"kind": base_plan["kind"] if base_plan["kind"] != "clean" else "unknown",
"auto_mergeable": False,
"doc_conflict_paths": base_plan["doc_conflict_paths"],
"code_conflict_paths": base_plan["code_conflict_paths"],
"hot_code_paths": base_plan["hot_code_paths"],
"local_dirty_count": local_dirty_count,
"local_snapshot": local_snapshot,
"merge_commit": "",
"recommended_strategy": "assisted",
**pins,
}
return {"available": True, "kind": "unknown", **pins,
"error": failure.get("error") or "could not build merge commit"}
merge_commit = built
return {
"available": True,
"kind": kind,
"auto_mergeable": kind == "clean",
"doc_conflict_paths": plan["doc_conflict_paths"],
"code_conflict_paths": plan["code_conflict_paths"],
"hot_code_paths": plan["hot_code_paths"],
"local_dirty_count": local_dirty_count,
"local_snapshot": local_snapshot,
"merge_commit": merge_commit,
# Git owns clean merges. Ouroboros is needed only for a real conflict.
"recommended_strategy": "auto_merge" if kind == "clean" else "assisted",
**pins,
}
except Exception as exc: # pragma: no cover — planning is best-effort
_g.log.warning("plan_managed_update_merge failed", exc_info=True)
return {
"available": True,
"kind": "unknown",
"error": f"{type(exc).__name__}: {exc}",
**pins,
}
finally:
if tmp_wt:
_g.git_capture(["git", "worktree", "remove", "--force", tmp_wt])
shutil.rmtree(os.path.dirname(tmp_wt), ignore_errors=True)
_g.git_capture(["git", "worktree", "prune"])
def _update_tx_marker_path():
return _g._git_dir() / UPDATE_TX_MARKER_NAME
@ -592,80 +266,6 @@ def create_rescue_local_ref(local_snapshot: str) -> str:
return ""
def materialize_assisted_merge_live(
branch: str, local_snapshot: str, target_sha: str, pre_update_sha: str
) -> Tuple[bool, str, str]:
"""Stage a REAL ``git merge --no-commit --no-ff target`` into the LIVE worktree (MERGE_HEAD +
a conflicted index + markers) for the agent to resolve and the unmodified ``commit_reviewed``
to finalize as a reviewed 2-parent commit. Caller MUST hold the update lock with workers
stopped. Conflicts make ``git merge`` exit nonzero — that is EXPECTED, not failure: success is
judged by MERGE_HEAD == target_sha. Returns ``(ok, message, m0_tree)`` where ``m0_tree`` is
the pinned MECHANICAL MERGE BASELINE — the just-materialized worktree's tree (conflict
markers as content) captured ONCE, before any resolver edit; reviewers diff m0_tree →
candidate, and a later re-merge (rerere, config drift) is never authority.
Since the stash-first apply order (Q9) ``local_snapshot`` normally equals ``pre_update_sha``
(uncommitted work rides a stash). Legacy transactions whose snapshot still carries dirty
work keep working: the merge is computed FROM ``local_snapshot``, then the first parent is
re-based to ``pre_update_sha`` (the last REVIEWED committed state), so the reviewed diff
includes that work — none of it reaches history as an unreviewed parent."""
if not local_snapshot or not target_sha or not pre_update_sha:
return False, "missing local_snapshot/target_sha/pre_update_sha", ""
# Clean the worktree first (dirty + untracked are all captured in the stash — legacy: in
# local_snapshot — plus the rescue snapshot) so `checkout -B` cannot fail on "untracked file
# would be overwritten". A real 3-way merge needs a clean tree to run.
rc_reset, _ro, reset_error = _g.git_capture(["git", "reset", "--hard", "HEAD"])
if rc_reset != 0:
return False, f"could not clean tracked files before assisted merge: {reset_error}", ""
rc_clean, _co, clean_error = _g.git_capture(["git", "clean", "-fd"])
if rc_clean != 0:
return False, f"could not clean untracked files before assisted merge: {clean_error}", ""
rc_c, _o, e_c = _g.git_capture(["git", "checkout", "-B", branch, local_snapshot])
if rc_c != 0:
return False, f"checkout -B {branch} {local_snapshot[:12]} failed: {e_c}", ""
# Ignore the merge return code; conflicts are expected. Judge by MERGE_HEAD.
rc_m, _mo, merge_error = _g.git_capture(
["git", *_MERGE_NEUTRAL_FLAGS, "merge", "--no-commit", "--no-ff", target_sha]
)
if rc_m not in (0, 1):
return False, f"merge failed before conflict resolution: {merge_error or rc_m}", ""
mh = _merge_head_sha()
if not mh:
return False, "merge produced no MERGE_HEAD (nothing to merge or fatal error)", ""
if mh != target_sha:
return False, f"MERGE_HEAD {mh[:12]} != target {target_sha[:12]}", ""
# Re-base the first parent to the reviewed pre-update state WITHOUT disturbing the merge
# result: `git reset --soft` is refused mid-merge, so move the branch ref directly with
# update-ref (HEAD follows the symbolic ref) — the index (conflicted/merged entries), the
# worktree, and MERGE_HEAD are all untouched, so commit_reviewed still makes a 2-parent
# commit [pre_update_sha, target].
# P9 projection (Q8, shared typed helper): VERSION := target, mechanical
# carrier tokens synced for non-conflicted files. MANDATORY: a failed
# projection aborts materialization — a half-projected tree must never be
# frozen as the M0 baseline (the caller rolls back and the owner retries).
ok_p, projected_note, projection_error = project_version_carriers(target_sha)
if not ok_p:
return False, f"carrier projection failed: {projection_error}", ""
# CAS: expected-old = the snapshot we just checked out; a concurrently moved
# ref (late human commit) must fail the re-parent instead of being clobbered.
rc_r, _ro, e_r = _g.git_capture(
["git", "update-ref", f"refs/heads/{branch}", pre_update_sha, local_snapshot]
)
if rc_r != 0:
return False, f"update-ref {branch} -> {pre_update_sha[:12]} failed: {e_r}", ""
if _merge_head_sha() != target_sha:
return False, "MERGE_HEAD lost after re-parenting the branch", ""
m0_tree, m0_error = worktree_snapshot_tree(pre_update_sha)
if not m0_tree:
return False, f"could not pin the mechanical merge baseline (M0): {m0_error}", ""
return (
True,
f"materialized merge of {target_sha[:12]} (parent={pre_update_sha[:12]}, "
f"MERGE_HEAD set, M0 {m0_tree[:12]}{projected_note})",
m0_tree,
)
def _assisted_head_state(tx: Dict[str, Any]) -> str:
"""Classify the live HEAD vs the assisted tx for boot recovery — keyed on MERGE STATE. During
resolution HEAD == pre_update_sha (the merge result is staged but uncommitted); the reviewed

View file

@ -0,0 +1,490 @@
"""Managed-update merge planning and live materialization (P2), split out of
``supervisor/update_merge.py`` (module-size discipline; the split is re-cut
from the update-flow redesign's two-module form, not the pre-redesign shape).
Owns the isolated temp-worktree dry-run planner, the durable clean-plan merge
commit builder, and the live assisted-merge materializer — the three insertion
points of the carrier-aware span resolver (``supervisor/update_carriers.py``,
owner-ratified spec §1.9-10 / v7next answers 5.12-5.14=A). The parent keeps the
tx marker, lock, rollback and boot-recovery primitives and re-exports every
name here, so ``supervisor.update_merge`` stays the one public surface.
Binding discipline (the module-handle rule the git_ops split pinned): candidate
primitives are read through the ``supervisor.update_candidate`` module object
(``_uc.X``) and the parent's own members through the call-time ``_um()`` handle
— never from-imports, which would freeze the binding this module saw at import
time and silently kill the test surface that monkeypatches those names on their
owner modules (e.g. ``update_merge.managed_update_constitution_present``,
``update_candidate.worktree_snapshot_tree``)."""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional, Tuple
from supervisor import git_ops as _g
from supervisor import update_candidate as _uc
from supervisor.update_carriers import resolve_carrier_conflicts
def _um():
"""The parent module, read at call time.
``supervisor.update_merge`` owns ``managed_update_constitution_present``
and tests monkeypatch it on the parent. Reading it through the module keeps
one binding; a from-import here would freeze the value this module saw at
import time."""
from supervisor import update_merge
return update_merge
def _build_clean_merge_commit(
tmp_wt: str,
base_sha: str,
target_sha: str,
*,
fast_forwardable: bool,
local_dirty_count: int,
) -> Tuple[str, Optional[Dict[str, Any]]]:
"""Build the durable merge commit for a CLEAN plan inside the temp worktree.
Owner decision (2026-08, Q1=C): local dirty work NEVER enters committed
history on a clean auto-update. The commit merges the reviewed HEAD (base)
and the official target only; the apply path stashes dirty work and
restores it as uncommitted content after the update. clean(snapshot,
target) implies clean(base, target) for ordinary hunk overlaps, but
file/directory-type collisions CAN break the implication — a conflicting
base re-merge is returned as ``{"base_conflicts": [...]}`` so the caller
routes it to the assisted lane. Returns (merge_commit, failure|None)."""
if local_dirty_count:
if fast_forwardable:
# Base is an ancestor of the target: pure official history,
# no merge commit needed at all.
return target_sha, None
rc_r, _ro, reset_error = _uc._git_run(["git", "-C", tmp_wt, "reset", "--hard", base_sha])
if rc_r != 0:
return "", {"error": reset_error or "could not reset plan worktree to base"}
rc_bm, _bo, base_merge_error = _uc._git_run(
["git", *_uc._MERGE_NEUTRAL_FLAGS, "-C", tmp_wt, "merge", "--no-commit", "--no-ff", target_sha]
)
if rc_bm == 1:
rc_u, unmerged_out, _ue = _uc._git_run(
["git", "-C", tmp_wt, "diff", "--name-only", "--diff-filter=U"]
)
base_conflicts = (
[ln.strip() for ln in unmerged_out.splitlines() if ln.strip()]
if rc_u == 0 else []
)
if not base_conflicts:
return "", {"error": base_merge_error or "base merge failed without an inventory"}
# Carrier engine insertion point 2 of 3 (spec §1.9-10, owner batch
# №8 answer 6=A): the base re-merge, applied BEFORE write-tree. A
# base conflict confined to declared version-carrier spans adopts
# the official side of the span and stays on the clean path;
# anything else routes to the assisted lane exactly as before.
resolution = resolve_carrier_conflicts(tmp_wt, base_conflicts, prefer="theirs")
carrier_resolved = set(resolution["resolved"])
remaining = [path for path in base_conflicts if path not in carrier_resolved]
if remaining:
return "", {"base_conflicts": remaining}
elif rc_bm != 0:
return "", {"error": base_merge_error or "clean base merge unexpectedly failed"}
# Q8 is unconditional on BOTH lanes: project VERSION + mechanical carrier
# tokens inside the temp worktree before serializing the merge commit, so a
# clean divergence can never ship the fork's version token. Typed failure —
# never a silently half-projected commit.
ok_p, _p_note, p_error = _uc.project_version_carriers(target_sha, cwd=tmp_wt)
if not ok_p:
return "", {"error": f"carrier projection failed: {p_error}"}
rc_mt, merged_tree, _mte = _uc._git_run(["git", "-C", tmp_wt, "write-tree"])
if rc_mt != 0 or not merged_tree:
return "", {"error": "could not build merged tree"}
rc_mc, built, commit_error = _uc._git_run([
"git", "commit-tree", merged_tree,
"-p", base_sha, "-p", target_sha,
"-m", f"Merge official Ouroboros update {target_sha[:12]} (auto)",
])
if rc_mc != 0 or not built:
return "", {"error": commit_error or "could not build merge commit"}
return built, None
def plan_managed_update_merge(
fetch: bool = False, branch: Optional[str] = None, build: bool = False
) -> Dict[str, Any]:
"""Dry-run the managed update as a REAL 3-way merge in an ISOLATED temp worktree and
classify the result (P2). NEVER touches the live worktree or index. Returns a
``merge_plan`` dict: available/kind/auto_mergeable, the doc/code conflict labels,
target_sha/base_sha, local_dirty_count, recommended_strategy. Best-effort:
always cleans up the temp index + worktree; classification uses update_merge_policy.
When ``build=True`` AND the merge is clean, the merged tree is committed as a real
merge commit (parents = [reviewed HEAD, target]; a fast-forwardable base lands the
official target itself) whose sha is returned as ``merge_commit`` — a durable
object in the shared DB that survives temp-worktree removal, ready for
``apply_managed_merge_update`` to land on the live repo. Dirty local work is used
only to CLASSIFY conflicts (via the synthetic snapshot); it never enters the built
commit — the apply path stashes and restores it (owner decision Q1=C)."""
import shutil
import tempfile
from ouroboros.update_channels import get_update_channel
from supervisor.update_merge_policy import classify_conflicts
branch_dev = branch or _g.BRANCH_DEV
remote_name, remote_branch, branch_ref = _g._managed_update_target()
update_channel = get_update_channel()
identity = {
"remote": remote_name,
"remote_branch": remote_branch,
"target_ref": branch_ref,
"update_channel": update_channel,
}
rc_b, current_branch, branch_error = _g.git_capture(
["git", "rev-parse", "--abbrev-ref", "HEAD"]
)
if rc_b != 0 or current_branch != branch_dev:
return {
"available": False,
"kind": "unavailable",
"error": branch_error or f"managed update requires local branch {branch_dev}",
"current_branch": current_branch if rc_b == 0 else "unknown",
**identity,
}
if not branch_ref:
return {
"available": False,
"kind": "unavailable",
"error": "no managed update remote",
**identity,
}
if fetch and remote_name:
remote_ok, remote_error = _g.ensure_official_update_remote()
if not remote_ok:
return {
"available": False,
"kind": "unavailable",
"error": remote_error or "could not configure official update remote",
**identity,
}
fetch_rc, _fetch_out, fetch_error = _g.git_fetch_bounded(remote_name)
if fetch_rc != 0:
return {
"available": False,
"kind": "unavailable",
"error": fetch_error or f"git fetch {remote_name} failed",
**identity,
}
target_ref, target_sha, target_error = _g._resolve_managed_update_target(
remote_name, remote_branch, branch_ref, update_channel
)
identity["target_ref"] = target_ref or branch_ref
if not target_ref or not target_sha:
return {
"available": False,
"kind": "unavailable",
"error": target_error or "could not resolve managed update target",
**identity,
}
rc_h, base_sha, head_error = _g.git_capture(
["git", "rev-parse", "--verify", "HEAD"]
)
pins = {"target_sha": target_sha, "base_sha": base_sha, **identity}
if rc_h != 0 or not base_sha:
return {
"available": False,
"kind": "unavailable",
"error": target_error or head_error or "could not resolve target/HEAD",
**pins,
}
if not _um().managed_update_constitution_present(target_sha):
return {
"available": False,
"kind": "unavailable",
"error": "official update target does not preserve BIBLE.md",
**pins,
}
status_rc, dirty_out, status_error = _g.git_capture(["git", "status", "--porcelain"])
if status_rc != 0:
return {
"available": target_sha != base_sha,
"kind": "unknown",
"error": status_error or "git status failed",
**pins,
}
local_dirty_count = len([ln for ln in dirty_out.splitlines() if ln.strip()])
pins["local_dirty_count"] = local_dirty_count
if target_sha == base_sha:
return {"available": False, "kind": "current", **pins}
ancestor_rc, _ancestor_out, ancestor_error = _g.git_capture(
["git", "merge-base", "--is-ancestor", target_sha, base_sha]
)
if ancestor_rc == 0:
return {"available": False, "kind": "current", **pins}
if ancestor_rc not in (0, 1):
return {
"available": False,
"kind": "unknown",
"error": ancestor_error or "could not compare target with HEAD",
**pins,
}
fast_forward_rc, _ff_out, fast_forward_error = _g.git_capture(
["git", "merge-base", "--is-ancestor", base_sha, target_sha]
)
if fast_forward_rc not in (0, 1):
return {
"available": True,
"kind": "unknown",
"error": fast_forward_error or "could not compare HEAD with target",
**pins,
}
if fast_forward_rc == 0 and local_dirty_count == 0:
return {
"available": True,
"kind": "clean",
"auto_mergeable": True,
"doc_conflict_paths": [],
"code_conflict_paths": [],
"hot_code_paths": [],
"local_snapshot": base_sha,
"merge_commit": target_sha if build else "",
"carrier_resolved_paths": [],
"recommended_strategy": "auto_merge",
**pins,
}
tmp_wt = None
try:
# A clean, diverged branch can merge directly from HEAD. A synthetic
# snapshot commit is needed only when it is the sole durable carrier of
# dirty/untracked local work (the informational/preview path; the apply
# path stashes first and re-plans from a clean tree).
local_snapshot = base_sha
if local_dirty_count:
local_tree, snapshot_error = _uc.worktree_snapshot_tree("HEAD")
if not local_tree:
return {
"available": True,
"kind": "unknown",
"error": snapshot_error or "worktree snapshot failed",
**pins,
}
rc_ct, local_snapshot, _ce = _uc._git_run(
["git", "commit-tree", local_tree, "-p", base_sha,
"-m", "ouroboros local snapshot (update merge plan)"],
)
if rc_ct != 0 or not local_snapshot:
return {"available": True, "kind": "unknown", "error": "commit-tree failed", **pins}
# 2. Isolated temp worktree at the snapshot; merge the target THERE (never live).
# Use a NON-existent child path (git worktree add refuses an existing dir).
tmp_wt = os.path.join(tempfile.mkdtemp(prefix="ouro-update-wt-"), "wt")
rc_add, _ao, add_err = _g.git_capture(["git", "worktree", "add", "--detach", tmp_wt, local_snapshot])
if rc_add != 0:
return {
"available": True,
"kind": "unknown",
"error": f"worktree add failed: {add_err}",
**pins,
}
# --no-commit --no-ff: leave the merged/conflicted index in place to inspect.
merge_rc, _merge_out, merge_error = _uc._git_run(
["git", *_uc._MERGE_NEUTRAL_FLAGS, "-C", tmp_wt, "merge", "--no-commit", "--no-ff", target_sha]
)
if merge_rc not in (0, 1):
return {
"available": True,
"kind": "unknown",
"error": merge_error or f"git merge failed with exit {merge_rc}",
**pins,
}
rc_u, unmerged_out, unmerged_error = _uc._git_run(
["git", "-C", tmp_wt, "diff", "--name-only", "--diff-filter=U"]
)
if rc_u != 0:
return {
"available": True,
"kind": "unknown",
"error": unmerged_error or "could not inspect merge conflicts",
**pins,
}
unmerged = [ln.strip() for ln in unmerged_out.splitlines() if ln.strip()]
if (merge_rc == 0 and unmerged) or (merge_rc == 1 and not unmerged):
return {
"available": True,
"kind": "unknown",
"error": "git merge result and conflict inventory disagree",
**pins,
}
# Carrier engine insertion point 1 of 3 (spec §1.9-10, owner batch №8
# answer 6=A): the planner merge, applied BEFORE classification and
# write-tree — the same body serves the preview plan AND the stash-first
# authoritative build=True replan. Conflicts confined to declared
# version-carrier spans adopt the official side of the span (staged in
# the ISOLATED temp worktree) and leave the plan's conflict inventory;
# every other conflict classifies exactly as before.
carrier_resolved: List[str] = []
if unmerged:
resolution = resolve_carrier_conflicts(tmp_wt, unmerged, prefer="theirs")
carrier_resolved = list(resolution["resolved"])
if carrier_resolved:
unmerged = [path for path in unmerged if path not in set(carrier_resolved)]
plan = classify_conflicts(unmerged)
kind = str(plan["kind"])
merge_commit = ""
if build and kind == "clean":
built, failure = _build_clean_merge_commit(
tmp_wt, base_sha, target_sha,
fast_forwardable=(fast_forward_rc == 0),
local_dirty_count=local_dirty_count,
)
if failure is not None:
if failure.get("base_conflicts"):
# Exotic but real (e.g. file/directory collisions): the local
# snapshot merged cleanly while the committed base does not.
# Route to the assisted lane with the BASE conflict inventory
# instead of refusing forever with kind=unknown.
base_plan = classify_conflicts(failure["base_conflicts"])
return {
"available": True,
"kind": base_plan["kind"] if base_plan["kind"] != "clean" else "unknown",
"auto_mergeable": False,
"doc_conflict_paths": base_plan["doc_conflict_paths"],
"code_conflict_paths": base_plan["code_conflict_paths"],
"hot_code_paths": base_plan["hot_code_paths"],
"local_dirty_count": local_dirty_count,
"local_snapshot": local_snapshot,
"merge_commit": "",
"carrier_resolved_paths": carrier_resolved,
"recommended_strategy": "assisted",
**pins,
}
return {"available": True, "kind": "unknown", **pins,
"error": failure.get("error") or "could not build merge commit"}
merge_commit = built
return {
"available": True,
"kind": kind,
"auto_mergeable": kind == "clean",
"doc_conflict_paths": plan["doc_conflict_paths"],
"code_conflict_paths": plan["code_conflict_paths"],
"hot_code_paths": plan["hot_code_paths"],
"local_dirty_count": local_dirty_count,
"local_snapshot": local_snapshot,
"merge_commit": merge_commit,
"carrier_resolved_paths": carrier_resolved,
# Git owns clean merges. Ouroboros is needed only for a real conflict.
"recommended_strategy": "auto_merge" if kind == "clean" else "assisted",
**pins,
}
except Exception as exc: # pragma: no cover — planning is best-effort
_g.log.warning("plan_managed_update_merge failed", exc_info=True)
return {
"available": True,
"kind": "unknown",
"error": f"{type(exc).__name__}: {exc}",
**pins,
}
finally:
if tmp_wt:
_g.git_capture(["git", "worktree", "remove", "--force", tmp_wt])
shutil.rmtree(os.path.dirname(tmp_wt), ignore_errors=True)
_g.git_capture(["git", "worktree", "prune"])
def materialize_assisted_merge_live(
branch: str, local_snapshot: str, target_sha: str, pre_update_sha: str
) -> Tuple[bool, str, str]:
"""Stage a REAL ``git merge --no-commit --no-ff target`` into the LIVE worktree (MERGE_HEAD +
a conflicted index + markers) for the agent to resolve and the unmodified ``commit_reviewed``
to finalize as a reviewed 2-parent commit. Caller MUST hold the update lock with workers
stopped. Conflicts make ``git merge`` exit nonzero — that is EXPECTED, not failure: success is
judged by MERGE_HEAD == target_sha. Returns ``(ok, message, m0_tree)`` where ``m0_tree`` is
the pinned MECHANICAL MERGE BASELINE — the just-materialized worktree's tree (conflict
markers as content) captured ONCE, before any resolver edit; reviewers diff m0_tree →
candidate, and a later re-merge (rerere, config drift) is never authority. The carrier-span
resolution below is applied BEFORE the M0 pin (owner decision Ф-2=A): span policy is part
of the mechanical baseline, so the resolver and reviewers only face real conflicts.
Since the stash-first apply order (Q9) ``local_snapshot`` normally equals ``pre_update_sha``
(uncommitted work rides a stash). Legacy transactions whose snapshot still carries dirty
work keep working: the merge is computed FROM ``local_snapshot``, then the first parent is
re-based to ``pre_update_sha`` (the last REVIEWED committed state), so the reviewed diff
includes that work — none of it reaches history as an unreviewed parent."""
if not local_snapshot or not target_sha or not pre_update_sha:
return False, "missing local_snapshot/target_sha/pre_update_sha", ""
# Clean the worktree first (dirty + untracked are all captured in the stash — legacy: in
# local_snapshot — plus the rescue snapshot) so `checkout -B` cannot fail on "untracked file
# would be overwritten". A real 3-way merge needs a clean tree to run.
rc_reset, _ro, reset_error = _g.git_capture(["git", "reset", "--hard", "HEAD"])
if rc_reset != 0:
return False, f"could not clean tracked files before assisted merge: {reset_error}", ""
rc_clean, _co, clean_error = _g.git_capture(["git", "clean", "-fd"])
if rc_clean != 0:
return False, f"could not clean untracked files before assisted merge: {clean_error}", ""
rc_c, _o, e_c = _g.git_capture(["git", "checkout", "-B", branch, local_snapshot])
if rc_c != 0:
return False, f"checkout -B {branch} {local_snapshot[:12]} failed: {e_c}", ""
# Ignore the merge return code; conflicts are expected. Judge by MERGE_HEAD.
rc_m, _mo, merge_error = _g.git_capture(
["git", *_uc._MERGE_NEUTRAL_FLAGS, "merge", "--no-commit", "--no-ff", target_sha]
)
if rc_m not in (0, 1):
return False, f"merge failed before conflict resolution: {merge_error or rc_m}", ""
mh = _uc._merge_head_sha()
if not mh:
return False, "merge produced no MERGE_HEAD (nothing to merge or fatal error)", ""
if mh != target_sha:
return False, f"MERGE_HEAD {mh[:12]} != target {target_sha[:12]}", ""
# Carrier engine insertion point 3 of 3 (spec §1.9-10, owner batch №8
# answer 6=A): the live materializer, applied BEFORE the Q8 projection and
# the M0 pin (Ф-2=A). Version-carrier spans in the staged merge adopt the
# official side so the assisted resolver only faces real conflicts;
# best-effort — whatever stays unresolved remains for the assisted lane
# exactly as before.
rc_cu, carrier_unmerged_out, _cue = _g.git_capture(
["git", "diff", "--name-only", "--diff-filter=U"]
)
if rc_cu == 0:
carrier_conflicted = [
ln.strip() for ln in carrier_unmerged_out.splitlines() if ln.strip()
]
if carrier_conflicted:
resolve_carrier_conflicts(
str(_g.REPO_DIR), carrier_conflicted, prefer="theirs"
)
# Re-base the first parent to the reviewed pre-update state WITHOUT disturbing the merge
# result: `git reset --soft` is refused mid-merge, so move the branch ref directly with
# update-ref (HEAD follows the symbolic ref) — the index (conflicted/merged entries), the
# worktree, and MERGE_HEAD are all untouched, so commit_reviewed still makes a 2-parent
# commit [pre_update_sha, target].
# P9 projection (Q8, shared typed helper): VERSION := target, mechanical
# carrier tokens synced for non-conflicted files. MANDATORY: a failed
# projection aborts materialization — a half-projected tree must never be
# frozen as the M0 baseline (the caller rolls back and the owner retries).
ok_p, projected_note, projection_error = _uc.project_version_carriers(target_sha)
if not ok_p:
return False, f"carrier projection failed: {projection_error}", ""
# CAS: expected-old = the snapshot we just checked out; a concurrently moved
# ref (late human commit) must fail the re-parent instead of being clobbered.
rc_r, _ro, e_r = _g.git_capture(
["git", "update-ref", f"refs/heads/{branch}", pre_update_sha, local_snapshot]
)
if rc_r != 0:
return False, f"update-ref {branch} -> {pre_update_sha[:12]} failed: {e_r}", ""
if _uc._merge_head_sha() != target_sha:
return False, "MERGE_HEAD lost after re-parenting the branch", ""
m0_tree, m0_error = _uc.worktree_snapshot_tree(pre_update_sha)
if not m0_tree:
return False, f"could not pin the mechanical merge baseline (M0): {m0_error}", ""
return (
True,
f"materialized merge of {target_sha[:12]} (parent={pre_update_sha[:12]}, "
f"MERGE_HEAD set, M0 {m0_tree[:12]}{projected_note})",
m0_tree,
)

View file

@ -111,24 +111,29 @@ def rescue_pointer_note(tx: Dict[str, Any]) -> str:
)
VERSION_CARRIER_PATHS = frozenset({
"VERSION", "pyproject.toml", "README.md", "docs/ARCHITECTURE.md",
"web/package.json", "web/modules/api_types.js",
})
def carrier_guidance(conflicts: List[str]) -> str:
"""Version-carrier guidance for the resolver (owner decisions Q8/Q24): the landed
update carries the TARGET's version; prose and history stay the fork's own."""
if not any(_norm(path) in VERSION_CARRIER_PATHS for path in conflicts):
update carries the TARGET's version; prose and history stay the fork's own.
The carrier inventory is the span SSOT (``release_sync.CARRIER_SPAN_PATHS``,
imported at call time — presentation only, no policy). Conflicts confined to
declared spans are resolved mechanically before the resolver task is built
(supervisor/update_carriers.py), so a carrier still in *conflicts* DEGRADED
to manual resolution and the guidance names what that means."""
from ouroboros.tools.release_sync import CARRIER_SPAN_PATHS
if not any(_norm(path) in CARRIER_SPAN_PATHS for path in conflicts):
return ""
return (
" Version carriers: the update lands under the official target's version — VERSION is "
"already projected and every NON-conflicted carrier token (pyproject.toml, "
"already projected, every NON-conflicted carrier token (pyproject.toml, "
"web/package.json, the README badge, the docs/ARCHITECTURE.md header, install pages) is "
"already synced mechanically. In carriers you resolve yourself, make version tokens match "
"VERSION exactly. In the README Version History table keep BOTH sides' rows (never delete "
"this fork's local history rows); resolve prose conflicts on their merits."
"already synced mechanically, and carrier conflicts confined to declared version spans "
"were already resolved to the target's side. A carrier still in your list degraded to "
"manual resolution (a broken or duplicate span anchor, or a conflict outside the spans): "
"make its version tokens match VERSION exactly. In the README Version History table keep "
"BOTH sides' rows (never delete this fork's local history rows); resolve prose conflicts "
"on their merits."
)

View file

@ -0,0 +1,110 @@
"""Unit test for scripts/carrier_rebase_helper.py — the tactical-rebase side
of the carrier engine (spec §1.9-10): span-substitution 'ours' for the
declared version carriers, ordinary 3-way for everything else, untouched
non-carrier conflicts, and honest exit codes.
The helper reads only the unmerged index stages, so a real `git merge`
conflict stands in for the rebase stop: during a rebase, stage 2 ('ours') is
the side being rebased ONTO, which is exactly the side the default preference
keeps inside the spans.
"""
import pathlib
import subprocess
import sys
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
HELPER = REPO_ROOT / "scripts" / "carrier_rebase_helper.py"
def _git(repo, *args):
return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True)
def _conflicted_repo(tmp_path, *, break_ours_anchor=False):
"""ours = upstream at 7.0.1 (+ a code edit), theirs = replayed work at 7.1.0."""
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q")
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "t")
_git(repo, "config", "commit.gpgsign", "false")
(repo / "VERSION").write_text("7.0.0\n")
(repo / "a.txt").write_text("base\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "base 7.0.0")
_git(repo, "checkout", "-q", "-b", "replayed")
(repo / "VERSION").write_text("7.1.0\n")
(repo / "a.txt").write_text("replayed code\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "replayed 7.1.0")
_git(repo, "checkout", "-q", "-")
(repo / "VERSION").write_text(
"broken anchor\n" if break_ours_anchor else "7.0.1\n"
)
(repo / "a.txt").write_text("upstream code\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "upstream 7.0.1")
merge = _git(repo, "merge", "--no-commit", "--no-ff", "replayed")
assert merge.returncode == 1, merge.stderr # both files conflict
return repo
def _run_helper(repo, *extra):
return subprocess.run(
[sys.executable, str(HELPER), "--worktree", str(repo), *extra],
capture_output=True, text=True,
)
def test_helper_keeps_ours_inside_the_span_and_leaves_the_rest(tmp_path):
repo = _conflicted_repo(tmp_path)
result = _run_helper(repo)
assert result.returncode == 0, result.stderr or result.stdout
assert "VERSION" in result.stdout
unmerged = _git(repo, "diff", "--name-only", "--diff-filter=U").stdout.split()
assert "VERSION" not in unmerged # resolved and staged
assert "a.txt" in unmerged # non-carrier conflict untouched
assert (repo / "VERSION").read_text() == "7.0.1\n" # 'ours' won the span
assert "not carrier files" in result.stdout and "a.txt" in result.stdout
def test_helper_prefer_theirs_flips_the_span_side(tmp_path):
repo = _conflicted_repo(tmp_path)
result = _run_helper(repo, "--prefer", "theirs")
assert result.returncode == 0, result.stderr or result.stdout
assert (repo / "VERSION").read_text() == "7.1.0\n"
def test_helper_degrades_a_broken_anchor_and_reports_failure(tmp_path):
repo = _conflicted_repo(tmp_path, break_ours_anchor=True)
result = _run_helper(repo)
assert result.returncode == 1, result.stderr or result.stdout
assert "left for manual resolution: VERSION" in result.stdout
unmerged = _git(repo, "diff", "--name-only", "--diff-filter=U").stdout.split()
assert "VERSION" in unmerged # untouched, exactly as git left it
body = (repo / "VERSION").read_text()
assert "<<<<<<<" in body and ">>>>>>>" in body
def test_helper_is_quiet_on_a_clean_tree(tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q")
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "t")
_git(repo, "config", "commit.gpgsign", "false")
(repo / "VERSION").write_text("7.0.0\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "base")
result = _run_helper(repo)
assert result.returncode == 0, result.stderr or result.stdout
assert "nothing to do" in result.stdout

View file

@ -67,6 +67,10 @@ _POPEN_ALLOWLIST = {
# v7 G1 split: sync_runtime_dependencies (the waited + panic-tracked pip
# child) moved into the checkout/reset leaf with its custody unchanged.
"supervisor/git_ops_reset.py",
# D34 carrier engine: short-lived bounded git plumbing (waited, own process
# group, whole-tree kill on timeout); kept self-contained so the standalone
# operator rebase helper can import the module without the runtime stack.
"supervisor/update_carriers.py",
"ouroboros/colab_bootstrap.py", # bounded Colab clone/fetch helper
}

View file

@ -0,0 +1,481 @@
"""Synthetic corpus for the carrier-aware update engine (spec §1.9-10, owner
batch №8 answer 6=A; mandatory v7next return, owner answers 5.12-5.14=A).
The mandatory matrix: a carrier-span conflict resolves by policy (official
side wins INSIDE the span only) and NEVER routes the update to the assisted
lane; a non-carrier conflict in the same file REMAINS a conflict; a malformed
anchor and a duplicate anchor each degrade to the ordinary assisted path;
rollback, crash and dirty-tree cases; and the honest frame that the FIRST
pre-v7 upgrade is driven by the OLD updater — documented and pinned, never
simulated. All merge corpus cases are steady-state (a 7.0.0 tree updating to
an official 7.0.1) because that is the population the ratified policy targets.
Re-proven against the update-flow redesign: the planner runs the mandatory Q8
projection + postcondition after span resolution, and the live materializer
resolves spans BEFORE the M0 baseline pin (owner decision Ф-2=A)."""
import pathlib
import subprocess
import supervisor.git_ops as git_ops
import supervisor.update_carriers as update_carriers
import supervisor.update_merge as update_merge
import supervisor.update_merge_plan as update_merge_plan
from ouroboros.tools.release_sync import (
VERSION_CARRIER_SPANS,
carrier_spans_for,
locate_carrier_span,
)
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
CARRIER_FILES = (
"VERSION",
"pyproject.toml",
"web/package.json",
"web/modules/api_types.js",
"README.md",
"docs/ARCHITECTURE.md",
"uv.lock",
)
def _git(repo, *args):
return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True)
def _history_rows(*versions):
return "".join(f"| {v} | 2026-08-18 | release {v}. |\n" for v in versions)
def _download_refs(version):
"""The direct-download reference block of the tip README carrier shape.
All proof ids are present with canonical URLs: the Q8 projection's
postcondition (version_carrier_desyncs) checks every RELEASE_ASSET_TEMPLATES
member once a README opts into the projection, and the readme_download_refs
span requires the block to anchor."""
from ouroboros.tools.release_sync import (
RELEASE_ASSET_TEMPLATES,
release_asset_download_url,
)
return "".join(
f"[download-{proof_id}]: {release_asset_download_url(proof_id, version)}\n"
for proof_id in RELEASE_ASSET_TEMPLATES
)
def _write_carriers(repo, version, *, history=("7.0.0", "6.104.0"), intro="Intro line.\n"):
(repo / "VERSION").write_text(f"{version}\n")
(repo / "pyproject.toml").write_text(
'[project]\nname = "ouroboros"\n'
f'version = "{version}"\n'
'description = "self-modifying agent"\n'
)
(repo / "web").mkdir(exist_ok=True)
(repo / "web" / "package.json").write_text(
'{\n "name": "ouroboros-web",\n'
f' "version": "{version}",\n'
' "private": true\n}\n'
)
(repo / "web" / "modules").mkdir(exist_ok=True)
(repo / "web" / "modules" / "api_types.js").write_text(
f"export const GATEWAY_CONTRACT_VERSION = '{version}';\n"
"export const OTHER = 1;\n"
)
(repo / "README.md").write_text(
"# Ouroboros\n\n"
f"[![Version {version}](https://img.shields.io/badge/version-{version}-green.svg)](VERSION)\n\n"
f"{intro}\n"
"## Version History\n\n"
"| Version | Date | Description |\n"
"|---------|------|-------------|\n"
+ _history_rows(*history)
+ "\n"
+ _download_refs(version)
)
(repo / "docs").mkdir(exist_ok=True)
# encoding pinned: the header carries an em dash, and Windows' locale codec
# (cp1252) would otherwise commit non-utf-8 bytes that the carrier-span
# resolver cannot decode — the file then stays a conflict instead of clean.
(repo / "docs" / "ARCHITECTURE.md").write_text(
f"# Ouroboros v{version} — Architecture & Reference\n\nArchitecture body.\n",
encoding="utf-8",
)
(repo / "uv.lock").write_text(
'version = 1\n\n[[package]]\nname = "ouroboros"\n'
f'version = "{version}"\nsource = {{ editable = "." }}\n\n'
'[[package]]\nname = "httpx"\nversion = "0.27.0"\n'
)
def _init_carrier_repo(tmp_path):
"""A synthetic 7.0.0 tree carrying all 7 release carriers plus code."""
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q")
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "t")
_git(repo, "config", "commit.gpgsign", "false")
(repo / "BIBLE.md").write_text("constitution\n")
(repo / "a.txt").write_text("base\n")
_write_carriers(repo, "7.0.0")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "v7.0.0 baseline")
head = _git(repo, "symbolic-ref", "--short", "HEAD").stdout.strip()
return repo, head
def _official_bump(repo, head, version="7.0.1", *, extra=None):
"""The official target: every carrier bumped, history row prepended."""
_git(repo, "checkout", "-q", "-b", "remote-sim")
_write_carriers(repo, version, history=(version, "7.0.0", "6.104.0"))
if extra:
extra(repo)
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", f"official {version}")
_git(repo, "checkout", "-q", head)
def _local_bump(repo, version="7.1.0", *, intro="Intro line.\n", extra=None):
"""A committed local self-modification bumping the same carrier spans."""
_write_carriers(repo, version, history=(version, "7.0.0", "6.104.0"), intro=intro)
if extra:
extra(repo)
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", f"local {version}")
def _point_at(monkeypatch, tmp_path, repo, head):
monkeypatch.setattr(git_ops, "REPO_DIR", repo)
monkeypatch.setattr(git_ops, "BRANCH_DEV", head)
monkeypatch.setattr(git_ops, "DRIVE_ROOT", tmp_path / "data")
monkeypatch.setattr(git_ops, "_git_dir", lambda: repo / ".git")
monkeypatch.setattr(
git_ops, "_managed_update_target", lambda branch=None: ("", "", "remote-sim")
)
monkeypatch.setattr(
git_ops,
"_resolve_managed_update_target",
lambda *_args: (
"remote-sim",
_git(repo, "rev-parse", "remote-sim").stdout.strip(),
"",
),
)
(tmp_path / "data" / "logs").mkdir(parents=True, exist_ok=True)
# --- matrix case 1: a carrier-span conflict resolves by policy ---------------
def test_planner_resolves_carrier_span_conflict_to_the_official_side(tmp_path, monkeypatch):
"""Steady-state 7.0.0 -> 7.0.1: local bumped its carriers to 7.1.0, the
official target to 7.0.1 — every carrier file conflicts inside its spans
only, so the plan is CLEAN (a conflicted carrier never routes the update
to the assisted lane), the built merge adopts the official spans, and the
local non-span README edit survives (never whole-file theirs)."""
repo, head = _init_carrier_repo(tmp_path)
_official_bump(repo, head, "7.0.1")
_local_bump(repo, "7.1.0", intro="Locally rewritten intro.\n")
_point_at(monkeypatch, tmp_path, repo, head)
plan = update_merge.plan_managed_update_merge(fetch=False, build=True)
assert plan["kind"] == "clean", plan
assert plan["auto_mergeable"] is True
assert plan["recommended_strategy"] == "auto_merge"
assert sorted(plan["carrier_resolved_paths"]) == sorted(CARRIER_FILES)
assert plan["merge_commit"], plan
ok, message = update_merge.apply_managed_merge_update(head, plan["merge_commit"])
assert ok, message
assert (repo / "VERSION").read_text() == "7.0.1\n"
assert 'version = "7.0.1"' in (repo / "pyproject.toml").read_text()
assert '"version": "7.0.1"' in (repo / "web" / "package.json").read_text()
assert "GATEWAY_CONTRACT_VERSION = '7.0.1'" in (
repo / "web" / "modules" / "api_types.js"
).read_text()
readme = (repo / "README.md").read_text()
assert "version-7.0.1-green" in readme
assert "| 7.0.1 |" in readme and "| 7.1.0 |" not in readme
assert "# Ouroboros v7.0.1" in (repo / "docs" / "ARCHITECTURE.md").read_text()
# Never whole-file theirs: the local NON-span edit survived the update.
assert "Locally rewritten intro." in readme
# A real 2-parent merge commit landed (reviewed base first, official second).
parents = _git(repo, "rev-list", "--parents", "-n", "1", "HEAD").stdout.split()
assert len(parents) == 3
# --- matrix case 2: a non-carrier conflict in the same file stays a conflict -
def test_non_carrier_conflict_in_the_same_file_stays_a_conflict(tmp_path, monkeypatch):
repo, head = _init_carrier_repo(tmp_path)
def official_description(r):
text = (r / "pyproject.toml").read_text()
(r / "pyproject.toml").write_text(
text.replace('description = "self-modifying agent"',
'description = "official rewrite"')
)
def local_description(r):
text = (r / "pyproject.toml").read_text()
(r / "pyproject.toml").write_text(
text.replace('description = "self-modifying agent"',
'description = "local rewrite"')
)
_official_bump(repo, head, "7.0.1", extra=official_description)
_local_bump(repo, "7.1.0", extra=local_description)
_point_at(monkeypatch, tmp_path, repo, head)
plan = update_merge.plan_managed_update_merge(fetch=False)
assert plan["kind"] == "conflicting", plan
assert "pyproject.toml" in plan["code_conflict_paths"]
assert "pyproject.toml" not in plan["carrier_resolved_paths"]
# The other carrier files, conflicted only inside their spans, DID resolve.
assert "VERSION" in plan["carrier_resolved_paths"]
assert plan["recommended_strategy"] == "assisted"
# --- matrix cases 3 + 4: malformed / duplicate anchors degrade to assisted ---
def test_malformed_anchor_degrades_to_assisted(tmp_path, monkeypatch):
repo, head = _init_carrier_repo(tmp_path)
_official_bump(repo, head, "7.0.1")
_local_bump(repo, "7.1.0")
(repo / "VERSION").write_text("not-a-version\n") # anchor destroyed locally
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "malformed local VERSION")
_point_at(monkeypatch, tmp_path, repo, head)
plan = update_merge.plan_managed_update_merge(fetch=False)
assert plan["kind"] == "conflicting", plan
assert "VERSION" in plan["code_conflict_paths"]
assert "VERSION" not in plan["carrier_resolved_paths"]
def test_duplicate_anchor_degrades_to_assisted(tmp_path, monkeypatch):
repo, head = _init_carrier_repo(tmp_path)
_official_bump(repo, head, "7.0.1")
_local_bump(repo, "7.1.0")
text = (repo / "pyproject.toml").read_text()
(repo / "pyproject.toml").write_text(text + 'version = "9.9.9"\n') # second anchor
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "duplicate local version anchor")
_point_at(monkeypatch, tmp_path, repo, head)
plan = update_merge.plan_managed_update_merge(fetch=False)
assert plan["kind"] == "conflicting", plan
assert "pyproject.toml" in plan["code_conflict_paths"]
assert "pyproject.toml" not in plan["carrier_resolved_paths"]
# --- insertion point 2: the base re-merge, before write-tree -----------------
def test_base_re_merge_resolves_carrier_conflicts_before_write_tree(tmp_path, monkeypatch):
"""Dirty-tree case of the corpus: committed local carrier bumps PLUS dirty
uncommitted work force the Q1=C base re-merge, whose carrier conflicts the
engine resolves BEFORE write-tree; the dirty file never enters the built
commit."""
repo, head = _init_carrier_repo(tmp_path)
_official_bump(repo, head, "7.0.1")
_local_bump(repo, "7.1.0")
(repo / "dirty.txt").write_text("uncommitted owner work\n")
_point_at(monkeypatch, tmp_path, repo, head)
plan = update_merge.plan_managed_update_merge(fetch=False, build=True)
assert plan["kind"] == "clean", plan
assert plan["local_dirty_count"] >= 1
assert plan["merge_commit"], plan
tree = _git(repo, "ls-tree", "-r", "--name-only", plan["merge_commit"]).stdout
assert "dirty.txt" not in tree # Q1=C: dirty work never enters history
shown = _git(repo, "show", f"{plan['merge_commit']}:VERSION").stdout
assert shown.strip() == "7.0.1"
parents = _git(
repo, "rev-list", "--parents", "-n", "1", plan["merge_commit"]
).stdout.split()
assert parents[1:] == [plan["base_sha"], plan["target_sha"]]
# --- insertion point 3: the live assisted materializer -----------------------
def test_live_materializer_resolves_carrier_conflicts_for_the_assisted_lane(tmp_path, monkeypatch):
"""A real (non-carrier) code conflict routes the update to the assisted
lane; the live materializer still resolves the version-carrier spans —
BEFORE the M0 baseline is pinned (Ф-2=A) — so the resolver task only
faces the real conflict."""
repo, head = _init_carrier_repo(tmp_path)
def official_code(r):
(r / "a.txt").write_text("official code change\n")
def local_code(r):
(r / "a.txt").write_text("local code change\n")
_official_bump(repo, head, "7.0.1", extra=official_code)
_local_bump(repo, "7.1.0", extra=local_code)
_point_at(monkeypatch, tmp_path, repo, head)
plan = update_merge.plan_managed_update_merge(fetch=False)
assert plan["kind"] == "conflicting", plan
assert "a.txt" in plan["code_conflict_paths"]
ok, message, m0_tree = update_merge.materialize_assisted_merge_live(
head, plan["local_snapshot"], plan["target_sha"], plan["base_sha"]
)
assert ok, message
assert m0_tree, message # the mechanical baseline pinned AFTER span policy
assert update_merge._merge_head_sha() == plan["target_sha"]
unmerged = _git(repo, "diff", "--name-only", "--diff-filter=U").stdout.split()
assert "a.txt" in unmerged # the real conflict stays for the resolver
for path in CARRIER_FILES:
assert path not in unmerged, path
assert (repo / "VERSION").read_text() == "7.0.1\n"
assert "<<<<<<<" in (repo / "a.txt").read_text()
# M0 carries the resolved carrier spans: the pinned baseline tree names the
# official VERSION blob, not a conflicted one.
m0_version = _git(repo, "cat-file", "-p", f"{m0_tree}:VERSION").stdout
assert m0_version == "7.0.1\n"
# --- rollback case -----------------------------------------------------------
def test_rollback_restores_pre_update_sha_after_carrier_resolved_apply(tmp_path, monkeypatch):
repo, head = _init_carrier_repo(tmp_path)
_official_bump(repo, head, "7.0.1")
_local_bump(repo, "7.1.0")
_point_at(monkeypatch, tmp_path, repo, head)
pre = _git(repo, "rev-parse", "HEAD").stdout.strip()
plan = update_merge.plan_managed_update_merge(fetch=False, build=True)
assert plan["kind"] == "clean" and plan["merge_commit"], plan
ok, message = update_merge.apply_managed_merge_update(head, plan["merge_commit"])
assert ok, message
update_merge.write_update_tx({
"phase": "pending_boot_smoke", "pre_update_sha": pre,
"pre_update_branch": head, "target_sha": plan["target_sha"],
"merge_commit": plan["merge_commit"],
})
gate_calls = []
import supervisor.workers as workers
monkeypatch.setattr(
workers, "close_repo_writer_admission",
lambda reason: gate_calls.append(("close", reason)),
)
monkeypatch.setattr(
workers, "open_repo_writer_admission",
lambda expected_reason="": gate_calls.append(("open", expected_reason)),
)
ok, message = update_merge.rollback_managed_update("carrier_test_rollback")
assert ok, message
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == pre
assert (repo / "VERSION").read_text() == "7.1.0\n" # the local tree is back
assert update_merge.read_update_tx_strict()[0] == "absent"
# --- crash cases -------------------------------------------------------------
def test_resolver_crash_degrades_the_plan_never_the_live_tree(tmp_path, monkeypatch):
"""A per-file resolver crash degrades that file to the assisted path; a
crash of the whole resolver is swallowed by the planner's best-effort
envelope. Neither touches the live worktree or leaks a temp worktree."""
repo, head = _init_carrier_repo(tmp_path)
_official_bump(repo, head, "7.0.1")
_local_bump(repo, "7.1.0")
_point_at(monkeypatch, tmp_path, repo, head)
def boom(*_args, **_kwargs):
raise RuntimeError("carrier resolver crashed")
monkeypatch.setattr(update_carriers, "resolve_carrier_conflict_file", boom)
plan = update_merge.plan_managed_update_merge(fetch=False)
assert plan["kind"] == "conflicting", plan # per-file degrade, no crash
assert plan["carrier_resolved_paths"] == []
assert not _git(repo, "status", "--porcelain").stdout.strip()
monkeypatch.setattr(update_merge_plan, "resolve_carrier_conflicts", boom)
plan2 = update_merge.plan_managed_update_merge(fetch=False)
assert plan2["kind"] == "unknown", plan2 # planner envelope, still no crash
assert not _git(repo, "status", "--porcelain").stdout.strip()
worktrees = _git(repo, "worktree", "list").stdout.strip().splitlines()
assert len(worktrees) == 1 # no leaked temp worktree
# --- SSOT + wiring pins ------------------------------------------------------
def test_every_descriptor_matches_the_live_repo_exactly_once():
"""The span SSOT must describe the REAL carriers of this checkout: every
descriptor anchors exactly once in the live file it names."""
for span in VERSION_CARRIER_SPANS:
text = (REPO_ROOT / span.path).read_text(encoding="utf-8")
status, location = locate_carrier_span(text, span)
assert status == "ok" and location is not None, (span.carrier_id, status)
readme_spans = carrier_spans_for("README.md")
assert {span.carrier_id for span in readme_spans} == {
"readme_badge", "readme_history", "readme_download_refs",
}
# The span set is cut from THIS tree's carrier inventory (everything
# sync_release_metadata writes / version_carrier_desyncs checks): the 7
# ratified carriers + README-history, the README direct-download reference
# block, the uv.lock root-package mirror, and 8 release-download anchors on
# each of the two public install pages (macos-arm64 appears twice there).
assert len(VERSION_CARRIER_SPANS) == 25
assert {s.carrier_id for s in VERSION_CARRIER_SPANS} >= {
"uv_lock_root_package", "readme_download_refs",
"site_install_download_macos-arm64_button",
"site_install_download_macos-arm64_step",
"docs_install_download_linux-x86_64",
}
install_paths = {s.path for s in VERSION_CARRIER_SPANS if "install" in s.carrier_id}
assert install_paths == {"site/install/index.html", "docs/install/index.html"}
def test_one_shared_resolver_serves_all_three_insertion_points():
"""The ratified wiring (spec §1.9-10): ONE shared resolver, called at the
planner merge, the base re-merge and the live materializer — all with the
official-side preference — and nowhere else in the update machinery.
Honest frame, documented rather than simulated: the FIRST pre-v7 -> 7.0.0
upgrade is driven by the OLD updater, whose code (the pre-redesign
supervisor/update_merge.py) never called this engine; the parent facade
still contains no resolver call, so the engine governs steady state only
(7.0.0 -> 7.0.1 and beyond). The boot-recovery M0 backfill window
(update_merge._recover_assisted_on_boot) deliberately re-runs only the Q8
projection, not span resolution — upstream recovery semantics are the
floor, and a carrier that stayed conflicted through that crash window
degrades to the assisted lane (fail-safe, never fail-wrong)."""
leaf_source = (REPO_ROOT / "supervisor" / "update_merge_plan.py").read_text(
encoding="utf-8"
)
calls = leaf_source.count("resolve_carrier_conflicts(")
assert calls == 3, calls
assert leaf_source.count('prefer="theirs"') == 3
parent_source = (REPO_ROOT / "supervisor" / "update_merge.py").read_text(
encoding="utf-8"
)
assert "resolve_carrier_conflicts" not in parent_source
engine_doc = update_carriers.__doc__ or ""
assert "OLD updater" in engine_doc and "steady state" in engine_doc
def test_resolver_rejects_an_unknown_preference(tmp_path):
try:
update_carriers.resolve_carrier_conflicts(str(tmp_path), [], prefer="mine")
except ValueError as exc:
assert "mine" in str(exc)
else: # pragma: no cover
raise AssertionError("unknown preference must be a typed refusal")

View file

@ -105,10 +105,14 @@ def test_marker_gate_accepts_staged_deletion_and_binary_blob(tmp_path, monkeypat
def test_materialize_projects_version_to_target_and_pins_m0(tmp_path, monkeypatch):
"""P9 projection (Q8): a conflicted VERSION file — a pure version token — is
mechanically resolved to the official target's side BEFORE the resolver sees
the tree, and the pinned M0 baseline already includes that projection (the
resolution delta must show only the resolver's own work)."""
"""P9 projection (Q8): a conflicted VERSION file is mechanically resolved
to the official target's side BEFORE the resolver sees the tree, and the
pinned M0 baseline already includes that projection (the resolution delta
must show only the resolver's own work). The local token is deliberately
NOT a valid version: a well-formed token conflict is already resolved by
the carrier-span engine at the PLANNER (D34) and never reaches this lane,
while a degraded anchor falls past the span resolver to the projection —
which projects VERSION conflicted-or-drifted alike."""
repo, head = _init_repo(tmp_path)
(repo / "VERSION").write_text("1.0.0\n")
_git(repo, "add", "-A")
@ -119,7 +123,7 @@ def test_materialize_projects_version_to_target_and_pins_m0(tmp_path, monkeypatc
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "official release")
_git(repo, "checkout", "-q", head)
(repo / "VERSION").write_text("1.5.0\n")
(repo / "VERSION").write_text("not-a-version\n")
(repo / "local.txt").write_text("local\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-q", "-m", "local fork release")

View file

@ -0,0 +1,78 @@
"""Facade-identity contract for the supervisor/update_merge.py leaf owners.
Every member that lives in a leaf of the update engine's two-module-plus split
(the redesign's own ``update_candidate`` boundary and the re-cut
``update_merge_plan`` planner leaf) keeps an update_merge re-export under its
historical name, so existing callers and monkeypatching tests keep working
unchanged — the update_merge binding IS the leaf's object, the same way the
queue and loop splits pin their leaves. The hot-code label parity clause pins
the OTHER direction of the loop-split rule: ``supervisor/update_merge.py`` is
not a HOT_CODE_PATHS member, so a leaf that merely moved code out of it must
not silently acquire the label either.
"""
from __future__ import annotations
import importlib
# leaf module -> every member the leaf owns (update_merge re-exports each name).
UPDATE_MERGE_LEAF_OWNERS: dict[str, str] = {
"update_merge_plan": (
"_build_clean_merge_commit plan_managed_update_merge "
"materialize_assisted_merge_live"
),
"update_candidate": (
"_MERGE_NEUTRAL_FLAGS _git_run _merge_head_sha _preserve_failed_update_attempt "
"_rev_parse existing_failed_update_ref live_unmerged_paths "
"UpdateTxCorrupt managed_assisted_marker_check managed_tests_evidence_covers "
"record_managed_tests_evidence record_managed_tests_proof "
"update_tx_phase update_tx_phase_or_keep worktree_snapshot_tree "
"find_update_stash_sha restore_stash_with_marker restore_update_stash "
"stash_local_changes_for_update lookup_update_stash "
"destructive_apply_guard project_version_carriers"
),
}
def test_update_merge_owner_facade_preserves_identity():
import supervisor.update_merge as update_merge
for leaf, names in UPDATE_MERGE_LEAF_OWNERS.items():
module = importlib.import_module(f"supervisor.{leaf}")
for name in names.split():
assert getattr(update_merge, name) is getattr(module, name), f"{leaf}.{name}"
def test_update_merge_leaves_keep_hot_code_label_parity():
"""Managed-update conflict labelling does not name ``supervisor/update_merge.py``;
the split must not silently upgrade or downgrade the label for code that merely
moved — parent and leaves carry the SAME membership."""
from supervisor.update_merge_policy import HOT_CODE_PATHS
parent_is_hot = "supervisor/update_merge.py" in HOT_CODE_PATHS
for leaf in UPDATE_MERGE_LEAF_OWNERS:
assert (f"supervisor/{leaf}.py" in HOT_CODE_PATHS) == parent_is_hot, leaf
# The shared span resolver is new update machinery, not moved hot code:
# same parity rule.
assert ("supervisor/update_carriers.py" in HOT_CODE_PATHS) == parent_is_hot
def test_update_engine_split_keeps_release_invariant_protection():
"""``supervisor/update_merge.py`` is a release-invariant path; the re-split
moved the planner/materializer bodies (and added the span resolver the
engine executes under the update lock) without moving any of the risk, so
the protecting inventory must cover them too — the same closure the G1
git_ops split pinned. ``supervisor/update_candidate.py`` is deliberately
NOT asserted: upstream's own redesign split it out without listing it, and
upstream owns that protected-inventory decision (disclosed, not repaired
here)."""
from ouroboros.runtime_mode_policy import RELEASE_INVARIANT_PATHS, protected_path_category
for path in (
"supervisor/update_merge.py",
"supervisor/update_merge_policy.py",
"supervisor/update_merge_plan.py",
"supervisor/update_carriers.py",
):
assert path in RELEASE_INVARIANT_PATHS, path
assert protected_path_category(path) == "release-invariant", path