What isn't the dev agent's moves to nisse-core, unchanged in behaviour, with its tests: canonical, render, render_anthropic, llm, catalog, probe, context, views, state, reconcile, worker, cancel, dump, transport, matrix, execution, testing, vault, memory, oplog, envfile and gitea (whole). - nisse_core.state.StateStore holds the tables every agent has; an agent's subclass adds its tables (version, migrations), roles and views. nisse_dev.state.StateStore adds the build VM blocks (v5). - views.project takes what each role is shown, rather than a fixed table. - Vault takes the agent's name (agent=), for provenance. - A test that nisse_core never imports an agent. - The loop, config, git, delegation, expert, tools and evals stay in nisse-dev until nisse-ops has a loop to share. Docs: the Development Plan marks M7.1 done and notes that Phase 6 waits on the agent host and Terraform; Architecture Part L names the package. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
188 lines
6.1 KiB
Python
188 lines
6.1 KiB
Python
"""The trust dial (L3, D5; M3.3): a project set to `merge: approved` has its
|
|
PRs merged by the harness once an operator approved the PR's current commit
|
|
and its checks pass. Gitea enforces the approval (protection); the agent
|
|
checks at start that it does, and otherwise leaves merging to the operator."""
|
|
|
|
import io
|
|
from typing import Any
|
|
|
|
from test_loop_milestones import (
|
|
DON,
|
|
FINISH,
|
|
RESTATE,
|
|
WORK,
|
|
Forge,
|
|
ScriptedModel,
|
|
call,
|
|
say,
|
|
)
|
|
from test_loop_milestones import item as the_item
|
|
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
|
|
|
from nisse_core.cancel import Cancel
|
|
from nisse_core.oplog import OpLog
|
|
from nisse_core.transport import InMemoryTransport
|
|
from nisse_core.worker import InlineWorker
|
|
from nisse_dev.loop.agent import LoopAgent, Project
|
|
from nisse_dev.loop.driver import Driver
|
|
from nisse_dev.loop.items import MERGE, loop_of
|
|
from nisse_dev.tools import Workspace
|
|
|
|
CLOSE = [call("close_item", summary="added and merged")]
|
|
|
|
|
|
def agent_for(
|
|
world: dict[str, Any], model: ScriptedModel, merge: str = "approved"
|
|
) -> tuple[LoopAgent, InMemoryTransport]:
|
|
transport = InMemoryTransport()
|
|
cancel = Cancel()
|
|
project = Project(
|
|
"sandbox",
|
|
"don/sandbox",
|
|
Workspace(world["host"], world["repo"].root, timeout_s=30),
|
|
world["milestones"],
|
|
merge=merge,
|
|
)
|
|
loop = LoopAgent(
|
|
world["store"],
|
|
transport,
|
|
world["forge"],
|
|
projects=[project],
|
|
account="nisse-dev",
|
|
operators={DON},
|
|
gitea_operators={"don"},
|
|
driver=Driver(model, "m", cancel=cancel), # type: ignore[arg-type]
|
|
worker=InlineWorker(),
|
|
cancel=cancel,
|
|
oplog=OpLog("nisse-dev", io.StringIO()),
|
|
)
|
|
transport.on_message(loop.on_message)
|
|
return loop, transport
|
|
|
|
|
|
def to_review(world: dict[str, Any], merge: str = "approved", *more: Any) -> Any:
|
|
"""An item whose PR is up, waiting for the merge."""
|
|
world["forge"].protect()
|
|
model = ScriptedModel(RESTATE, [*WORK, *FINISH], *more)
|
|
agent, transport = agent_for(world, model, merge)
|
|
agent.boot()
|
|
agent.intake()
|
|
say(transport, world, "go")
|
|
item = the_item(world)
|
|
assert loop_of(item).wait == MERGE, (item.position, loop_of(item), said(transport))
|
|
return agent, transport
|
|
|
|
|
|
def said(transport: InMemoryTransport) -> list[str]:
|
|
return [m.text for m in transport.outbound]
|
|
|
|
|
|
def test_an_approved_pr_is_merged_and_the_item_goes_on(world: dict[str, Any]) -> None:
|
|
forge: Forge = world["forge"]
|
|
agent, transport = to_review(world, "approved", CLOSE)
|
|
pr = loop_of(the_item(world)).pr
|
|
agent.watch()
|
|
assert forge.agent_merged == [] # not approved yet
|
|
forge.approve(pr)
|
|
agent.watch()
|
|
agent._schedule()
|
|
assert forge.agent_merged == [pr]
|
|
assert f"Merged PR #{pr} after don's approval." in said(transport)
|
|
assert the_item(world).status == "closed"
|
|
|
|
|
|
def test_the_operator_merges_by_default(world: dict[str, Any]) -> None:
|
|
forge: Forge = world["forge"]
|
|
agent, _ = to_review(world, "operator")
|
|
pr = loop_of(the_item(world)).pr
|
|
forge.approve(pr)
|
|
agent.watch()
|
|
assert forge.agent_merged == []
|
|
assert loop_of(the_item(world)).wait == MERGE
|
|
|
|
|
|
def test_an_approval_of_an_older_commit_doesnt_count(world: dict[str, Any]) -> None:
|
|
forge: Forge = world["forge"]
|
|
agent, _ = to_review(world)
|
|
pr = loop_of(the_item(world)).pr
|
|
forge.approve(pr, sha="0" * 40) # what the PR was before a fix
|
|
agent.watch()
|
|
assert forge.agent_merged == []
|
|
|
|
|
|
def test_only_an_operators_approval_counts(world: dict[str, Any]) -> None:
|
|
forge: Forge = world["forge"]
|
|
agent, _ = to_review(world)
|
|
pr = loop_of(the_item(world)).pr
|
|
forge.approve(pr, by="mallory")
|
|
agent.watch()
|
|
assert forge.agent_merged == []
|
|
|
|
|
|
def test_pending_checks_wait_and_failed_ones_are_said_once(
|
|
world: dict[str, Any],
|
|
) -> None:
|
|
forge: Forge = world["forge"]
|
|
agent, transport = to_review(world)
|
|
pr = loop_of(the_item(world)).pr
|
|
forge.approve(pr)
|
|
forge.status[forge.head_sha(pr)] = "pending"
|
|
agent.watch()
|
|
assert forge.agent_merged == []
|
|
forge.status[forge.head_sha(pr)] = "failure"
|
|
agent.watch()
|
|
agent.watch()
|
|
held = [t for t in said(transport) if "its checks failed" in t]
|
|
assert len(held) == 1 and forge.agent_merged == []
|
|
forge.status[forge.head_sha(pr)] = "success"
|
|
agent.watch()
|
|
assert forge.agent_merged == [pr]
|
|
|
|
|
|
def test_protection_is_checked_at_start(world: dict[str, Any]) -> None:
|
|
forge: Forge = world["forge"]
|
|
forge.protect()
|
|
agent, transport = agent_for(world, ScriptedModel())
|
|
agent.boot()
|
|
assert agent._merging == {"sandbox": True}
|
|
# The check's branch and PR are gone again.
|
|
assert all(p["state"] == "closed" for p in forge.prs.values())
|
|
assert forge.agent_merged == []
|
|
assert not [t for t in said(transport) if "leave merging to you" in t]
|
|
|
|
|
|
def test_unprotected_means_the_operator_merges(world: dict[str, Any]) -> None:
|
|
forge: Forge = world["forge"] # not protected: the agent could merge anything
|
|
model = ScriptedModel(RESTATE, [*WORK, *FINISH])
|
|
agent, transport = agent_for(world, model)
|
|
agent.boot()
|
|
assert agent._merging == {"sandbox": False}
|
|
assert any("I could merge my own PR" in t for t in said(transport))
|
|
agent.intake()
|
|
say(transport, world, "go")
|
|
pr = loop_of(the_item(world)).pr
|
|
forge.approve(pr)
|
|
agent.watch()
|
|
assert pr not in forge.agent_merged # left to the operator
|
|
|
|
|
|
def test_a_refused_merge_is_said_once_and_left_to_the_operator(
|
|
world: dict[str, Any],
|
|
) -> None:
|
|
from nisse_core.gitea import GiteaError
|
|
|
|
forge: Forge = world["forge"]
|
|
agent, transport = to_review(world)
|
|
pr = loop_of(the_item(world)).pr
|
|
forge.approve(pr)
|
|
|
|
def refuse(repo: str, index: int) -> None:
|
|
raise GiteaError("gitea POST merge returned 405: not mergeable")
|
|
|
|
forge.merge_pull_request = refuse # type: ignore[method-assign]
|
|
agent.watch()
|
|
agent.watch()
|
|
refused = [t for t in said(transport) if "Gitea refused my merge" in t]
|
|
assert len(refused) == 1
|
|
assert loop_of(the_item(world)).wait == MERGE
|