Preserve snapshot working bytes and repair Windows CI fixtures

Keep the normal Git baseline while copying original regular-file bytes into
delegated snapshots. Reject a source change before registration and preserve
existing links, patch semantics and cleanup. Correct Windows cmd quoting and
UTF-8/newline fixtures, and synchronize lifecycle tests on the actual phase.
This commit is contained in:
Ouroboros 2026-09-15 03:41:52 +03:00
parent 627ee94471
commit 88cc7f8c71
8 changed files with 208 additions and 55 deletions

View file

@ -1639,7 +1639,7 @@ WHERE a mutating run's changes are destined is the second, separate record — t
A payload target gets a standalone private Git snapshot (`subagent_worktrees.provision_payload_snapshot`): the live payload is never initialized as Git; the loader-visible inventory is committed as a registered synthetic baseline, and capture trusts nothing under the child-writable snapshot's `.git`. Disposition applies a live, index-free `git apply` under a whole-payload content-hash CAS (drift = typed conflict; identical content = idempotent applied). The post-apply outcome set is complete: a live loader hash equal to the recorded RESULT hash is the success; a hash equal to the recorded BASELINE hash with a non-empty touched set is a provable non-mutation that RESOLVES the apply intent (`apply_no_op`: no success, no disposition, no reconcile queued, retry lane open) independently of whether a result hash was ever recorded; anything else is the ambiguous mismatch, whose intent stays PENDING and whose reconcile marker IS queued because the payload did mutate. A successful apply queues the extension reconcile and the skill's review goes STALE pending fresh `skill_preflight`/`skill_review`. The `apply_no_op` arm carries no content residual: a run whose ONLY change is a mode flip is already refused at CAPTURE time as `unreviewable_metadata_change`, and any patch that did land content would move the whole-payload hash, so a live hash still equal to the baseline means nothing was written.
**A mutating run executes in a PRIVATE EXECUTION SNAPSHOT, never in the shared tree** (metered children keep sharing the tree). At `delegate_start` the host snapshots the target's real state — tracked + staged + eligible untracked, with the sensitive/credential veto DECIDED BEFORE ANYTHING IS HASHED: a blanket `git add -A` would write vetoed secrets, `.env` included, into the shared object database. The baseline commit is pinned by a `refs/ouroboros/delegated/` ref and checked out as a detached worktree (`provision_execution_snapshot`); `scope.root` stays the stable authority target while `execution.workspaceRoot` names the snapshot. The typed snapshot binding is recorded durably on the custody rows BEFORE the POST; an explicit retry reproduces it exactly (a GC-collected snapshot is a typed `execution_snapshot_missing` refusal, never a re-mint). The run still runs `live` from the engine's view, which is why the scoped-HOME/`delegated` marker below applies unchanged.
**A mutating run executes in a PRIVATE EXECUTION SNAPSHOT, never in the shared tree** (metered children keep sharing the tree). At `delegate_start` the host snapshots the target's real state — tracked + staged + eligible untracked, with the sensitive/credential veto DECIDED BEFORE ANYTHING IS HASHED: a blanket `git add -A` would write vetoed secrets, `.env` included, into the shared object database. The baseline commit is pinned by a `refs/ouroboros/delegated/` ref and checked out as a detached worktree (`provision_execution_snapshot`). Git keeps its normal filtered baseline; `artifacts.copy_artifact_file` restores the original regular-file working bytes after checkout, and one Git comparison rejects concurrent source drift before registration. Links, gitlinks and baseline deletions remain Git-owned; `scope.root` stays the stable authority target while `execution.workspaceRoot` names the snapshot. The typed snapshot binding is recorded durably on the custody rows BEFORE the POST; an explicit retry reproduces it exactly (a GC-collected snapshot is a typed `execution_snapshot_missing` refusal, never a re-mint). The run still runs `live` from the engine's view, which is why the scoped-HOME/`delegated` marker below applies unchanged.
At terminal, `delegate_wait` captures the run's diff against the baseline durably into the task's artifact store; NOTHING reaches the target automatically — the nanny explicitly applies or rejects through `integrate_delegated_patch`. Only the staging substrate differs between lanes: a GIT workspace target applies under the repo git lock — first PROVING no touched path drifted from `baseline_sha` (a plain `git apply` relocates hunks by offset; the touched-path set is read from `git apply --numstat` in BOTH directions because each direction names only the paths it writes) — then applying and STAGING, never committing; a skill-payload target applies live under the whole-payload CAS. A SKILL-PAYLOAD target captures through the payload adapter over a parent-owned trusted index (`_write_payload_patch_artifacts`: nothing under the child-writable snapshot's `.git` is trusted, the baseline comes from the host-owned snapshot registry, and the final loader inventory is staged from raw bytes) and applies LIVE into the non-Git payload — nothing is staged into any active root, no `.git` or index is created in the payload, and a successful apply invalidates the skill's review and queues the extension reconcile before the verdict artifact. The protected-path gate applies only when the target IS the Ouroboros body; a conflict (proven drift) is owned by the still-running nanny, with snapshot and patch persisting until explicit resolution or discard. Mutation rides an apply-intent protocol: a durable `delegate_run_patch_apply_started` row lands before any tree mutation, so on replay a pending intent without a disposition answers typed `INTEGRATE_DELEGATED_APPLY_AMBIGUOUS`, resolved only by explicit `acknowledge_ambiguous=true`. The provably non-mutating outcomes that instead RESOLVE the intent are a lock error, proven baseline drift, a failed apply, a verified revert, and a live payload hash equal to the baseline after a payload apply. `artifacts.delegated_capture_read_target` narrowly rebinds `artifact_store` READS for the owning task's own `delegated_runs/` prefix, so a child-drive nanny can inspect the patch it must dispose without widened write authority. Its sibling `delegate_shared.orphan_capture_read_target` extends the same READ, still read-only and still confined to one capture directory, to the terminal-owner ORPHAN the disposition rule already authorizes: the actor that MAY apply a foreign capture may also read it, resolved by path shape first (so an ordinary refusal never replays the custody log) and confirmed by `orphan_disposition_status` before any path is returned. A read-only child stays in Claudexor's default envelope — one transport with one derived difference, not a second pipeline.

View file

@ -1940,6 +1940,10 @@ both critical. The imperatives:
invariant and the tool description; every other guard (owner terminality,
top-level principal, proven drift, protected paths, staged-never-committed)
is unchanged (`tests/test_delegated_run_isolation_orphans.py`).
Snapshot tests compare raw LF/CRLF inputs under Git checkout filters, require
zero patch before child edits, and retain normal Git apply semantics afterwards.
Copy failures or a source change against the baseline leave no registered
snapshot or pinned ref (`tests/test_snapshot_file_inputs.py`).
- Outcome honesty: a delegating parent must not produce a clean no-tool
final answer while direct children run undecided — one bounded absorption
reminder, then best-effort (`children_unabsorbed`); while that gate is

View file

@ -477,7 +477,9 @@ def provision_execution_snapshot(
Baseline construction never touches the target's own index, HEAD or working
files: a TEMPORARY index is seeded from HEAD and stages the eligible
tracked/staged/text inputs (``.gitignore`` respected). Binary and large
tracked/staged/text inputs (``.gitignore`` respected). The execution copy
retains original regular-file bytes despite Git checkout filters; a Git
comparison binds the copied content to that same baseline. Binary and large
untracked inputs are streamed into the execution root outside Git's ODB,
with exact preimages retained in the existing snapshot record. The Git tree
is committed as a synthetic baseline pinned by a ref under
@ -583,6 +585,24 @@ def provision_execution_snapshot(
try:
wt_path.parent.mkdir(parents=True, exist_ok=True)
_git(target, "worktree", "add", "--detach", str(wt_path), baseline_sha)
from ouroboros.artifacts import copy_artifact_file
# Git owns the baseline representation, but the child must see the
# source's actual working bytes, not checkout's CRLF/smudge rewrite.
# Read the existing tree inventory so deletions, links and gitlinks
# keep Git's semantics and excluded paths can never enter the copy.
for item in manifest_raw.split(b"\0"):
metadata, separator, raw_path = item.partition(b"\t")
if separator and metadata.split()[0] in (b"100644", b"100755"):
relative = raw_path.decode("utf-8", errors="surrogateescape")
original = target / relative
if original.is_symlink():
raise OSError(f"snapshot input changed from a regular file: {relative}")
copy_artifact_file(original, wt_path / relative)
# A concurrent source edit must not appear as the child's work.
# Use the same Git representation as ordinary patch capture, once
# for the whole tree, before the separately tracked file inputs.
_git(wt_path, "diff", "--quiet", "--no-ext-diff", baseline_sha, "--")
from ouroboros.workspace_file_outputs import copy_snapshot_file_inputs
file_baseline = copy_snapshot_file_inputs(target, wt_path, file_inputs)
if file_baseline:

View file

@ -11,7 +11,7 @@ from ouroboros.reference_books import compose_book, load_reference_book
from tests.test_deep_review_slot import _native_row, _ScriptedLLM, _tool_call
def _corpus(root):
def _corpus(root, *, newline="\n"):
files = {
"BIBLE.md": "# Constitution\n\nThe constitutional source.\n",
"docs/CHECKLISTS.md": "# Checklists\n\n## Review\n\nCheck the actual contract.\n",
@ -22,10 +22,11 @@ def _corpus(root):
for name in ("flow", "state"):
files[f"docs/{book_id}/{name}.md"] = (
f"# {name.title()}\n\nIntroduction to {book_id} {name}.\n\n## Contract\n\nExact {book_id} {name} contract body.\n")
files = {rel: text.replace("\n", newline) for rel, text in files.items()}
for rel, text in files.items():
target = root / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(text)
target.write_bytes(text.encode("utf-8"))
return files
@ -37,13 +38,14 @@ def _required(root, rel):
"range_basis": "unicode_text_universal_newlines"}
def test_packed_chaptered_books_are_complete_once_and_stable_before_atlas(tmp_path, monkeypatch):
@pytest.mark.parametrize("newline", ["\n", "\r\n"], ids=["lf", "crlf"])
def test_packed_chaptered_books_are_complete_once_and_stable_before_atlas(tmp_path, monkeypatch, newline):
monkeypatch.setattr(deep, "get_context_mode", lambda: "max")
monkeypatch.setattr(deep, "_compute_graph_centrality", lambda *a: {})
prefixes = []
for name in ("first", "second"):
repo, data = tmp_path / name, tmp_path / f"{name}-data"
files = _corpus(repo)
files = _corpus(repo, newline=newline)
monkeypatch.setattr(deep, "_dulwich_tracked_paths", lambda *a: (list(files), []))
monkeypatch.chdir(tmp_path)
pack, stats = deep.build_review_pack(repo, data)

View file

@ -4,8 +4,8 @@ ROOT = Path(__file__).parents[1]
def test_settings_and_chat_expose_nano_context_mode():
settings = (ROOT / 'web/modules/settings_ui.js').read_text()
chat = (ROOT / 'web/modules/chat.js').read_text()
settings = (ROOT / 'web/modules/settings_ui.js').read_text(encoding="utf-8")
chat = (ROOT / 'web/modules/chat.js').read_text(encoding="utf-8")
assert "{ value: 'nano', label: 'Nano' }" in settings
assert 'data-mode="nano">Nano' in chat
assert "['nano', 'low', 'max'].includes(data.context_mode)" in chat
@ -13,8 +13,8 @@ def test_settings_and_chat_expose_nano_context_mode():
def test_chat_control_ids_match_instance_wiring_and_nano_has_active_style():
chat = (ROOT / 'web/modules/chat.js').read_text()
styles = (ROOT / 'web/style.css').read_text()
chat = (ROOT / 'web/modules/chat.js').read_text(encoding="utf-8")
styles = (ROOT / 'web/style.css').read_text(encoding="utf-8")
# The markup keeps the chat-* ids that byId() resolves for the main
# instance (and namespaces for project panels), so handlers cannot drift
# to a selector for a different control.

View file

@ -1793,10 +1793,12 @@ def test_run_shell_writes_skill_state_examples(shape, filename, tmp_path, monkey
# ``bash`` on hosted Windows resolves to WSL, which has no distro in
# the GitHub runner image. Exercise the same redirect/copy shapes via
# the native command interpreter instead of depending on WSL setup.
# Separate operands let subprocess quote paths without backslash-escaping
# the inner quotes of a prequoted cmd body.
cmd = (
["cmd.exe", "/d", "/c", f'type "{payload}" > "{target}"']
["cmd.exe", "/d", "/c", "type", str(payload), ">", str(target)]
if shape == "redirect"
else ["cmd.exe", "/d", "/c", f'copy /Y "{payload}" "{target}" >NUL']
else ["cmd.exe", "/d", "/c", "copy", "/Y", str(payload), str(target), ">NUL"]
)
else:
cmd = (

View file

@ -340,6 +340,16 @@ def test_direct_review_ignores_historical_completed_job_hash(tmp_path, monkeypat
assert load_review_state(drive_root, "alpha").content_hash == content_hash
async def _wait_for_reconcile(task, started):
# Preparation includes filesystem work and imports, not a two-second contract.
# Still surface early lifecycle completion instead of waiting for a phase it skipped.
while not started.is_set():
if task.done():
result = await task
raise AssertionError(f"lifecycle finished before extension reconcile: {result!r}")
await asyncio.sleep(0.01)
def test_cancellation_during_extension_reconcile_keeps_lifecycle_lane(tmp_path, monkeypatch):
from ouroboros.skill_review import SkillReviewOutcome
from ouroboros.skill_review_runner import run_skill_review_lifecycle
@ -375,7 +385,7 @@ def test_cancellation_during_extension_reconcile_keeps_lifecycle_lane(tmp_path,
def fake_reconcile(*_args, **_kwargs):
reconcile_started.set()
release_reconcile.wait(2)
release_reconcile.wait()
return reconcile_receipt("extension_loaded", "ok")
monkeypatch.setattr(runner, "_reconcile_extension_payload", fake_reconcile)
@ -384,30 +394,33 @@ def test_cancellation_during_extension_reconcile_keeps_lifecycle_lane(tmp_path,
task = asyncio.create_task(
run_skill_review_lifecycle(ctx, "alpha", source="test", review_impl=fake_review)
)
assert await asyncio.to_thread(reconcile_started.wait, 2)
task.cancel()
await asyncio.sleep(0.05)
task.cancel()
await asyncio.sleep(0.05)
active = lifecycle_queue.queue_snapshot()["active"]
assert active is not None
assert active["target"] == "alpha"
quick = asyncio.create_task(
lifecycle_queue.run_lifecycle_job(
kind="review",
target="beta",
dedupe_key="review:beta:hash",
runner=lambda: asyncio.sleep(0, result={"quick": True}),
options=lifecycle_queue.LifecycleJobOptions(drive_root=drive_root),
try:
await _wait_for_reconcile(task, reconcile_started)
task.cancel()
await asyncio.sleep(0.05)
task.cancel()
await asyncio.sleep(0.05)
active = lifecycle_queue.queue_snapshot()["active"]
assert active is not None
assert active["target"] == "alpha"
quick = asyncio.create_task(
lifecycle_queue.run_lifecycle_job(
kind="review",
target="beta",
dedupe_key="review:beta:hash",
runner=lambda: asyncio.sleep(0, result={"quick": True}),
options=lifecycle_queue.LifecycleJobOptions(drive_root=drive_root),
)
)
)
await asyncio.sleep(0.05)
assert not quick.done()
release_reconcile.set()
result = await asyncio.wait_for(task, timeout=2)
assert result["status"] == "clean"
assert await asyncio.wait_for(quick, timeout=2) == {"quick": True}
assert lifecycle_queue.queue_snapshot()["active"] is None
await asyncio.sleep(0.05)
assert not quick.done()
release_reconcile.set()
result = await asyncio.wait_for(task, timeout=2)
assert result["status"] == "clean"
assert await asyncio.wait_for(quick, timeout=2) == {"quick": True}
assert lifecycle_queue.queue_snapshot()["active"] is None
finally:
release_reconcile.set()
asyncio.run(main())
@ -451,7 +464,7 @@ def test_heartbeat_continues_during_extension_reconcile(tmp_path, monkeypatch):
def fake_reconcile(*_args, **_kwargs):
reconcile_started.set()
release_reconcile.wait(2)
release_reconcile.wait()
return reconcile_receipt("extension_loaded", "ok")
monkeypatch.setattr(runner, "_reconcile_extension_payload", fake_reconcile)
@ -460,22 +473,25 @@ def test_heartbeat_continues_during_extension_reconcile(tmp_path, monkeypatch):
task = asyncio.create_task(
run_skill_review_lifecycle(ctx, "alpha", source="test", review_impl=fake_review)
)
assert await asyncio.to_thread(reconcile_started.wait, 2)
job_path = review_job_state_path(drive_root, "alpha")
before = json.loads(job_path.read_text(encoding="utf-8"))["last_heartbeat_at"]
# A bounded wait, not 20 x 10 ms: on windows-latest the heartbeat's atomic replace can
# lose a few rounds to this very poll holding the file open (sharing violation, logged
# and retried by the beat), and the clock ticks at ~15.6 ms — 200 ms saw no change.
for _ in range(60):
await asyncio.sleep(0.05)
after = json.loads(job_path.read_text(encoding="utf-8"))["last_heartbeat_at"]
if after != before:
break
assert after != before, "no heartbeat within 3 s while the reconcile blocks"
release_reconcile.set()
result = await asyncio.wait_for(task, timeout=2)
assert result["status"] == "clean"
final = json.loads(job_path.read_text(encoding="utf-8"))
assert final["status"] == "completed"
try:
await _wait_for_reconcile(task, reconcile_started)
job_path = review_job_state_path(drive_root, "alpha")
before = json.loads(job_path.read_text(encoding="utf-8"))["last_heartbeat_at"]
# A bounded wait, not 20 x 10 ms: on windows-latest the heartbeat's atomic replace can
# lose a few rounds to this very poll holding the file open (sharing violation, logged
# and retried by the beat), and the clock ticks at ~15.6 ms — 200 ms saw no change.
for _ in range(60):
await asyncio.sleep(0.05)
after = json.loads(job_path.read_text(encoding="utf-8"))["last_heartbeat_at"]
if after != before:
break
assert after != before, "no heartbeat within 3 s while the reconcile blocks"
release_reconcile.set()
result = await asyncio.wait_for(task, timeout=2)
assert result["status"] == "clean"
final = json.loads(job_path.read_text(encoding="utf-8"))
assert final["status"] == "completed"
finally:
release_reconcile.set()
asyncio.run(main())

View file

@ -1,4 +1,4 @@
"""Copied binary/large inputs retain exact preimages without entering Git's ODB."""
"""Snapshot working bytes stay exact; binary/large preimages stay outside Git's ODB."""
from hashlib import sha256
from pathlib import Path
import subprocess
@ -172,3 +172,112 @@ def test_baseline_file_copy_failure_cleans_checkout_ref_and_record(tmp_path, mon
assert find_execution_snapshot("input-snapshot", tmp_path / "data") is None
assert not git(target, "for-each-ref", "refs/ouroboros/delegated/").stdout
assert not list((tmp_path / "snapshots").glob("dlg_*"))
@pytest.mark.parametrize("newline", [b"\n", b"\r\n"], ids=["lf", "crlf"])
@pytest.mark.parametrize("policy", ["autocrlf", "attributes"])
def test_git_checkout_preserves_working_bytes_and_normal_patch_semantics(tmp_path, newline, policy):
target = target_tree(tmp_path)
git(target, "config", "core.autocrlf", "true" if policy == "autocrlf" else "false")
if policy == "attributes":
(target / ".gitattributes").write_bytes(b"*.txt text eol=crlf\n")
original = newline.join([b"original", b"working bytes", b""])
for name in ("code.txt", "staged.txt", "untracked.txt"):
(target / name).write_bytes(original)
git(target, "add", "staged.txt")
(target / "deleted.txt").write_bytes(b"removed before snapshot\n")
git(target, "add", "deleted.txt")
(target / "deleted.txt").unlink()
before_status = git(target, "status", "--porcelain=v1", "-z").stdout
before_index = git(target, "ls-files", "--stage", "-z").stdout
before_head = git(target, "rev-parse", "HEAD").stdout
handle = snapshot(tmp_path, target)
execution = Path(handle.path)
for name in ("code.txt", "staged.txt", "untracked.txt"):
assert (execution / name).read_bytes() == original
assert not (execution / "deleted.txt").exists()
assert git(target, "status", "--porcelain=v1", "-z").stdout == before_status
assert git(target, "ls-files", "--stage", "-z").stdout == before_index
assert git(target, "rev-parse", "HEAD").stdout == before_head
assert all((target / name).read_bytes() == original
for name in ("code.txt", "staged.txt", "untracked.txt"))
manifest, rows = capture(tmp_path, handle)
assert manifest["status"] == "ready_no_changes" and manifest["patch_size"] == 0 and rows == []
changed = original.replace(b"original", b"child edit")
(execution / "code.txt").write_bytes(changed)
manifest, rows = capture(tmp_path, handle)
assert manifest["status"] == "ready_with_changes" and rows == []
assert manifest["tracked_changed"] == ["code.txt"]
patch = tmp_path / "capture" / "workspace.patch"
git(target, "apply", "--binary", "--check", str(patch))
git(target, "apply", "--binary", str(patch))
# Explicit apply retains the target's normal Git checkout representation.
assert (target / "code.txt").read_bytes() == b"child edit\r\nworking bytes\r\n"
assert (target / "staged.txt").read_bytes() == original
assert (target / "untracked.txt").read_bytes() == original
assert git(target, "ls-files", "--stage", "-z").stdout == before_index
assert git(target, "rev-parse", "HEAD").stdout == before_head
@pytest.mark.parametrize("failure", ["copy_error", "source_changed"])
def test_regular_input_copy_failure_cleans_snapshot_without_rewriting_target(tmp_path, monkeypatch, failure):
from ouroboros import artifacts
target = target_tree(tmp_path)
before_index = git(target, "ls-files", "--stage", "-z").stdout
before_head = git(target, "rev-parse", "HEAD").stdout
before_bytes = (target / "code.txt").read_bytes()
original = artifacts.copy_artifact_file
newer = b"concurrent owner edit\n"
def copy(source, destination, **kwargs):
if Path(source) == target / "code.txt":
if failure == "copy_error":
raise OSError("source changed during copy")
Path(source).write_bytes(newer)
return original(source, destination, **kwargs)
monkeypatch.setattr(artifacts, "copy_artifact_file", copy)
expected = OSError if failure == "copy_error" else subprocess.CalledProcessError
with pytest.raises(expected):
snapshot(tmp_path, target)
assert (target / "code.txt").read_bytes() == (newer if failure == "source_changed" else before_bytes)
assert git(target, "ls-files", "--stage", "-z").stdout == before_index
assert git(target, "rev-parse", "HEAD").stdout == before_head
assert find_execution_snapshot("input-snapshot", tmp_path / "data") is None
assert not git(target, "for-each-ref", "refs/ouroboros/delegated/").stdout
assert not list((tmp_path / "snapshots").glob("dlg_*"))
def test_snapshot_keeps_git_link_entries_out_of_regular_file_copy(tmp_path, monkeypatch):
from ouroboros import artifacts
target = target_tree(tmp_path)
# Git's portable symlink checkout is a regular file containing the target;
# the baseline mode, not its host file type, still owns that representation.
git(target, "config", "core.symlinks", "false")
(target / "link").write_bytes(b"code.txt")
oid = git(target, "hash-object", "-w", "link").stdout.decode().strip()
git(target, "update-index", "--add", "--cacheinfo", f"120000,{oid},link")
commit = git(target, "rev-parse", "HEAD").stdout.decode().strip()
git(target, "update-index", "--add", "--cacheinfo", f"160000,{commit},nested")
git(target, "-c", "user.name=Fixture", "-c", "user.email=f@invalid", "commit", "-m", "link entries")
(target / "nested").mkdir() # An uninitialized gitlink stays Git-owned.
before_index = git(target, "ls-files", "--stage", "-z").stdout
original = artifacts.copy_artifact_file
def copy(source, destination, **kwargs):
assert Path(source).name not in {"link", "nested"}
return original(source, destination, **kwargs)
monkeypatch.setattr(artifacts, "copy_artifact_file", copy)
handle = snapshot(tmp_path, target)
execution = Path(handle.path)
assert (execution / "link").read_bytes() == b"code.txt"
assert git(execution, "ls-tree", "HEAD", "link").stdout.startswith(b"120000 blob ")
assert git(execution, "ls-tree", "HEAD", "nested").stdout.startswith(b"160000 commit ")
assert git(target, "ls-files", "--stage", "-z").stdout == before_index
manifest, rows = capture(tmp_path, handle)
assert manifest["status"] == "ready_no_changes" and rows == []