diff --git a/CHANGELOG.md b/CHANGELOG.md index 7070a85f..beeed687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Versions follow [SemVer](https://semver.org). ## [Unreleased] +### Changed + +- `merge: auto` merges a blessed head even while its author is still at work. Before, the merge waited until the run had no jobs in flight and no unread messages, so an author that kept revising on each panel round's advisory findings held its own cleared pull request open for days. A human's comment still holds the merge until the author has read it. Merging ends the run and cancels its jobs, as before; further work comes back as a new pull request. + ## [0.6.0] - 2026-10-26 ### Upgrading diff --git a/docs/contract.md b/docs/contract.md index 9a9c4a50..79aa1855 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -154,7 +154,9 @@ vouches for the measurement, and what taste the agents carry. - `merge` controls the end of a clean pull request. The default, `manual`, waits for a human. `merge: auto` lets the kernel merge a clean pull request - at the blessed head. Set branch protection with your repo's own required + at the blessed head, even while its author is still running experiments; + only an unread human comment holds it. Merging ends the run and cancels its + jobs. Set branch protection with your repo's own required checks under strict up-to-date enforcement and no required review. - `verification` controls who measures. The default, `gate`, is the kernel's own measurement, suite checks and panel. `self_report` accepts an diff --git a/src/outerloop/brief.py b/src/outerloop/brief.py index 967f61e3..beba55cd 100644 --- a/src/outerloop/brief.py +++ b/src/outerloop/brief.py @@ -491,6 +491,11 @@ def render(brief: SessionBrief) -> str: "Stopping without a submit ends unmeasured; with a PR open it returns " "to review. An edit in review is measured and pushed only on submit. " 'Withdraw a superseded open PR with `stop --withdraw ""`.', + "When the contract sets merge to auto, the kernel merges a PR as soon " + "as its head is credited with no blocking finding, without waiting for " + "you; advisory " + "findings never hold it. The merge ends this run and cancels its jobs, " + "so work you want on top of it belongs in a later PR.", ] if brief.verification == "gate" else [] diff --git a/src/outerloop/inbox.py b/src/outerloop/inbox.py index c0a83a0f..a4ef6bd1 100644 --- a/src/outerloop/inbox.py +++ b/src/outerloop/inbox.py @@ -925,3 +925,10 @@ def gather_github_messages( def wake_pending(directory: Path, record: RunRecord) -> bool: return any(not m.payload.get("context_only") for m in pending(directory, record.inbox_seq)) + + +def human_pending(directory: Path, record: RunRecord) -> bool: + return any( + m.source == "human" and not m.payload.get("context_only") + for m in pending(directory, record.inbox_seq) + ) diff --git a/src/outerloop/tick.py b/src/outerloop/tick.py index 6c88dc49..c643e987 100644 --- a/src/outerloop/tick.py +++ b/src/outerloop/tick.py @@ -760,7 +760,7 @@ def _merge_blessed_pr( bot_login: str = "", ) -> bool: from outerloop.github import GitHubError - from outerloop.inbox import wake_pending + from outerloop.inbox import human_pending number = int(record.pr_url.rstrip("/").split("/")[-1]) @@ -775,8 +775,10 @@ def why_not(record: RunRecord, pr: dict, dial: str, tip: str, contains_base: boo "no head was blessed at publish" + (f": {record.auto_bless_reason}" if record.auto_bless_reason else ""), ), - (bool(_poll_targets(record)), "the run sleeps on jobs"), - (wake_pending(run_dir(root, record.run_id), record), "a message waits for the author"), + # the author's jobs and the loop's own messages (verdicts, results, CI) + # do not hold a cleared head, or an author that keeps revising never + # lets it merge; a human's comment still waits for the author + (human_pending(run_dir(root, record.run_id), record), "a comment waits for the author"), (pr.get("state") != "open" or bool(pr.get("merged")), "the PR is not open"), (bool(pr.get("draft")), "the PR is a draft"), (head != record.auto_blessed_head, f"the head {head[:8]} is not the blessed one"), @@ -1266,8 +1268,12 @@ def wake(record: RunRecord, reason: str, tag: str) -> None: gather_github_messages( run_dir(root, record.run_id), record, github, bot_login, now, pr ) - merged = _merge_blessed_pr(root, record, github, pr, holder, now, bot_login) - if merged and record.target not in ledger_blocked: + # a held ledger defers close_if_done, which would leave a busy + # author's jobs running past the merge; merge once it clears + merged = record.target not in ledger_blocked and _merge_blessed_pr( + root, record, github, pr, holder, now, bot_login + ) + if merged: ending = close_if_done( root, load_record(root, record.run_id), github, now ) diff --git a/tests/test_ledger_events.py b/tests/test_ledger_events.py index c618f6d9..079c3ad2 100644 --- a/tests/test_ledger_events.py +++ b/tests/test_ledger_events.py @@ -344,7 +344,7 @@ def test_sweep_waits_for_deferred_reset_before_observing_solvers(tmp_path, monke ): monkeypatch.setattr(function, lambda *args, name=function: polled.append(name)) sweep(tmp_path, FakeSlurm().compute(), RecordingDispatcher(), 3, github=github) - assert len(polled) == 6 + assert len(polled) == 4 and "outerloop.tick._merge_blessed_pr" not in polled assert LEDGER_RETRY in load_record(tmp_path, reset_record.run_id).stage assert not fake.ledger_writes @@ -569,7 +569,7 @@ def test_sweep_rebless_refusals(tmp_path, rebless_run, monkeypatch, blocked): elif blocked == "message": append( run_dir(tmp_path, r.run_id), - Message(0, "note", "kernel", "org/repo#1", 2, "note", {"text": "feedback"}), + Message(0, "comment", "human", "org/repo#1", 2, "comment:1", {"body": "why?"}), ) else: reason = { @@ -637,12 +637,12 @@ def test_refused_merge_keeps_the_saved_blessing_for_the_next_tick( assert load_record(tmp_path, r.run_id).state == ENDED -def test_same_tick_merge_observation_respects_ledger_hold(tmp_path, rebless_run, monkeypatch): +def test_merge_waits_for_ledger_hold(tmp_path, rebless_run, monkeypatch): r, fake, merges, _bodies = rebless_run save_record(tmp_path, replace(r, stage={**r.stage, LEDGER_RETRY: {"waiting": True}}), 2) monkeypatch.setattr("outerloop.ledger_events.retry_pending", lambda root, r, *a: r) run_rebless_sweep(tmp_path, fake) - assert merges == [1] + assert merges == [] assert load_record(tmp_path, r.run_id).state == PARKED assert LEADER_FILE not in fake.ledger_files r = load_record(tmp_path, r.run_id) diff --git a/tests/test_tick.py b/tests/test_tick.py index 624450c1..d5313826 100644 --- a/tests/test_tick.py +++ b/tests/test_tick.py @@ -3958,8 +3958,8 @@ def test_legacy_followup_count_logs_even_when_paused(tmp_path, caplog): "blocked", [ "", - "message", - "job", + "busy", + "comment", "lease", "draft", "head", @@ -3979,7 +3979,7 @@ def test_sweep_merges_only_quiet_blessed_pr(tmp_path, blocked, caplog): record = waiting_run( tmp_path, - experiment_job_id="100" if blocked == "job" else "", + experiment_job_id="100" if blocked == "busy" else "", deadline=NOW + 1000, pr_url="https://github.com/org/repo/pull/9", auto_blessed_head="head", @@ -3988,11 +3988,17 @@ def test_sweep_merges_only_quiet_blessed_pr(tmp_path, blocked, caplog): ) if blocked == "running": save_record(tmp_path, replace(record, state=RUNNING), NOW) - if blocked == "message": + if blocked == "busy": + # an author still at work (a job in flight, a verdict unread) does not hold a cleared head append( run_dir(tmp_path, record.run_id), Message(0, "note", "kernel", "org/repo#9", NOW, "note", {"text": "feedback"}), ) + if blocked == "comment": + append( + run_dir(tmp_path, record.run_id), + Message(0, "comment", "human", "org/repo#9", NOW, "comment:1", {"body": "why?"}), + ) if blocked == "lease": acquire_lease(tmp_path, record.run_id, "wake", "100", NOW) @@ -4063,7 +4069,7 @@ def merge_pull(self, repo, number, method, expected_head): bot_login="bot", ) assert github.merged == ( - [] if blocked not in ("", "checks403") else [("org/repo", 9, "squash", "head")] + [] if blocked not in ("", "busy", "checks403") else [("org/repo", 9, "squash", "head")] ) if blocked == "checks403": assert "cannot read check runs" in caplog.text @@ -4074,13 +4080,9 @@ def merge_pull(self, repo, number, method, expected_head): assert "no allowed merge methods" in caplog.text # a blessed PR that is not merged says why in the tick log waits = { - "message": "a message waits for the author", - "job": "the run sleeps on jobs", + "comment": "a comment waits for the author", "draft": "the PR is a draft", - # a moved head or base is first a MESSAGE to the author (head-moved, - # base-moved), so that is the reason the sweep names; only a base that - # moves between the sweep's read and the lease is caught as such - "head": "a message waits for the author", + "head": "the head changed is not the blessed one", "unclean": "GitHub says blocked", "base-race": "the head head does not contain the base tip moved", "manual": "the base contract is not auto", @@ -4088,7 +4090,7 @@ def merge_pull(self, repo, number, method, expected_head): } if blocked in waits: assert waits[blocked] in caplog.text - if blocked in ("", "checks403", "methods"): + if blocked in ("", "busy", "checks403", "methods"): assert read_lease(tmp_path, record.run_id) is None if not blocked: @@ -4445,8 +4447,7 @@ def merge_pull(self, *args, **kwargs): "", "running", "unblessed", - "job", - "wake", + "comment", "base", "tip-error", "tip-empty", @@ -4479,11 +4480,10 @@ def acquire(root, run_id, holder, job, now): updated = replace(record, state="running") elif changed == "unblessed": updated = replace(record, auto_blessed_head="") - elif changed == "job": - updated = replace(record, experiment_job_id="100", deadline=NOW + 100) - elif changed == "wake": + elif changed == "comment": append( - run_dir(root, run_id), Message(0, "note", "kernel", "org/repo#9", now, "wake", {}) + run_dir(root, run_id), + Message(0, "comment", "human", "org/repo#9", now, "comment:1", {"body": "why?"}), ) save_record(root, updated, now) return True