Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/outerloop/brief.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<reason>"`.',
"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 []
Expand Down
7 changes: 7 additions & 0 deletions src/outerloop/inbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
16 changes: 11 additions & 5 deletions src/outerloop/tick.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand All @@ -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"),
Comment thread
renmengye marked this conversation as resolved.
Resolved
(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"),
Expand Down Expand Up @@ -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
)
Expand Down
8 changes: 4 additions & 4 deletions tests/test_ledger_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down
36 changes: 18 additions & 18 deletions tests/test_tick.py
Original file line number Diff line number Diff line change
Expand Up @@ -3958,8 +3958,8 @@ def test_legacy_followup_count_logs_even_when_paused(tmp_path, caplog):
"blocked",
[
"",
"message",
"job",
"busy",
"comment",
"lease",
"draft",
"head",
Expand All @@ -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",
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -4074,21 +4080,17 @@ 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",
"steward": "a steward's PR",
}
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:
Expand Down Expand Up @@ -4445,8 +4447,7 @@ def merge_pull(self, *args, **kwargs):
"",
"running",
"unblessed",
"job",
"wake",
"comment",
"base",
"tip-error",
"tip-empty",
Expand Down Expand Up @@ -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
Expand Down
Loading