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:
dongarandClaude Opus 5.5 committed 2026-10-07 15:39:00 +00:00
1 parent 9a86ccb2a4
commit ad2d090143
13 files changed
+723 -54

No files matched your search

+27 -1
View File
@@ -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']}"
+193
View File
@@ -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
+34 -2
View File
@@ -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"