A pure move; behaviour unchanged. - models (L9, L7): llm, catalog, probe, render, render_anthropic, context - log (L7, L6): canonical, views - state (L8): store (was state), reconcile, worker, cancel, dump - knowledge (L10, L6.4): vault, memory - conversation (L5): transport, matrix - execution (L13): ssh (was execution), testing - authority (P3, P4, L4): actions - gitea, oplog, envfile stay at the top until each gains a second module. Every import, monkeypatch target, script and doc reference rewritten. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
189 lines
7.0 KiB
Python
189 lines
7.0 KiB
Python
"""Several projects on the new loop (M1.4, D8): intake from each project's
|
|
repository, each item working in its own project's workspace and publishing
|
|
to its own repository, issue numbers told apart, label defaults, and parking
|
|
across projects. Real git on the throwaway build host; fake Gitea per repo."""
|
|
|
|
import io
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from test_loop_milestones import DON, Forge, ScriptedModel, call, git, make_world
|
|
|
|
from nisse_core.conversation.transport import InMemoryTransport
|
|
from nisse_core.execution.ssh import SSHHost
|
|
from nisse_core.oplog import OpLog
|
|
from nisse_core.state.cancel import Cancel
|
|
from nisse_core.state.worker import InlineWorker
|
|
from nisse_dev.loop.agent import LoopAgent, Project
|
|
from nisse_dev.loop.driver import Driver
|
|
from nisse_dev.loop.items import ANSWER, GO_AHEAD, MERGE, item_for_issue, loop_of
|
|
from nisse_dev.tools import Workspace
|
|
|
|
REPOS = {"alpha": "don/alpha", "beta": "don/beta"}
|
|
|
|
|
|
class Router:
|
|
"""One Gitea for the agent, routing each call to its repository's fake."""
|
|
|
|
def __init__(self, forges: dict[str, Forge]) -> None:
|
|
self.forges = forges
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
def call(repo: str, *args: Any, **kwargs: Any) -> Any:
|
|
return getattr(self.forges[repo], name)(repo, *args, **kwargs)
|
|
|
|
return call
|
|
|
|
|
|
@pytest.fixture
|
|
def worlds(ssh_host: SSHHost, tmp_path: Path) -> dict[str, dict[str, Any]]:
|
|
alpha = make_world(ssh_host, tmp_path / "alpha", repo_name=REPOS["alpha"])
|
|
beta = make_world(
|
|
ssh_host, tmp_path / "beta", store=alpha["store"], repo_name=REPOS["beta"]
|
|
)
|
|
return {"alpha": alpha, "beta": beta}
|
|
|
|
|
|
def agent_for(
|
|
worlds: dict[str, dict[str, Any]],
|
|
model: ScriptedModel,
|
|
labels: dict[str, tuple[str, ...]] | None = None,
|
|
) -> tuple[LoopAgent, InMemoryTransport]:
|
|
transport = InMemoryTransport()
|
|
cancel = Cancel()
|
|
router = Router({REPOS[n]: w["forge"] for n, w in worlds.items()})
|
|
projects = [
|
|
Project(
|
|
name=n,
|
|
repo=REPOS[n],
|
|
workspace=Workspace(w["host"], w["repo"].root, timeout_s=30),
|
|
milestones=w["milestones"],
|
|
labels=(labels or {}).get(n, ()),
|
|
)
|
|
for n, w in worlds.items()
|
|
]
|
|
for w in worlds.values():
|
|
w["milestones"]._gitea = router # publishing goes through the router too
|
|
agent = LoopAgent(
|
|
worlds["alpha"]["store"],
|
|
transport,
|
|
router, # type: ignore[arg-type]
|
|
projects=projects,
|
|
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(agent.on_message)
|
|
return agent, transport
|
|
|
|
|
|
def item(worlds: dict[str, Any], project: str) -> Any:
|
|
found = item_for_issue(worlds["alpha"]["store"], 14, project)
|
|
assert found is not None
|
|
return found
|
|
|
|
|
|
def say(transport: InMemoryTransport, worlds: Any, project: str, text: str) -> None:
|
|
transport.receive(DON, text, thread=loop_of(item(worlds, project)).thread)
|
|
|
|
|
|
def restate(name: str) -> list[Any]:
|
|
return [call("restate", text=f"{name}: add count_words")]
|
|
|
|
|
|
def work(path: str) -> list[Any]:
|
|
return [
|
|
call("start_milestone", name=path),
|
|
call("write_file", path=path, content=f"# {path}\n"),
|
|
call("finish_milestone", summary=path, final="yes"),
|
|
]
|
|
|
|
|
|
def test_both_projects_issues_are_taken_and_told_apart(worlds: Any) -> None:
|
|
# Both forges have an issue #14.
|
|
agent, transport = agent_for(worlds, ScriptedModel(restate("a"), restate("b")))
|
|
agent.intake()
|
|
a, b = item(worlds, "alpha"), item(worlds, "beta")
|
|
assert (a.scope, b.scope) == ("alpha", "beta")
|
|
said = [m.text for m in transport.outbound]
|
|
assert any(t.startswith("Queued don/alpha#14") for t in said)
|
|
assert any(t.startswith("Queued don/beta#14") for t in said)
|
|
status = agent.status()
|
|
assert "alpha#14 Add count_words" in status and "beta#14 Add count_words" in status
|
|
|
|
|
|
def test_each_item_works_and_publishes_in_its_own_project(worlds: Any) -> None:
|
|
model = ScriptedModel(restate("a"), restate("b"), work("a.py"), work("b.py"))
|
|
agent, transport = agent_for(worlds, model)
|
|
agent.intake()
|
|
say(transport, worlds, "alpha", "go")
|
|
say(transport, worlds, "beta", "go")
|
|
fa, fb = worlds["alpha"]["forge"], worlds["beta"]["forge"]
|
|
[pa], [pb] = fa.prs, fb.prs
|
|
assert fa.files(pa) == ["a.py"] and fb.files(pb) == ["b.py"]
|
|
assert fa.prs[pa]["head"].startswith(item(worlds, "alpha").id)
|
|
assert fb.prs[pb]["head"].startswith(item(worlds, "beta").id)
|
|
# Each workspace has only its own project's work.
|
|
assert not (worlds["alpha"]["repo"].root / "b.py").exists()
|
|
assert loop_of(item(worlds, "beta")).wait == MERGE
|
|
|
|
|
|
def test_a_bare_number_in_two_projects_is_asked_about(worlds: Any) -> None:
|
|
model = ScriptedModel(restate("a"), restate("b"), work("b.py"))
|
|
agent, transport = agent_for(worlds, model)
|
|
agent.intake()
|
|
transport.receive(DON, "go #14")
|
|
asked = transport.outbound[-1].text
|
|
assert "open in more than one project" in asked
|
|
assert "go alpha#14" in asked and "go beta#14" in asked
|
|
assert not loop_of(item(worlds, "alpha")).started
|
|
transport.receive(DON, "go beta#14")
|
|
assert loop_of(item(worlds, "beta")).started
|
|
assert not loop_of(item(worlds, "alpha")).started
|
|
|
|
|
|
def test_do_next_names_a_project(worlds: Any) -> None:
|
|
agent, transport = agent_for(worlds, ScriptedModel(restate("a"), restate("b")))
|
|
agent.intake()
|
|
transport.receive(DON, "do #14 next")
|
|
assert "do alpha#14 next or do beta#14 next" in transport.outbound[-1].text
|
|
transport.receive(DON, "do beta#14 next")
|
|
assert "beta#14 goes first" in transport.outbound[-1].text
|
|
|
|
|
|
def test_a_projects_label_defaults_steer_its_items(worlds: Any) -> None:
|
|
model = ScriptedModel(
|
|
[call("ask_human", question="what's it for?")], # alpha: discuss first
|
|
restate("b"),
|
|
)
|
|
agent, _ = agent_for(worlds, model, labels={"alpha": ("discuss",)})
|
|
agent.intake()
|
|
assert loop_of(item(worlds, "alpha")).labels == ("discuss",)
|
|
assert loop_of(item(worlds, "alpha")).wait == ANSWER
|
|
assert loop_of(item(worlds, "beta")).wait == GO_AHEAD
|
|
|
|
|
|
def test_switching_projects_parks_the_other_projects_work(worlds: Any) -> None:
|
|
model = ScriptedModel(
|
|
restate("a"),
|
|
restate("b"),
|
|
[ # alpha: half done, then a question
|
|
call("start_milestone", name="a"),
|
|
call("write_file", path="half.py", content="H = 1\n"),
|
|
call("ask_human", question="one more thing?"),
|
|
],
|
|
work("b.py"), # beta runs meanwhile
|
|
)
|
|
agent, transport = agent_for(worlds, model)
|
|
agent.intake()
|
|
say(transport, worlds, "alpha", "go")
|
|
say(transport, worlds, "beta", "go")
|
|
bare = ["--git-dir", str(worlds["alpha"]["bare"])]
|
|
parked = f"refs/heads/{item(worlds, 'alpha').id}-parked"
|
|
assert git(*bare, "show", f"{parked}^3:half.py") == "H = 1"
|