New projects from conversation (M2.5, PR B)
- propose_project(name, purpose, decisions) in a conversation goes up for a go-ahead (checked: a slug, not an existing project, no trailing question). - On go, the provisioning checklist (D1): a repository with a README, protection on its default branch, the agent as a collaborator, a build VM, a deploy key, and the project entry as YAML with <...> to fill in. - On "done", the agent reads its configuration again and adds the new project live (build_loop_agent(load=...); only additions), then checks what was provided (D4, verified, not assumed): permissions and protection as it sees them, then on a throwaway branch with an empty commit, a branch push must succeed and a push to the default branch and merging its own PR must be refused. The check's PR is closed and its branch deleted. Failures are said per item; nothing starts. - Checked: the tracking issue (the distilled decisions, carrying the new item's ID), and the project's item, agreed and labelled `design`, whose first milestone is ARCHITECTURE.md and the build plan. A conversation waiting on provisioning doesn't close as idle. - The walk replays exactly from its seed (item IDs too), and event IDs stay unique across simulated restarts, as a server's are. - The test config holds output to 8192 tokens, under the test key's per-request cost cap. Tests: new-project unit tests (checklist and its YAML parse as config, done before the entry, what isn't set up, a checked project's record and first PR, an existing name), protection enforced by a hook on the fake forge, the walk proposing projects and saying done, and an e2e new project in a repository the test operator provisions from the agent's checklist. 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
9a86ccb2a4
commit
ad2d090143
13 files changed
+723
-54
No files matched your search
@@ -14,7 +14,7 @@ from nisse_dev.cancel import Cancel
|
||||
from nisse_dev.canonical import ToolCall
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.git import GitRepo
|
||||
from nisse_dev.gitea import Comment, Issue, PullRequest, ReviewNote
|
||||
from nisse_dev.gitea import Comment, GiteaError, Issue, PullRequest, ReviewNote
|
||||
from nisse_dev.loop.agent import LoopAgent
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import ANSWER, MERGE, WAITING, loop_of
|
||||
@@ -124,6 +124,32 @@ class Forge:
|
||||
def close_pull_request(self, repo: str, index: int) -> None:
|
||||
self.prs[index]["state"] = "closed"
|
||||
|
||||
# what a new project's checks read (D4)
|
||||
protected = False
|
||||
|
||||
def protect(self) -> None:
|
||||
"""Protection as the server enforces it: pushes to main refused by a
|
||||
hook; merging needs an approval nobody here gives."""
|
||||
hook = self.bare / "hooks" / "pre-receive"
|
||||
hook.write_text(
|
||||
"#!/bin/sh\nwhile read old new ref; do\n"
|
||||
' [ "$ref" = refs/heads/main ] && echo "main is protected" && exit 1\n'
|
||||
"done\nexit 0\n"
|
||||
)
|
||||
hook.chmod(0o755)
|
||||
self.protected = True
|
||||
|
||||
def repository(self, repo: str) -> dict[str, Any]:
|
||||
return {"permissions": {"push": True}}
|
||||
|
||||
def branch(self, repo: str, name: str) -> dict[str, Any]:
|
||||
return {"protected": self.protected, "user_can_push": not self.protected}
|
||||
|
||||
def merge_pull_request(self, repo: str, index: int) -> None:
|
||||
if self.protected:
|
||||
raise GiteaError("gitea POST merge returned 405: approvals needed")
|
||||
self.merge(index)
|
||||
|
||||
# what the operator does in Gitea
|
||||
def merge(self, n: int) -> None:
|
||||
head = f"refs/heads/{self.prs[n]['head']}"
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""New projects on the new loop (M2.5, PR B): a conversation proposes one,
|
||||
the agent lists what it needs, and on "done" reads its configuration again
|
||||
and checks what was provided (D1, D4, D8) before opening the tracking issue
|
||||
and starting on the architecture. Real git on the throwaway build host,
|
||||
with protection enforced by a hook on the fake forge's bare repository."""
|
||||
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from test_loop_milestones import DON, Forge, ScriptedModel, call, make_world
|
||||
from test_loop_projects import Router
|
||||
|
||||
from nisse_dev.cancel import Cancel
|
||||
from nisse_dev.config import _parse_projects
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.loop.agent import LoopAgent, Project
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import CHAT, GO_AHEAD, MERGE, PROVISION, loop_of
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.tools import Workspace
|
||||
from nisse_dev.transport import InMemoryTransport
|
||||
from nisse_dev.worker import InlineWorker
|
||||
|
||||
PROPOSAL = call(
|
||||
"propose_project",
|
||||
name="tally",
|
||||
purpose="A small command-line tally counter.",
|
||||
decisions="Python 3.12 with pytest; no services, secrets or extra egress.",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worlds(ssh_host: SSHHost, tmp_path: Path) -> dict[str, Any]:
|
||||
sandbox = make_world(ssh_host, tmp_path / "sandbox")
|
||||
tally = make_world(
|
||||
ssh_host, tmp_path / "tally", store=sandbox["store"], repo_name="don/tally"
|
||||
)
|
||||
sandbox["forge"].issues.clear() # only the conversation here
|
||||
tally["forge"].issues.clear()
|
||||
return {"sandbox": sandbox, "tally": tally}
|
||||
|
||||
|
||||
def agent_for(
|
||||
worlds: dict[str, Any], model: ScriptedModel, *, entry_added: list[bool]
|
||||
) -> tuple[LoopAgent, InMemoryTransport]:
|
||||
"""An agent that knows only the sandbox; tally's entry appears in its
|
||||
configuration once ``entry_added[0]`` is set."""
|
||||
transport = InMemoryTransport()
|
||||
cancel = Cancel()
|
||||
router = Router({w["repo_name"]: w["forge"] for w in worlds.values()})
|
||||
for w in worlds.values():
|
||||
w["milestones"]._gitea = router
|
||||
|
||||
def runtime(name: str) -> Project:
|
||||
w = worlds[name]
|
||||
return Project(
|
||||
name=name,
|
||||
repo=w["repo_name"],
|
||||
workspace=Workspace(w["host"], w["repo"].root, timeout_s=30),
|
||||
milestones=w["milestones"],
|
||||
)
|
||||
|
||||
def reload(known: set[str]) -> list[Project]:
|
||||
names = ["sandbox", "tally"] if entry_added[0] else ["sandbox"]
|
||||
return [runtime(n) for n in names if n not in known]
|
||||
|
||||
agent = LoopAgent(
|
||||
worlds["sandbox"]["store"],
|
||||
transport,
|
||||
router, # type: ignore[arg-type]
|
||||
projects=[runtime("sandbox")],
|
||||
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()),
|
||||
reload=reload,
|
||||
)
|
||||
transport.on_message(agent.on_message)
|
||||
return agent, transport
|
||||
|
||||
|
||||
def conversation(worlds: dict[str, Any]) -> Any:
|
||||
[chat] = [
|
||||
i for i in worlds["sandbox"]["store"].work_items() if i.scope == "general"
|
||||
]
|
||||
return chat
|
||||
|
||||
|
||||
def proposed(
|
||||
worlds: Any, model: ScriptedModel, added: list[bool]
|
||||
) -> tuple[Any, Any, str]:
|
||||
agent, transport = agent_for(worlds, model, entry_added=added)
|
||||
root = transport.receive(DON, "I want a little tally counter, as its own project.")
|
||||
assert transport.outbound[-1].text.startswith("rev-1: a new project, `tally`.")
|
||||
assert loop_of(conversation(worlds)).wait == GO_AHEAD
|
||||
transport.receive(DON, "go", thread=root)
|
||||
return agent, transport, root
|
||||
|
||||
|
||||
def test_an_agreed_project_gets_a_checklist_with_its_entry(worlds: Any) -> None:
|
||||
_, transport, _ = proposed(worlds, ScriptedModel([PROPOSAL]), [False])
|
||||
loop = loop_of(conversation(worlds))
|
||||
assert (loop.wait, loop.new_project) == (PROVISION, "tally")
|
||||
checklist = transport.outbound[-1].text
|
||||
for need in ("repository", "Protection", "collaborator", "build VM", "deploy key"):
|
||||
assert need in checklist
|
||||
# The entry is valid configuration once its placeholders are filled in.
|
||||
entry = re.search(r"```yaml\n(.*?)\n```", checklist, re.DOTALL)
|
||||
assert entry is not None
|
||||
filled = (
|
||||
entry.group(1)
|
||||
.replace("<owner>/tally", "don/tally")
|
||||
.replace("<the build VM>", "build.test")
|
||||
.replace("<my SSH key for it>", "/k")
|
||||
.replace("<the deploy key's private half>", "/p")
|
||||
)
|
||||
errors: list[str] = []
|
||||
[project] = _parse_projects(yaml.safe_load(filled)["projects"], {}, errors)
|
||||
assert errors == [] and project.name == "tally" and project.repo == "don/tally"
|
||||
|
||||
|
||||
def test_done_before_the_entry_is_added_says_so(worlds: Any) -> None:
|
||||
_, transport, root = proposed(worlds, ScriptedModel([PROPOSAL]), [False])
|
||||
transport.receive(DON, "done", thread=root)
|
||||
assert "I don't see `tally` in my configuration yet" in transport.outbound[-1].text
|
||||
assert loop_of(conversation(worlds)).new_project == "tally"
|
||||
|
||||
|
||||
def test_what_isnt_set_up_is_named_and_nothing_starts(worlds: Any) -> None:
|
||||
added = [False]
|
||||
_, transport, root = proposed(worlds, ScriptedModel([PROPOSAL]), added)
|
||||
added[0] = True # the entry is there, but tally's main isn't protected
|
||||
transport.receive(DON, "done", thread=root)
|
||||
said = transport.outbound[-1].text
|
||||
assert said.startswith("Not ready yet:")
|
||||
assert "main isn't protected" in said and "could merge my own PR" in said
|
||||
assert loop_of(conversation(worlds)).new_project == "tally"
|
||||
assert worlds["tally"]["forge"].issues == []
|
||||
# The check's PR was closed and its branch deleted.
|
||||
assert all(p["state"] == "closed" for p in worlds["tally"]["forge"].prs.values())
|
||||
|
||||
|
||||
def test_a_checked_project_gets_its_record_and_starts_on_its_design(
|
||||
worlds: Any,
|
||||
) -> None:
|
||||
added = [False]
|
||||
model = ScriptedModel(
|
||||
[PROPOSAL],
|
||||
[ # tally's first item: the architecture and the build plan
|
||||
call("start_milestone", name="architecture"),
|
||||
call("write_file", path="ARCHITECTURE.md", content="# tally\n"),
|
||||
call("write_file", path="docs/plans/tally.md", content="# plan\n"),
|
||||
call("finish_milestone", summary="architecture and plan", final="no"),
|
||||
],
|
||||
)
|
||||
agent, transport, root = proposed(worlds, model, added)
|
||||
forge: Forge = worlds["tally"]["forge"]
|
||||
forge.protect()
|
||||
added[0] = True
|
||||
transport.receive(DON, "done", thread=root)
|
||||
|
||||
assert "tally" in agent._projects
|
||||
[record] = forge.issues
|
||||
marker = re.search(r"<!-- nisse-item: ([\w-]+) -->", record.body)
|
||||
assert marker and "tally counter" in record.body
|
||||
work = worlds["sandbox"]["store"].work_item(marker.group(1))
|
||||
assert work.scope == "tally" and loop_of(work).started
|
||||
assert "design" in loop_of(work).labels
|
||||
assert loop_of(work).wait == MERGE
|
||||
pr = max(n for n, p in forge.prs.items() if p["state"] == "open")
|
||||
assert sorted(forge.files(pr)) == ["ARCHITECTURE.md", "docs/plans/tally.md"]
|
||||
assert f"Part of #{record.number}" in forge.prs[pr]["body"]
|
||||
chat = loop_of(conversation(worlds))
|
||||
assert chat.became == (f"don/tally#{record.number}",)
|
||||
assert (chat.wait, chat.new_project) == (CHAT, "")
|
||||
|
||||
|
||||
def test_a_project_name_that_exists_is_sent_back(worlds: Any) -> None:
|
||||
model = ScriptedModel(
|
||||
[call("propose_project", name="sandbox", purpose="again", decisions="x.")],
|
||||
[call("reply", text="sandbox exists; shall I propose work on it?")],
|
||||
)
|
||||
agent, transport = agent_for(worlds, model, entry_added=[False])
|
||||
transport.receive(DON, "a new sandbox?")
|
||||
assert loop_of(conversation(worlds)).wait == CHAT
|
||||
assert agent
|
||||
@@ -11,6 +11,7 @@ the seed and the steps, so it replays exactly.
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -32,6 +33,7 @@ from nisse_dev.loop.items import (
|
||||
CHAT,
|
||||
GO_AHEAD,
|
||||
MERGE,
|
||||
PROVISION,
|
||||
RUNNING,
|
||||
WAITING,
|
||||
loop_of,
|
||||
@@ -67,6 +69,15 @@ class PolicyModel:
|
||||
if roll < 0.12:
|
||||
return [ToolCall(call_id=f"p{self.n}", tool="nonsense", arguments={})]
|
||||
if "reply" in offered: # a conversation: talk, or propose work
|
||||
if self.rng.random() < 0.1:
|
||||
return [
|
||||
call(
|
||||
"propose_project",
|
||||
name=f"proj{self.n}",
|
||||
purpose=f"a project {self.n}.",
|
||||
decisions="Python.",
|
||||
)
|
||||
]
|
||||
if self.rng.random() < 0.4:
|
||||
return [
|
||||
call(
|
||||
@@ -125,6 +136,10 @@ class Walk:
|
||||
|
||||
def _agent(self) -> tuple[LoopAgent, InMemoryTransport]:
|
||||
transport = InMemoryTransport()
|
||||
# Event IDs are unique across restarts, as a server's are: a new
|
||||
# transport goes on numbering where the old one stopped.
|
||||
old = getattr(self, "transport", None)
|
||||
transport._next_id = old._next_id if old is not None else 0
|
||||
cancel = Cancel()
|
||||
router = Router({w["repo_name"]: w["forge"] for w in self.worlds.values()})
|
||||
for w in self.worlds.values():
|
||||
@@ -193,6 +208,8 @@ class Walk:
|
||||
if item and loop:
|
||||
weights.update({"go": 3, "answer": 2, "abandon": 1, "go-stale": 1})
|
||||
weights.update({"plain-yes": 2, "wait": 2})
|
||||
if loop.new_project:
|
||||
weights.update({"done": 2})
|
||||
if item.scope != "general": # conversations aren't named by number
|
||||
weights.update({"first": 1, "named-go": 1})
|
||||
if loop.pr:
|
||||
@@ -203,6 +220,9 @@ class Walk:
|
||||
self.steps.append(f"{action}{self.name(item)}" if item else action)
|
||||
if action == "intake":
|
||||
self.agent.intake()
|
||||
elif action == "done" and loop:
|
||||
# The operator says a new project is ready; no entry was added.
|
||||
self.transport.receive(DON, "done", thread=loop.thread)
|
||||
elif action == "chat":
|
||||
# A top-level message: a conversation of its own (L1, L5).
|
||||
self.transport.receive(DON, f"thinking aloud {len(self.steps)}")
|
||||
@@ -295,6 +315,7 @@ class Walk:
|
||||
if item.status == "open" and item.position == WAITING:
|
||||
explained = (
|
||||
(loop.wait == CHAT and item.scope == "general")
|
||||
or (loop.wait == PROVISION and bool(loop.new_project))
|
||||
or (loop.wait == GO_AHEAD and loop.rev > 0)
|
||||
or (loop.wait == ANSWER and bool(loop.note))
|
||||
or (loop.wait == MERGE and loop.pr > 0)
|
||||
@@ -546,8 +567,18 @@ def _is_ancestor(bare: Path, commit: str, ref: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _seeded_ids(monkeypatch: pytest.MonkeyPatch, seed: int) -> None:
|
||||
"""Item IDs and branch names come from uuid4: seeded too, so a walk
|
||||
replays exactly from its seed."""
|
||||
ids = random.Random(seed)
|
||||
monkeypatch.setattr(uuid, "uuid4", lambda: uuid.UUID(int=ids.getrandbits(128)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", SEEDS)
|
||||
def test_the_loop_keeps_its_properties(world: dict[str, Any], seed: int) -> None:
|
||||
def test_the_loop_keeps_its_properties(
|
||||
world: dict[str, Any], seed: int, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seeded_ids(monkeypatch, seed)
|
||||
walk = Walk({"sandbox": world}, seed)
|
||||
for _ in range(STEPS):
|
||||
walk.step()
|
||||
@@ -556,9 +587,10 @@ def test_the_loop_keeps_its_properties(world: dict[str, Any], seed: int) -> None
|
||||
|
||||
@pytest.mark.parametrize("seed", TWO_PROJECT_SEEDS)
|
||||
def test_two_projects_keep_the_properties(
|
||||
ssh_host: SSHHost, tmp_path: Path, seed: int
|
||||
ssh_host: SSHHost, tmp_path: Path, seed: int, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""D8: the same walk over two projects, whose issue numbers collide."""
|
||||
_seeded_ids(monkeypatch, seed)
|
||||
sandbox = make_world(ssh_host, tmp_path / "sandbox")
|
||||
other = make_world(
|
||||
ssh_host, tmp_path / "other", store=sandbox["store"], repo_name="don/other"
|
||||
|
||||
Reference in new issue
Block a user