nisse-dev now runs only the conversational loop. The gated state machine it was built alongside is gone: - Removed control.py, planner.py, executor.py, approvals.py, gates.py (command_form moves to loop/reader.py), publish.py and poller.py, the old assembly in agent.py (its shared helpers move to loop/build.py), the store's state-machine view (get_state, transition), their tests, the old end-to-end and live planning/milestone/gate tests, and specs/orchestrator.qnt with its scenarios. - Roles are driver, helper, expert and classifier; driver is required. loop:, roles.planner, roles.executor and tuning.executor are refused with what to use instead; tuning.driver takes the turn cap and failure limit. - The benchmark and the capability matrix measure the driver: the loop's own system prompt and tools, the task's plan as the agreed restatement. Task plans end with finish_milestone, so earlier runs show as (old); stored runs' executor tuning reads as the driver's. - A delegation on the loop records what its helper's alias resolved to (L9), as the old path's did. - The dump shows a work item's revision and what it waits for. - Behaviour the old tests covered and the loop shares stays covered: driver runs that fail on a model call, stop mid-turn, reset the failure count; go-aheads ignoring case and punctuation; a request too big to fit is never sent; the assembly tests, now on build_loop_agent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
197 lines
6.7 KiB
Python
197 lines
6.7 KiB
Python
"""Shared by the live tests: each run gets a repository of its own, made by
|
|
the test operator and deleted afterwards, so runs never depend on one
|
|
another or on what earlier runs merged."""
|
|
|
|
import base64
|
|
import contextlib
|
|
import os
|
|
import subprocess
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from nisse_dev.config import BuildHost
|
|
from nisse_dev.execution import SSHHost
|
|
from nisse_dev.gitea import GiteaClient, GiteaError
|
|
from nisse_dev.llm import RoleMap
|
|
|
|
# The end-to-end tests run every role on a local model, which costs nothing
|
|
# per call; NISSE_E2E_MODEL overrides it (e.g. claude-haiku-4-5 for a quick,
|
|
# paid run). The no-thinking alias (LiteLLM: chat_template_kwargs
|
|
# enable_thinking false): with thinking, some answers ran to 6-8k tokens,
|
|
# minutes each, and stretched a test from 3.5 to 20 minutes.
|
|
E2E_MODEL = "qwen3.8-27b-nothink"
|
|
|
|
|
|
def e2e_roles() -> RoleMap:
|
|
model = os.environ.get("NISSE_E2E_MODEL", "").strip() or E2E_MODEL
|
|
return RoleMap(driver=model, helper=model, expert=model)
|
|
|
|
|
|
def task(run: str) -> str:
|
|
"""A request no earlier run can have fulfilled."""
|
|
return (
|
|
f"Add a function vowels_{run}(text) to the sandbox package that returns "
|
|
"how many of a, e, i, o, u (either case) the text contains, with tests."
|
|
)
|
|
|
|
|
|
def dependent_task(run: str) -> str:
|
|
"""A request that builds on task(run): it can only be done after it."""
|
|
return (
|
|
f"Add a function consonants_{run}(text) to the sandbox package that "
|
|
"returns how many letters in the text are not vowels, using "
|
|
f"vowels_{run}, with tests."
|
|
)
|
|
|
|
|
|
def operator_gitea(url: str) -> GiteaClient:
|
|
token = os.environ.get("NISSE_OPERATOR_TOKEN", "")
|
|
assert token.strip(), (
|
|
"NISSE_OPERATOR_TOKEN is not set: the test operator's Gitea token"
|
|
)
|
|
return GiteaClient(url, token)
|
|
|
|
|
|
OWNER = "nisse-testing-operator" # the test operator: owns every test repository
|
|
SEED = Path(__file__).parent / "seed"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FreshRepo:
|
|
name: str # owner/repo
|
|
push_key: Path # this repository's deploy key, private half
|
|
|
|
|
|
def make_repo(
|
|
operator: GiteaClient, name: str, keys: Path, *, agent: str, seed: bool = True
|
|
) -> FreshRepo:
|
|
"""A repository as the operator would make a project's (D4, D8): seeded
|
|
with a small Python package (or only a README), the ``agent`` account a
|
|
collaborator with write access, the default branch protected (pushes only
|
|
by the owner, an approval to merge), and a deploy key of its own, written
|
|
under ``keys``. A failure part-way deletes what was made."""
|
|
full = f"{OWNER}/{name}"
|
|
operator._request(
|
|
"POST",
|
|
"/user/repos",
|
|
json={
|
|
"name": name,
|
|
"private": True,
|
|
"auto_init": True,
|
|
"default_branch": "main",
|
|
},
|
|
)
|
|
try:
|
|
files = [
|
|
{
|
|
"operation": "create",
|
|
"path": str(path.relative_to(SEED)),
|
|
"content": base64.b64encode(path.read_bytes()).decode(),
|
|
}
|
|
for path in sorted(SEED.rglob("*"))
|
|
if seed and path.is_file() and path.name != "README.md"
|
|
]
|
|
if files:
|
|
operator._request(
|
|
"POST",
|
|
f"/repos/{full}/contents",
|
|
json={
|
|
"branch": "main",
|
|
"message": "Seed the test project",
|
|
"files": files,
|
|
},
|
|
)
|
|
readme = operator._request("GET", f"/repos/{full}/contents/README.md")
|
|
# A new project's README says only what it is; a seeded one, the seed's.
|
|
text = (SEED / "README.md").read_bytes() if seed else f"# {name}\n".encode()
|
|
operator._request(
|
|
"PUT",
|
|
f"/repos/{full}/contents/README.md",
|
|
json={
|
|
"branch": "main",
|
|
"message": "README",
|
|
"sha": readme["sha"],
|
|
"content": base64.b64encode(text).decode(),
|
|
},
|
|
)
|
|
operator._request(
|
|
"PUT", f"/repos/{full}/collaborators/{agent}", json={"permission": "write"}
|
|
)
|
|
keys.mkdir(parents=True, exist_ok=True)
|
|
key = keys / f"{name}.key"
|
|
key.unlink(missing_ok=True)
|
|
subprocess.run(
|
|
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", name, "-f", str(key)],
|
|
check=True,
|
|
)
|
|
operator._request(
|
|
"POST",
|
|
f"/repos/{full}/keys",
|
|
json={
|
|
"title": f"{name} push",
|
|
"key": key.with_suffix(".key.pub").read_text().strip(),
|
|
"read_only": False,
|
|
},
|
|
)
|
|
operator._request(
|
|
"POST",
|
|
f"/repos/{full}/branch_protections",
|
|
# Pushes only by the operator (who sets up fixtures, as an owner
|
|
# can); the agent's are refused, and merging needs an approval.
|
|
json={
|
|
"branch_name": "main",
|
|
"enable_push": True,
|
|
"enable_push_whitelist": True,
|
|
"push_whitelist_usernames": [OWNER],
|
|
"required_approvals": 1,
|
|
},
|
|
)
|
|
# The labels the agent reads (D1), as a project would have them.
|
|
for label, color in (
|
|
("bug", "#ee0701"),
|
|
("discuss", "#0e8a16"),
|
|
("design", "#5319e7"),
|
|
):
|
|
operator._request(
|
|
"POST", f"/repos/{full}/labels", json={"name": label, "color": color}
|
|
)
|
|
except BaseException:
|
|
delete_repo(operator, full)
|
|
raise
|
|
return FreshRepo(full, key)
|
|
|
|
|
|
def delete_repo(operator: GiteaClient, full: str) -> None:
|
|
with contextlib.suppress(GiteaError):
|
|
operator._request("DELETE", f"/repos/{full}")
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def fresh_repo(
|
|
operator: GiteaClient, run: str, keys: Path, *, agent: str, seed: bool = True
|
|
) -> Iterator[FreshRepo]:
|
|
"""A repository of its own for one test run (make_repo), deleted
|
|
afterwards unless NISSE_E2E_KEEP_REPOS is set."""
|
|
repo = make_repo(operator, f"nisse-e2e-{run}", keys, agent=agent, seed=seed)
|
|
try:
|
|
yield repo
|
|
finally:
|
|
if not os.environ.get("NISSE_E2E_KEEP_REPOS"):
|
|
delete_repo(operator, repo.name)
|
|
|
|
|
|
def build_host(ssh_host: SSHHost) -> BuildHost:
|
|
"""The operator's build VM when scripts/e2e.sh names one, otherwise the
|
|
throwaway local sshd."""
|
|
address = os.environ.get("NISSE_E2E_BUILD_HOST")
|
|
if address:
|
|
key = Path(os.environ["NISSE_E2E_BUILD_KEY"])
|
|
return BuildHost(address=address, key=key)
|
|
return BuildHost(
|
|
address=ssh_host.address,
|
|
key=ssh_host._key,
|
|
user=ssh_host.user,
|
|
port=ssh_host.port,
|
|
)
|