M3.3: the trust dial (the agent merges approved PRs)
A project's trust.merge is operator (the default) or approved (L3, D5, #65). Set to approved, the harness, never the model, merges a PR in the PR watch once an approving review from a gitea.operators account is on its current commit, its checks pass (none at all counts; pending waits) and it's mergeable; the item then goes on as after any merge. Each reason to hold off (failed checks, conflicts, a refused merge) is said once per commit. Gitea enforces it: protection requires approvals from an operators-only whitelist and dismisses stale ones. At start, for each approved project, the agent checks by behaviour that its own unapproved merge is refused, with a throwaway branch and PR made through the API (never the workspace); if it isn't refused, the operator merges until a restart. - gitea: approvals, combined check status, a PR's head commit and mergeability, API-made branches and their deletion. - Test and bring-up repositories get the protection. - Tests: the decision (unit), the hard wall on a real Gitea (test_trust_live), an e2e test where the operator only approves, and a walk property, merges_only_approved, checked against a mutant that ignores which commit was approved. Closes #65 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
1 parent
21cbf1682c
commit
ab469b60bb
17 files changed
+733
-11
No files matched your search
@@ -243,8 +243,33 @@ class Walk:
|
||||
self.steps: list[str] = []
|
||||
self.shown_at_go: list[str] = [] # item:revision on screen at each go
|
||||
self.now = 1000.0
|
||||
# The trust dial (M3.3): half the walks have the agent merge approved
|
||||
# PRs, behind protection that refuses anything else.
|
||||
self.merge = "approved" if seed % 2 == 0 else "operator"
|
||||
self.agent_merges: list[tuple[int, bool]] = [] # (PR, approved at head)
|
||||
if self.merge == "approved":
|
||||
for w in worlds.values():
|
||||
self._watch_merges(w["forge"])
|
||||
self.agent, self.transport = self._agent()
|
||||
|
||||
def _watch_merges(self, forge: Forge) -> None:
|
||||
"""Protected, and each merge the agent asks for recorded with
|
||||
whether an operator had approved the PR as it was."""
|
||||
forge.protect()
|
||||
merge = forge.merge_pull_request
|
||||
|
||||
def recorded(repo: str, index: int) -> None:
|
||||
if not forge.prs[index]["head"].startswith("nisse-check-"):
|
||||
head = forge.head_sha(index)
|
||||
approved = any(
|
||||
a.author == "don" and a.commit == head and not a.stale
|
||||
for a in forge.approved.get(index, [])
|
||||
)
|
||||
self.agent_merges.append((index, approved))
|
||||
merge(repo, index)
|
||||
|
||||
forge.merge_pull_request = recorded # type: ignore[method-assign]
|
||||
|
||||
def reads_yes(self, proposal: str, reply: str) -> bool:
|
||||
"""The scripted go-ahead reader: a plain yes usually reads as one,
|
||||
and nothing else ever does."""
|
||||
@@ -270,6 +295,7 @@ class Walk:
|
||||
repo=w["repo_name"],
|
||||
workspace=Workspace(w["host"], w["repo"].root, timeout_s=30),
|
||||
milestones=w["milestones"],
|
||||
merge=self.merge,
|
||||
)
|
||||
for name, w in self.worlds.items()
|
||||
],
|
||||
@@ -342,6 +368,8 @@ class Walk:
|
||||
weights.update(
|
||||
{"comment": 4, "merge": 2, "close-pr": 1, "comment-merge": 2}
|
||||
)
|
||||
if self.merge == "approved":
|
||||
weights.update({"approve": 4})
|
||||
action = self.rng.choices(list(weights), list(weights.values()))[0]
|
||||
self.steps.append(f"{action}{self.name(item)}" if item else action)
|
||||
if action == "intake":
|
||||
@@ -455,6 +483,16 @@ class Walk:
|
||||
self.agent.watch()
|
||||
forge.merge(loop.pr)
|
||||
self.agent._schedule()
|
||||
elif action == "approve" and loop and forge.prs[loop.pr]["state"] == "open":
|
||||
# An operator's approval, or someone else's; of the PR as it is,
|
||||
# or of an earlier commit.
|
||||
forge.approve(
|
||||
loop.pr,
|
||||
by=self.rng.choice(["don", "don", "mallory"]),
|
||||
sha=self.rng.choice(["", "0" * 40]),
|
||||
)
|
||||
self.agent.watch()
|
||||
self.agent._schedule()
|
||||
elif action == "merge" and loop and forge.prs[loop.pr]["state"] == "open":
|
||||
forge.merge(loop.pr)
|
||||
self.agent.watch()
|
||||
@@ -497,6 +535,10 @@ class Walk:
|
||||
|
||||
def check(self) -> None:
|
||||
where = f"seed {self.seed}, steps {self.steps}"
|
||||
# D5: the agent asks Gitea to merge only what an operator approved
|
||||
# as it is (Gitea would refuse the rest; the harness mustn't ask).
|
||||
unapproved = [pr for pr, approved in self.agent_merges if not approved]
|
||||
assert not unapproved, f"merges_only_approved: {unapproved}: {where}"
|
||||
self._dangerous_only_when_allowed(where)
|
||||
store = self.world["store"]
|
||||
items = self.items()
|
||||
|
||||
Reference in new issue
Block a user