From 1d9526b3247cac535cda0d24f718c83d5f17bd49 Mon Sep 17 00:00:00 2001 From: Mengye Ren Date: Wed, 30 Sep 2026 10:11:28 -0400 Subject: [PATCH] Line snapshots seal only admitted paths: drop out-of-scope changes, keep work and line memory Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 7 ++ src/outerloop/attempt.py | 105 +++++++++++++++-- src/outerloop/dispatch.py | 9 +- tests/test_attempt.py | 234 +++++++++++++++++++++++++++++++++++--- 4 files changed, 328 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2f9322e..7d8d16c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ Versions follow [SemVer](https://semver.org). ## [Unreleased] +- Research-line salvage and terminal snapshots now recheck scope admission + against the trusted contract before sealing. Out-of-scope changes are + dropped from the seal (tracked paths retain their parent content), while + admitted work and line memory survive normal endings and crashes after + scope refusals. Filtering leaves working files and the real index untouched + and logs a bounded list of dropped paths. No persisted-state format changes. + - Launch, submit, and stale-submit checkpoint scope violations now refuse every request and resume the author with the offending paths and bounded allowed scope in the kernel inbox. Later refusals say “Refused again:”. Refusals diff --git a/src/outerloop/attempt.py b/src/outerloop/attempt.py index 3ed957be..3d654c32 100644 --- a/src/outerloop/attempt.py +++ b/src/outerloop/attempt.py @@ -97,8 +97,10 @@ attempt_once, benchmark_floor, clears_min_delta, + out_of_scope, pr_body, resume_attempt, + steward_out_of_scope, ) from outerloop.panel import PanelLens, PanelVerdict, run_panel from outerloop.paths import CONFIG_DIR @@ -1351,8 +1353,6 @@ def post_leg_replies(messages: tuple[dict, ...]) -> None: lambda: syscall_write_siblings(workspace, _sibling_entries(ws, config.agent_id)), ) if record.agent_id.startswith("steward"): - from outerloop.orchestrator import steward_out_of_scope - kwargs.setdefault("scope_validator", steward_out_of_scope) kwargs.setdefault("ruler", RULER) if not record.resume_session_id: @@ -2198,7 +2198,7 @@ def _is_ancestor(ws: Workspace, older: str, newer: str) -> bool: return False -def _line_head(ws: Workspace, line_ref: str, branch: str) -> str: +def _line_head(ws: Workspace, line_ref: str, branch: str, *, session_commits: bool = True) -> str: """The commit the seal parents on: the line's head as the kernel knows it, not wherever the session left the checked-out branch (#368). The kernel's record (`LINE_HEAD_REF`, written at checkout and after each push) is the @@ -2216,6 +2216,8 @@ def _line_head(ws: Workspace, line_ref: str, branch: str) -> str: log.info("line %s: the kernel's record is off the line; using the remote head", line_ref) record = "" head = record or remote or branch + if not session_commits: + return head if head == branch: return branch if _is_ancestor(ws, head, branch): @@ -2244,6 +2246,29 @@ def _log_line_snapshot( log.log(level, "%s", redact(message, secrets).replace("\n", " ").replace("\r", " ")) +def _out_of_scope_working_tree( + ws: Workspace, contract: Contract, revision: str, line_ref: str +) -> list[str]: + """Find changes against a trusted revision without resetting the index.""" + scope_validator = ( + steward_out_of_scope if line_ref.startswith("agents/steward") else out_of_scope + ) + ensure_regular_git_dir(ws.root) + paths = set(ws.git("diff", "--no-renames", "--name-only", "-z", revision).split("\0")) + paths.update(ws.git("ls-files", "--others", "--exclude-standard", "-z").split("\0")) + return scope_validator( + sorted(p for p in paths if p and not (line_ref and _is_line_memory(p))), contract + ) + + +def _admit_working_tree(ws: Workspace, contract: Contract, base_branch: str, line_ref: str) -> None: + """Apply the orchestrator's scope admission before a capacity park.""" + common = _scope_revision(ws, f"refs/remotes/origin/{base_branch}", "HEAD") + violations = _out_of_scope_working_tree(ws, contract, common, line_ref) + if violations: + raise ValueError(f"snapshot skipped: scope admission refused: {', '.join(violations)}") + + def _push_line_snapshot( ws: Workspace, line_ref: str, @@ -2251,6 +2276,8 @@ def _push_line_snapshot( outcome: str, secrets: tuple[str, ...] = (), bot_login: str = "", + *, + contract: Contract, ) -> None: """Publish the session's final tree to the agent's line as a sealed snapshot commit — every terminal path, any outcome @@ -2276,11 +2303,24 @@ def report(detail: str) -> None: "snapshot skipped: no GitHub auth" if ws.auth is None else "snapshot skipped: dry run" ) return + dropped: set[str] = set() try: branch = ws.git("rev-parse", f"refs/heads/{line_ref}").strip() seen = ws.git("rev-parse", "HEAD").strip() # Track untouched files against each reconciled head across retries. fork = _line_head(ws, line_ref, branch) + trusted = _line_head(ws, line_ref, branch, session_commits=False) + if fork != trusted: + # Do not publish forbidden changes through session commit ancestry, + # even if a subsequent commit reverted them. Seal on the known line. + committed = ws.git( + "log", "-m", "--format=", "--name-only", "--no-renames", "-z", f"{trusted}..{fork}" + ).split("\0") + validator = ( + steward_out_of_scope if line_ref.startswith("agents/steward") else out_of_scope + ) + if validator(sorted(p for p in committed if p and not _is_line_memory(p)), contract): + fork = trusted for attempt in range(3): parent = fork operation = "fetch" @@ -2295,8 +2335,15 @@ def report(detail: str) -> None: fork = parent = remote operation = "seal" _restore_line_memory(ws, parent, seen=seen) + # Filter against the actual parent, including on a moved-line retry. + # Only the snapshot's private index is sanitized: later measurement + # and publication steps still see the session's original files/index. + violations = _out_of_scope_working_tree(ws, contract, parent, line_ref) + dropped.update(violations) memory = tuple(p for p in LINE_MEMORY_PATHS if (Path(ws.root) / p).exists()) - snap = snapshot_tree(ws, parent, force=memory, author=bot_login) + snap = snapshot_tree( + ws, parent, force=memory, author=bot_login, restore_from_base=tuple(violations) + ) try: # seal only when the tree moved past the parent; the push runs # either way, since a session that committed its work advanced @@ -2336,6 +2383,10 @@ def report(detail: str) -> None: raise except Exception as exc: report(f"failed: {type(exc).__name__}: {exc}") + finally: + if dropped: + paths = ", ".join(repr(p) for p in sorted(dropped)[:20])[:1000] + report(f"snapshot dropped {len(dropped)} out-of-scope paths: {paths}") def _reconcile_with_remote(ws: Workspace, old: str, new: str) -> None: @@ -2967,6 +3018,7 @@ def _wake_author( result.outcome, secrets, bot_login=config.bot_login, + contract=contract, ) ws.git("checkout", "-f", "--detach", candidate_sha) ws.git("clean", "-fd") @@ -3092,6 +3144,7 @@ def _wake_author( NEGATIVE_RESULT, secrets, bot_login=config.bot_login, + contract=contract, ) finish_run( run_root, @@ -3559,7 +3612,13 @@ def _finish_attempt( report_path.write_text(result.report(config, redact_secrets=secrets)) if not snapshot_attempted and not result.tree_rejected: _push_line_snapshot( - ws, line_ref, run_id, result.outcome, secrets, bot_login=config.bot_login + ws, + line_ref, + run_id, + result.outcome, + secrets, + bot_login=config.bot_login, + contract=contract, ) if record.pr_url: final = dc_replace(_clear_stage(record, run_root), state=PARKED) @@ -3707,7 +3766,13 @@ def publish( # publish-error snapshot at the tail. if not snapshot_attempted: _push_line_snapshot( - ws, line_ref, run_id, result.outcome, secrets, bot_login=config.bot_login + ws, + line_ref, + run_id, + result.outcome, + secrets, + bot_login=config.bot_login, + contract=contract, ) record = dc_replace( @@ -4277,7 +4342,15 @@ def refuse( # the publish failed after the gate credited the tree: the improved # snapshot above stands (the measurement was real); a second seal # records how the run ended - _push_line_snapshot(ws, line_ref, run_id, outcome_name, secrets, bot_login=config.bot_login) + _push_line_snapshot( + ws, + line_ref, + run_id, + outcome_name, + secrets, + bot_login=config.bot_login, + contract=contract, + ) log.info("run %s: %s %s", run_id, outcome_name, pr_url) return AttemptOutcome( run_id=run_id, @@ -4443,7 +4516,7 @@ def live_attempt( line_memory = "" line_divergence = "" if line_ref: - salvage.update(ws=ws, line_ref=line_ref) + salvage.update(ws=ws, line_ref=line_ref, contract=contract) try: # the line's own memory index, rendered into the brief # (data-fenced there); topic files are read on demand from @@ -4675,6 +4748,7 @@ def acknowledge(seq: int) -> None: ) except EndpointUnavailable as exc: # Reuse a jobless capacity park; its first wake starts the author. + _admit_working_tree(ws, contract, base_branch, line_ref) sha = snapshot() kept_ref = snapshots[-1].ref p = RunParked( @@ -4760,6 +4834,7 @@ def acknowledge(seq: int) -> None: "attempt-error", secrets, bot_login=config.bot_login, + contract=cast(Contract, salvage["contract"]), ) failed = RunRecord( **{ @@ -5540,12 +5615,22 @@ def finish_run( if not snapshot_attempted and ws is not None and ws.root.is_dir(): line_ref = f"agents/{record.agent_id}" try: + base_branch = str(record.stage.get("base_branch") or "main") contract = load_contract( - contract_at(ws, str(record.stage.get("base_sha") or "HEAD")), record.target + contract_at(ws, str(record.stage.get("base_sha") or f"origin/{base_branch}")), + record.target, ) bench = _benchmark(contract, record.benchmark) line_ref = _line_ref_for(bench, record.agent_id) - _push_line_snapshot(ws, line_ref, record.run_id, ending, secrets, bot_login=bot_login) + _push_line_snapshot( + ws, + line_ref, + record.run_id, + ending, + secrets, + bot_login=bot_login, + contract=contract, + ) except Exception as exc: _log_line_snapshot( "prepare", diff --git a/src/outerloop/dispatch.py b/src/outerloop/dispatch.py index bf0789dc..0209b3ef 100644 --- a/src/outerloop/dispatch.py +++ b/src/outerloop/dispatch.py @@ -116,6 +116,7 @@ def snapshot_tree( exclude: tuple[str, ...] = (), force: tuple[str, ...] = (), author: str = "", + restore_from_base: tuple[str, ...] = (), ) -> Snapshot: """Snapshot the workspace's current CONTENT as a commit parented on `base_sha`, without touching the working index, and retain it under a @@ -127,7 +128,9 @@ def snapshot_tree( when the target's ignore rules match them — the notebook seal uses it so a .gitignore entry cannot silently discard session memory; callers pass only paths that exist. `author` is the bot login the seal commit is - made as (empty: OUTERLOOP_BOT_LOGIN). + made as (empty: OUTERLOOP_BOT_LOGIN). `restore_from_base` resets exact + paths to their parent content (or removes them when absent there), in + the private index only; working files and the real index stay untouched. """ # the snapshot writes an index, a tree, a commit, and a ref into this # repository: a session-reshaped .git is refused first, like every other @@ -176,6 +179,10 @@ def run(args: list[str], timeout: int) -> str: run([*git, "add", "-f", "--", *force], 60) if exclude: run([*git, "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", *exclude], 60) + if restore_from_base: + # Literal pathspecs: agent-written filenames may contain glob syntax. + paths = [f":(literal){path}" for path in restore_from_base] + run([*git, "reset", "-q", base_sha, "--", *paths], 60) # .gitattributes are KEPT: the job materializes the tree by CHECKOUT # (git worktree), which reproduces content faithfully — including # .gitattributes — and does NOT apply export-ignore/export-subst diff --git a/tests/test_attempt.py b/tests/test_attempt.py index 89ff676e..9452ab05 100644 --- a/tests/test_attempt.py +++ b/tests/test_attempt.py @@ -14,6 +14,7 @@ from ledger_fake import LedgerGitHub from outerloop import attempt as climb_mod from outerloop.attempt import _park_run, live_attempt, resume_run +from outerloop.contract import load_contract from outerloop.dispatch import Snapshot from outerloop.harness import SessionResult from outerloop.orchestrator import RunConfig, RunParked @@ -30,6 +31,14 @@ roadmap: docs/roadmap.md """ +SNAPSHOT_CONTRACT = load_contract( + CONTRACT.replace( + "[src/pilot/solvers/]", + "[src/pilot/solvers/, docs/belief.md, train.py, notes.txt, config.txt, .gitignore]", + ), + "org/pilot", +) + # Same contract with an eval hint past the in-job runway, so `should_dispatch` # selects the dispatched backend. CONTRACT_DISPATCH = CONTRACT.replace( @@ -4254,7 +4263,9 @@ def test_push_line_snapshot_publishes_the_terminal_tree(tmp_path: Path, target_r ws = _line_ws(tmp_path, target_repo) _checkout_line(ws, ws.root, "agent-07", "main") (ws.root / "docs" / "belief.md").write_text("depth pays\n") - _push_line_snapshot(ws, "agents/agent-07", "tsp-9", "no-improvement") + _push_line_snapshot( + ws, "agents/agent-07", "tsp-9", "no-improvement", contract=SNAPSHOT_CONTRACT + ) assert _git(target_repo, "show", "agents/agent-07:docs/belief.md") == "depth pays\n" msg = _git(target_repo, "log", "-1", "--format=%s", "agents/agent-07").strip() assert "tsp-9" in msg and "no-improvement" in msg @@ -4271,7 +4282,9 @@ def test_push_line_snapshot_skips_an_unchanged_tree(tmp_path: Path, target_repo) ws = _line_ws(tmp_path, target_repo) _checkout_line(ws, ws.root, "agent-07", "main") before = _git(target_repo, "rev-parse", "agents/agent-07").strip() - _push_line_snapshot(ws, "agents/agent-07", "tsp-9", "no-improvement") + _push_line_snapshot( + ws, "agents/agent-07", "tsp-9", "no-improvement", contract=SNAPSHOT_CONTRACT + ) assert _git(target_repo, "rev-parse", "agents/agent-07").strip() == before @@ -4281,10 +4294,12 @@ def test_push_line_snapshot_chains_sequential_terminals(tmp_path: Path, target_r ws = _line_ws(tmp_path, target_repo) _checkout_line(ws, ws.root, "agent-07", "main") (ws.root / "docs" / "belief.md").write_text("first\n") - _push_line_snapshot(ws, "agents/agent-07", "tsp-9", "no-improvement") + _push_line_snapshot( + ws, "agents/agent-07", "tsp-9", "no-improvement", contract=SNAPSHOT_CONTRACT + ) first = _git(target_repo, "rev-parse", "agents/agent-07").strip() (ws.root / "docs" / "belief.md").write_text("second\n") - _push_line_snapshot(ws, "agents/agent-07", "tsp-9", "eval-error") + _push_line_snapshot(ws, "agents/agent-07", "tsp-9", "eval-error", contract=SNAPSHOT_CONTRACT) tip = _git(target_repo, "rev-parse", "agents/agent-07").strip() assert _git(target_repo, "rev-parse", f"{tip}^").strip() == first # fast-forward chain assert _git(target_repo, "show", "agents/agent-07:docs/belief.md") == "second\n" @@ -4317,7 +4332,7 @@ def test_line_seal_parents_on_the_kernels_head_not_the_checked_out_branch( # ...and it starts a fresh topic file in the (recreated) memory directory (ws.root / "agent_memory").mkdir() (ws.root / "agent_memory" / "wd.md").write_text("wd 0.01\n") - _push_line_snapshot(ws, "agents/agent-07", "tsp-9", "improved") + _push_line_snapshot(ws, "agents/agent-07", "tsp-9", "improved", contract=SNAPSHOT_CONTRACT) tip = _git(target_repo, "rev-parse", "agents/agent-07").strip() assert _git(target_repo, "rev-parse", f"{tip}^").strip() == line_tip # on the line, not main assert _git(target_repo, "show", "agents/agent-07:AGENT_MEMORY.md") == "remember the pivot\n" @@ -4352,7 +4367,9 @@ def test_line_seal_survives_a_session_removing_or_moving_the_kernels_record( ws.git("reset", "-q", "--hard", "origin/main") assert not (ws.root / "AGENT_MEMORY.md").exists() (ws.root / "docs" / "belief.md").write_text(f"after the reset ({tamper})\n") - _push_line_snapshot(ws, "agents/agent-07", f"tsp-{tamper}", "improved") + _push_line_snapshot( + ws, "agents/agent-07", f"tsp-{tamper}", "improved", contract=SNAPSHOT_CONTRACT + ) tip = _git(target_repo, "rev-parse", "agents/agent-07").strip() assert _git(target_repo, "rev-parse", f"{tip}^").strip() == line_tip, tamper assert _git(target_repo, "show", "agents/agent-07:AGENT_MEMORY.md") == ( @@ -4376,7 +4393,7 @@ def test_line_seal_prefers_the_lines_memory_over_mains_stale_copy( ws.git("checkout", "-q", "--detach", "origin/main") # the line branch itself is untouched assert (ws.root / "AGENT_MEMORY.md").read_text() == "main's stale copy\n" (ws.root / "docs" / "belief.md").write_text("after the reset\n") - _push_line_snapshot(ws, "agents/agent-07", "tsp-9", "improved") + _push_line_snapshot(ws, "agents/agent-07", "tsp-9", "improved", contract=SNAPSHOT_CONTRACT) assert ( _git(target_repo, "show", "agents/agent-07:AGENT_MEMORY.md") == "the line's newer memory\n" ) @@ -4399,7 +4416,9 @@ def test_line_seal_keeps_a_memory_deletion_the_session_made_on_the_line( _checkout_line(ws, ws.root, "agent-07", "main") (ws.root / "agent_memory" / "lr.md").unlink() # the agent retires a topic (ws.root / "AGENT_MEMORY.md").write_text("fresh\n") - _push_line_snapshot(ws, "agents/agent-07", "tsp-9", "no-improvement") + _push_line_snapshot( + ws, "agents/agent-07", "tsp-9", "no-improvement", contract=SNAPSHOT_CONTRACT + ) tree = _git(target_repo, "ls-tree", "-r", "--name-only", "agents/agent-07") assert "agent_memory/lr.md" not in tree assert _git(target_repo, "show", "agents/agent-07:AGENT_MEMORY.md") == "fresh\n" @@ -4409,9 +4428,11 @@ def test_push_line_snapshot_is_best_effort(tmp_path: Path, target_repo) -> None: from outerloop.attempt import _push_line_snapshot ws = _line_ws(tmp_path, target_repo) - _push_line_snapshot(ws, "", "tsp-9", "no-improvement") # feature off: no-op _push_line_snapshot( - ws, "agents/agent-99", "tsp-9", "no-improvement" + ws, "", "tsp-9", "no-improvement", contract=SNAPSHOT_CONTRACT + ) # feature off: no-op + _push_line_snapshot( + ws, "agents/agent-99", "tsp-9", "no-improvement", contract=SNAPSHOT_CONTRACT ) # no such ref: logged skip with pytest.raises(subprocess.CalledProcessError): _git(target_repo, "rev-parse", "agents/agent-99") @@ -4482,7 +4503,9 @@ def test_push_line_snapshot_publishes_session_commits(tmp_path: Path, target_rep (ws.root / "docs" / "belief.md").write_text("committed by the session\n") ws.git("add", "-A") ws.git("-c", "user.name=s", "-c", "user.email=s@s", "commit", "-qm", "agent commit") - _push_line_snapshot(ws, "agents/agent-07", "tsp-9", "no-improvement") + _push_line_snapshot( + ws, "agents/agent-07", "tsp-9", "no-improvement", contract=SNAPSHOT_CONTRACT + ) assert ( _git(target_repo, "show", "agents/agent-07:docs/belief.md") == "committed by the session\n" ) @@ -4583,7 +4606,9 @@ def test_notebook_keeps_memory_the_target_gitignores(tmp_path: Path, target_repo (ws.root / "AGENT_MEMORY.md").write_text("survives the ignore\n") (ws.root / "agent_memory").mkdir() (ws.root / "agent_memory" / "muon.md").write_text("notes\n") - _push_line_snapshot(ws, "agents/agent-07", "tsp-9", "no-improvement") + _push_line_snapshot( + ws, "agents/agent-07", "tsp-9", "no-improvement", contract=SNAPSHOT_CONTRACT + ) tree = _git(target_repo, "ls-tree", "-r", "--name-only", "agents/agent-07") assert "AGENT_MEMORY.md" in tree and "agent_memory/muon.md" in tree @@ -4621,7 +4646,8 @@ def broken_checkout(*a, **k): assert "src/pilot/solvers/tsp.py" in published and "AGENT_MEMORY.md" not in published -def test_wake_terminal_pushes_the_line_notebook(tmp_path, monkeypatch) -> None: +@pytest.mark.parametrize("cruft", [False, True]) +def test_wake_terminal_pushes_the_line_notebook(tmp_path, monkeypatch, cruft, caplog) -> None: """A negative candidate wake on a lines run lands the session's final tree — memory included — on the agent's branch.""" state, run_id = _write_parked_candidate( @@ -4634,7 +4660,11 @@ def test_wake_terminal_pushes_the_line_notebook(tmp_path, monkeypatch) -> None: agent_id="agent-07", ) wsroot = state / "runs" / run_id / "ws" + if not cruft: + (wsroot / "eval-cache.tmp").unlink() (wsroot / "AGENT_MEMORY.md").write_text("- candidate was flat\n") + (wsroot / "agent_memory").mkdir(exist_ok=True) + (wsroot / "agent_memory" / "candidate.md").write_text("keep these observations\n") outcome = resume_run( state, run_id, @@ -4647,8 +4677,20 @@ def test_wake_terminal_pushes_the_line_notebook(tmp_path, monkeypatch) -> None: bare = tmp_path / f"origin-{run_id}.git" tree = _git(bare, "ls-tree", "-r", "--name-only", "agents/agent-07") assert "AGENT_MEMORY.md" in tree + assert "eval-cache.tmp" not in tree + assert _git(bare, "show", "agents/agent-07:AGENT_MEMORY.md") == "- candidate was flat\n" + assert _git(bare, "show", "agents/agent-07:agent_memory/candidate.md") == ( + "keep these observations\n" + ) + assert _git(bare, "show", "agents/agent-07:src/pilot/solvers/tsp.py") == ( + "def solve(): return 'better'\n" + ) msg = _git(bare, "log", "-1", "--format=%s", "agents/agent-07").strip() assert run_id in msg and "no-improvement" in msg + logs = [r.message for r in caplog.records if "snapshot dropped" in r.message] + assert len(logs) == int(cruft) + if cruft: + assert "eval-cache.tmp" in logs[0] def test_line_memory_reaches_the_next_session_brief(tmp_path: Path, target_repo_lines) -> None: @@ -5096,6 +5138,7 @@ def test_line_snapshot_parents_on_a_remote_line_that_moved_while_parked(tmp_path bare = tmp_path / "origin.git" _git(tmp_path, "clone", "-q", "--bare", str(wsroot), str(bare)) _git(wsroot, "remote", "add", "origin", str(bare)) + _git(wsroot, "fetch", "-q", "origin") _git(wsroot, "branch", "agents/agent-01", base) _git(wsroot, "push", "-q", "origin", "agents/agent-01") # the sibling run advances the remote line while we are parked @@ -5122,7 +5165,11 @@ def test_line_snapshot_parents_on_a_remote_line_that_moved_while_parked(tmp_path # our run ends with its own tree (wsroot / "train.py").write_text("winner\n") _push_line_snapshot( - Workspace(root=wsroot, auth=NoAuth()), "agents/agent-01", "run-1", "improved" + Workspace(root=wsroot, auth=NoAuth()), + "agents/agent-01", + "run-1", + "improved", + contract=SNAPSHOT_CONTRACT, ) head = _git(bare, "rev-parse", "agents/agent-01").strip() assert head != sibling @@ -5154,6 +5201,7 @@ def test_line_snapshot_reseals_when_the_line_moves_between_fetch_and_push( bare = tmp_path / "origin.git" _git(tmp_path, "clone", "-q", "--bare", str(wsroot), str(bare)) _git(wsroot, "remote", "add", "origin", str(bare)) + _git(wsroot, "fetch", "-q", "origin") _git(wsroot, "branch", "agents/agent-01", base) _git(wsroot, "push", "-q", "origin", "agents/agent-01") other = tmp_path / "other" @@ -5181,7 +5229,11 @@ def racing_push(self: Workspace, branch: str) -> None: monkeypatch.setattr(attempt_mod.Workspace, "push", racing_push) (wsroot / "train.py").write_text("winner\n") _push_line_snapshot( - Workspace(root=wsroot, auth=NoAuth()), "agents/agent-01", "run-1", "improved" + Workspace(root=wsroot, auth=NoAuth()), + "agents/agent-01", + "run-1", + "improved", + contract=SNAPSHOT_CONTRACT, ) sibling = _git(other, "rev-parse", "HEAD").strip() head = _git(bare, "rev-parse", "agents/agent-01").strip() @@ -5754,6 +5806,8 @@ def test_failed_submitted_park_without_resume_ends(tmp_path, monkeypatch, pr_url contract=CONTRACT_LINES, agent_id="agent-01", ) + # Exercise the terminal publish with an admitted working tree. + (state / "runs" / run_id / "ws" / "eval-cache.tmp").unlink() record = load_record(state, run_id) save_record( state, @@ -6096,7 +6150,14 @@ def snapshot(*args, **kwargs): monkeypatch.setattr(ws, "fetch_origin" if operation == "fetch" else "push", fail) monkeypatch.setattr(climb_mod, "snapshot_tree", snapshot) caplog.set_level("INFO", logger="outerloop.attempt") - _push_line_snapshot(ws, "agents/agent-07", "failure-run", "no-improvement", ("private-token",)) + _push_line_snapshot( + ws, + "agents/agent-07", + "failure-run", + "no-improvement", + ("private-token",), + contract=SNAPSHOT_CONTRACT, + ) assert calls == [operation] assert len(seals) == (0 if operation == "fetch" else 1) logs = [r.message for r in caplog.records if "failed:" in r.message] @@ -7621,3 +7682,144 @@ def no_snapshot(*args, **kwargs): ) assert outcome.outcome == ("negative-result" if submitted else "scope-violation") assert load_record(state, run_id).state == "ended" + + +def test_scope_refusal_resume_exception_salvages_only_admitted_paths( + tmp_path, target_repo_lines, monkeypatch, caplog +): + from outerloop import orchestrator + + tips = [] + real_append = orchestrator.append + + def crash_after_refusal(directory, message): + if "Refused:" in str(message.payload): + tips.append(_git(target_repo_lines, "rev-parse", "agents/agent-01").strip()) + raise RuntimeError("injected at resume entry after scope refusal") + return real_append(directory, message) + + monkeypatch.setattr(orchestrator, "append", crash_after_refusal) + with _queued_local([]): + outcome = live_attempt( + config=RunConfig(target="org/pilot", benchmark="tsp"), + run_root=tmp_path / "state", + run_id="scope-crash", + harness=ScriptedHarness( + edits={ + "rejected.txt": "must never be sealed", + "src/pilot/solvers/tsp.py": "admitted work\n", + "AGENT_MEMORY.md": "preserve notebook\n", + ".outerloop/syscall.json": json.dumps( + {"type": "sleep", "submit": True, "report": "candidate"} + ), + } + ), + github=FakeGitHub(), # type: ignore[arg-type] + bot_auth=NoAuth(), + now=1_000_000.0, + created="2026-08-06T00:00:00Z", + dispatch=_fake_dispatch(), + ) + assert outcome.outcome == "attempt-error" + assert len(tips) == 1 + assert _git(target_repo_lines, "rev-parse", "agents/agent-01^").strip() == tips[0] + tree = _git(target_repo_lines, "ls-tree", "-r", "--name-only", "agents/agent-01") + assert "rejected.txt" not in tree + assert ( + _git(target_repo_lines, "show", "agents/agent-01:AGENT_MEMORY.md") == "preserve notebook\n" + ) + assert ( + _git(target_repo_lines, "show", "agents/agent-01:src/pilot/solvers/tsp.py") + == "admitted work\n" + ) + logs = [r.message for r in caplog.records if "snapshot dropped" in r.message] + assert len(logs) == 1 and "rejected.txt" in logs[0] + + +def test_clean_tree_still_salvages_after_attempt_exception( + tmp_path, target_repo_lines, monkeypatch +): + def crash(config, contract_text, workspace, *args, **kwargs): + (workspace / "src/pilot/solvers/tsp.py").write_text("admitted work\n") + (workspace / "AGENT_MEMORY.md").write_text("preserve notebook\n") + raise RuntimeError("injected attempt failure") + + monkeypatch.setattr(climb_mod, "attempt_once", crash) + outcome = live_attempt( + config=RunConfig(target="org/pilot", benchmark="tsp"), + run_root=tmp_path / "state", + run_id="clean-crash", + harness=ScriptedHarness(edits={}), + github=FakeGitHub(), # type: ignore[arg-type] + bot_auth=NoAuth(), + now=1_000_000.0, + created="2026-08-06T00:00:00Z", + ) + assert outcome.outcome == "attempt-error" + assert ( + _git(target_repo_lines, "show", "agents/agent-01:src/pilot/solvers/tsp.py") + == "admitted work\n" + ) + assert ( + _git(target_repo_lines, "show", "agents/agent-01:AGENT_MEMORY.md") == "preserve notebook\n" + ) + + +@pytest.mark.parametrize("change", ["edit", "delete", "add", "commit", "revert", "merge"]) +def test_line_snapshot_restores_protected_paths_only_in_seal(tmp_path, target_repo, change, caplog): + from outerloop.attempt import _checkout_line, _push_line_snapshot + + ws = _line_ws(tmp_path, target_repo) + _checkout_line(ws, ws.root, "agent-07", "main") + parent = ws.git("rev-parse", "HEAD").strip() + protected = ".outerloop.yaml" if change != "add" else "report[1].log" + path = ws.root / protected + if change == "delete": + path.unlink() + else: + path.write_text("out of scope\n") + ws.git("add", "--", protected) + if change == "merge": + identity = ("-c", "user.name=s", "-c", "user.email=s@s") + side = ws.git(*identity, "commit-tree", f"{parent}^{{tree}}", "-p", parent, "-m", "side") + merged = ws.git( + *identity, + "commit-tree", + ws.git("write-tree"), + "-p", + parent, + "-p", + side, + "-m", + "forbidden merge resolution", + ) + ws.git("update-ref", "HEAD", merged) + if change in {"commit", "revert"}: + ws.git("-c", "user.name=s", "-c", "user.email=s@s", "commit", "-qm", "forbidden") + if change == "revert": + ws.git("checkout", parent, "--", protected) + ws.git("-c", "user.name=s", "-c", "user.email=s@s", "commit", "-qm", "revert forbidden") + (ws.root / "src/pilot/solvers/tsp.py").write_text("admitted work\n") + (ws.root / "AGENT_MEMORY.md").write_text("notes\n") + index = (ws.root / ".git/index").read_bytes() + _push_line_snapshot( + ws, "agents/agent-07", "protected", "no-improvement", contract=SNAPSHOT_CONTRACT + ) + assert not _git(target_repo, "diff", parent, "agents/agent-07", "--", protected) + assert _git(target_repo, "rev-parse", "agents/agent-07^").strip() == parent + assert ( + _git(target_repo, "show", "agents/agent-07:src/pilot/solvers/tsp.py") == "admitted work\n" + ) + assert _git(target_repo, "show", "agents/agent-07:AGENT_MEMORY.md") == "notes\n" + assert (ws.root / ".git/index").read_bytes() == index + if change == "delete": + assert not path.exists() + elif change == "revert": + assert path.read_text() == _git(ws.root, "show", f"{parent}:{protected}") + else: + assert path.read_text() == "out of scope\n" + logs = [r.message for r in caplog.records if "snapshot dropped" in r.message] + if change == "revert": + assert not logs # Only ancestry was dropped; the final content was already restored. + else: + assert len(logs) == 1 and protected in logs[0]