mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
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:
parent
1b4a8da957
commit
7f0a112443
14 changed files with 1808 additions and 425 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
107
scripts/carrier_rebase_helper.py
Normal file
107
scripts/carrier_rebase_helper.py
Normal 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())
|
||||
225
supervisor/update_carriers.py
Normal file
225
supervisor/update_carriers.py
Normal 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}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
490
supervisor/update_merge_plan.py
Normal file
490
supervisor/update_merge_plan.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
110
tests/test_carrier_rebase_helper.py
Normal file
110
tests/test_carrier_rebase_helper.py
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
481
tests/test_update_carriers.py
Normal file
481
tests/test_update_carriers.py
Normal 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)\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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
78
tests/test_update_merge_owner_facade.py
Normal file
78
tests/test_update_merge_owner_facade.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue