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
@@ -12,6 +12,7 @@ scripts/e2e.sh after each milestone.
|
||||
import base64
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
@@ -83,7 +84,11 @@ def running(
|
||||
monkeypatch.setattr("nisse_dev.loop.build.MatrixTransport", lambda cfg: transport)
|
||||
agent = None
|
||||
try:
|
||||
agent = build_loop_agent(config)
|
||||
# What the agent reads when told a new project is ready: replaced by
|
||||
# a test that adds one (D8).
|
||||
source = [config]
|
||||
agent = build_loop_agent(config, load=lambda: source[0])
|
||||
agent.source = source # type: ignore[attr-defined]
|
||||
# The test drives intake and PR watching itself, not on a timer.
|
||||
agent.loop._interval = 3600
|
||||
agent.start()
|
||||
@@ -145,11 +150,13 @@ class Operator:
|
||||
"""Intake and PR watching, as the agent's timer would do."""
|
||||
self.agent.worker.call(self.agent.loop.intake, timeout=DEADLINE_S)
|
||||
|
||||
project: str | None = None # whose issues ``issue`` numbers, if not the only
|
||||
|
||||
def item(self, issue: int = 0) -> Any:
|
||||
"""The work item for ``issue``; by default the first one."""
|
||||
if issue:
|
||||
store = self.agent.store
|
||||
return self.agent.worker.call(lambda: item_for_issue(store, issue))
|
||||
store, project = self.agent.store, self.project
|
||||
return self.agent.worker.call(lambda: item_for_issue(store, issue, project))
|
||||
items = self.agent.worker.call(self.agent.store.work_items)
|
||||
return items[0] if items else None
|
||||
|
||||
@@ -545,3 +552,85 @@ def test_a_conversation_becomes_filed_work(
|
||||
assert f"Closes #{number}" in pr["body"]
|
||||
finally:
|
||||
operator.tidy(issues, prs)
|
||||
|
||||
|
||||
def test_a_conversation_becomes_a_new_project(
|
||||
running: tuple[RunningLoop, InMemoryTransport, str],
|
||||
) -> None:
|
||||
"""M2.5: a conversation proposes a new project; the operator provides
|
||||
what the checklist asks for (here, a repository of its own made the way
|
||||
fresh_repo makes one, and the entry the agent wrote); "done" has the
|
||||
agent check it all, open the tracking issue, and start the architecture."""
|
||||
import yaml
|
||||
|
||||
from nisse_dev.config import _parse_projects
|
||||
|
||||
agent, transport, run = running
|
||||
operator = Operator(agent, transport)
|
||||
as_operator = operator_gitea(agent.config.gitea_url)
|
||||
name = f"tally-{run}"
|
||||
|
||||
def conversation() -> Any:
|
||||
items = agent.worker.call(agent.store.work_items)
|
||||
return next(i for i in items if i.scope == "general")
|
||||
|
||||
root = transport.receive(
|
||||
OPERATOR,
|
||||
f"I'd like a new project, named {name}: a tiny command-line tally "
|
||||
"counter in Python 3 with pytest, no services, secrets or extra network "
|
||||
"access. Please propose it as a new project.",
|
||||
)
|
||||
operator.settle()
|
||||
for _ in range(MAX_ANSWERS):
|
||||
if loop_of(conversation()).wait == GO_AHEAD:
|
||||
break
|
||||
transport.receive(
|
||||
OPERATOR, f"Yes: propose it as a new project named {name}.", thread=root
|
||||
)
|
||||
operator.settle()
|
||||
assert loop_of(conversation()).proposal_kind == "project", operator.last()
|
||||
transport.receive(OPERATOR, "go", thread=root)
|
||||
operator.settle()
|
||||
checklist = operator.last()
|
||||
entry = re.search(r"```yaml\n(.*?)\n```", checklist, re.DOTALL)
|
||||
assert entry is not None, checklist
|
||||
|
||||
# --- the operator provides it all, as the checklist asks ---
|
||||
keep = agent.config.state_db.parent
|
||||
with fresh_repo(
|
||||
as_operator, f"{run}-new", keep / "keys", agent=agent.account, seed=False
|
||||
) as repo:
|
||||
host = agent.config.build_host
|
||||
filled = (
|
||||
entry.group(1)
|
||||
.replace(f"<owner>/{name}", repo.name)
|
||||
.replace("<the build VM>", host.address)
|
||||
.replace("<my SSH key for it>", str(host.key))
|
||||
.replace("<the deploy key's private half>", str(repo.push_key))
|
||||
)
|
||||
errors: list[str] = []
|
||||
[project] = _parse_projects(yaml.safe_load(filled)["projects"], {}, errors)
|
||||
assert errors == [], errors
|
||||
# On this run's build host and in its scratch directory.
|
||||
project = replace(
|
||||
project, build_host=host, workspace=Path(f"/tmp/nisse-e2e-{run}/{name}")
|
||||
)
|
||||
agent.source[0] = replace( # type: ignore[attr-defined]
|
||||
agent.config, projects=(*agent.config.projects, project)
|
||||
)
|
||||
transport.receive(OPERATOR, "done", thread=root)
|
||||
operator.settle()
|
||||
|
||||
became = loop_of(conversation()).became
|
||||
assert became, f"not set up: {operator.last()}"
|
||||
number = int(became[0].split("#")[1])
|
||||
operator.project = name
|
||||
item = operator.drive_until(MERGE, number)
|
||||
assert "design" in loop_of(item).labels
|
||||
pr = loop_of(item).pr
|
||||
files = [
|
||||
f["filename"]
|
||||
for f in agent.gitea._request("GET", f"/repos/{repo.name}/pulls/{pr}/files")
|
||||
]
|
||||
assert "ARCHITECTURE.md" in files, files
|
||||
assert any(f.startswith("docs/plans/") for f in files), files
|
||||
Reference in new issue
Block a user