Files
nisse/nisse-dev/tests/test_loop_toolchain.py
dongarandClaude Opus 5.5 f32ccf9881
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m21s
CI / e2e (pull_request) Successful in 11m45s
M7.2: share the loop's parts
Shared parts, not a shared scheduler (agreed): nisse-review will have its
own small scheduler; a common one waits until two exist to compare.

- nisse_core.loop.driver: the driver run, moved; the agent names its
  waiting tools per run (nisse-dev's are WAITING_TOOLS), and execution
  goes to any Executor (ToolOutcome moves with it).
- nisse_core.loop.tools: the tool helper and the tools any agent offers
  (notes, memory, memory_done, ask_expert); nisse_core.loop.expert.
- nisse_core.knowledge.handlers: Knowledge, the vault and memory as
  control tools, the index and prompt text, out of LoopAgent.
- nisse_core.config: file reading, references, people, tuning sections of
  the agent's own, LLM numbers, the vault.
- New core tests: a driver run with a non-workspace executor and the
  agent's own waiting set; Knowledge alone; the shared config pieces.

Docs: the Development Plan marks M7.2 done; Architecture Part L and the
nisse-core README name the new parts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-13 21:31:29 +00:00

474 lines
18 KiB
Python

"""The toolchain step in the loop (D3; M4.1, M4.2): before the driver plans,
the build VM is checked (baseline, clone, marker); without a marker the harness
starts a toolchain step that sets the VM up from the repository and edits
nothing there; the driver can ask for one once work is agreed, whose edits are
part of the work; later runs check versions again; a rebuilt VM is re-cloned
and set up again, and a changed host key waits for the operator. A VM the
driver finds broken blocks its projects until the operator says it's ready
(M4.3)."""
import io
import shutil
import uuid
from pathlib import Path
from typing import Any
import pytest
from test_loop_milestones import (
DON,
FINISH,
RESTATE,
WORK,
ScriptedModel,
call,
say,
)
from test_loop_milestones import item as the_item
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
from nisse_core.conversation.transport import InMemoryTransport
from nisse_core.execution.ssh import HostKeyChanged
from nisse_core.gitea import Issue
from nisse_core.log.canonical import Fact, ToolResult
from nisse_core.loop.driver import Driver
from nisse_core.oplog import OpLog
from nisse_core.state.cancel import Cancel
from nisse_core.state.worker import InlineWorker
from nisse_dev.loop import preflight
from nisse_dev.loop.agent import LoopAgent, Project
from nisse_dev.loop.items import ANSWER, GO_AHEAD, WAITING, loop_of
from nisse_dev.tools import Workspace
PYTHON = {"name": "python", "version_command": "python3 --version"}
def ready(**more: Any) -> list[Any]:
"""A preflight that finds Python and reports it."""
return [
call("run_command", command="python3 --version"),
call(
"toolchain_ready",
summary="Python is there",
toolchain=[PYTHON], # type: ignore[arg-type]
**more,
),
]
@pytest.fixture
def project() -> str:
return f"pf-{uuid.uuid4().hex[:8]}"
def markers(world: dict[str, Any]) -> str:
"""Markers in the test's own directory, not this host's home."""
return str(Path(world["repo"].root).parent / "markers")
def agent_for(
world: dict[str, Any], project: str, model: ScriptedModel, **more: Any
) -> tuple[LoopAgent, InMemoryTransport]:
transport = InMemoryTransport()
cancel = Cancel()
loop = LoopAgent(
world["store"],
transport,
world["forge"],
projects=[
Project(
project,
"don/sandbox",
Workspace(world["host"], world["repo"].root, timeout_s=30),
world["milestones"],
)
],
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()),
preflight=True,
preflight_turns=5,
markers=markers(world),
**more,
)
transport.on_message(loop.on_message)
return loop, transport
def facts(world: dict[str, Any]) -> list[str]:
events = world["store"].transcript(the_item(world).id, "orchestrator").read()
return [e.event.text for e in events if isinstance(e.event, Fact)]
def said(transport: InMemoryTransport) -> list[str]:
return [m.text for m in transport.outbound]
def test_the_setup_step_comes_before_planning(
world: dict[str, Any], project: str
) -> None:
model = ScriptedModel(ready(), RESTATE)
agent, transport = agent_for(world, project, model)
agent.intake()
item = the_item(world)
assert (item.position, loop_of(item).wait) == (WAITING, GO_AHEAD)
known = facts(world)
toolchain = [
f for f in known if f.startswith("toolchain step: toolchain: python Python 3.")
]
assert toolchain, known
assert known.index(
"toolchain step: setting the build VM up from the repository"
) < known.index("restatement rev-1 put up for a go-ahead")
assert loop_of(item).toolchain[0][:2] == ("python", "python3 --version")
marker = preflight.check(
world["host"], project, str(world["repo"].root), markers(world)
).marker
assert marker is not None and marker["complete"]
assert not [t for t in said(transport) if t.startswith("Toolchain step:")] # quiet
def test_the_setup_step_runs_once_then_versions_are_checked(
world: dict[str, Any], project: str, monkeypatch: pytest.MonkeyPatch
) -> None:
model = ScriptedModel(ready(), RESTATE, [*WORK, *FINISH])
agent, transport = agent_for(world, project, model)
agent.intake()
real = preflight.versions
def newer(host: Any, tools: list[Any]) -> list[Any]:
return [
preflight.Tool(t.name, t.command, "Python 3.99.0")
for t in real(host, tools)
]
monkeypatch.setattr(preflight, "versions", newer)
say(transport, world, "go") # no second preflight: the script has none
changed = [f for f in facts(world) if f.startswith("the toolchain changed")]
assert changed and "→ Python 3.99.0" in changed[0]
assert any("toolchain changed" in t for t in said(transport))
[pr] = [n for n in world["forge"].prs]
assert "Tested with: python Python 3.99.0" in world["forge"].prs[pr]["body"]
def test_only_version_commands_are_accepted(
world: dict[str, Any], project: str
) -> None:
bad = call(
"toolchain_ready",
summary="s",
toolchain=[{"name": "py", "version_command": "python3 -c 'print(1)'"}], # type: ignore[arg-type]
)
model = ScriptedModel([bad], ready(), RESTATE)
agent, _ = agent_for(world, project, model)
agent.intake()
results = [
e.event.result
for e in world["store"].transcript(the_item(world).id, "driver").read()
if isinstance(e.event, ToolResult)
]
assert any("plain version commands" in r for r in results)
assert loop_of(the_item(world)).toolchain
def test_what_was_installed_or_is_missing_is_said(
world: dict[str, Any], project: str
) -> None:
model = ScriptedModel(
ready(
installed=["libfoo-dev"], # type: ignore[arg-type]
missing=[{"name": "protoc", "why": "no package for this OS"}], # type: ignore[arg-type]
),
RESTATE,
)
agent, transport = agent_for(world, project, model)
agent.intake()
[line] = [t for t in said(transport) if t.startswith("Toolchain step:")]
assert "installed: libfoo-dev" in line
assert "missing: protoc (no package for this OS)" in line
def test_an_unfinished_step_is_said_and_work_goes_on(
world: dict[str, Any], project: str
) -> None:
from nisse_core.log.canonical import AssistantText
prose = [AssistantText(text="hmm", model="m")]
model = ScriptedModel(prose, prose, RESTATE)
agent, transport = agent_for(world, project, model)
agent.intake()
assert loop_of(the_item(world)).wait == GO_AHEAD # it planned anyway
assert any("the step didn't finish" in t for t in said(transport))
marker = preflight.check(
world["host"], project, str(world["repo"].root), markers(world)
).marker
assert marker is not None and not marker["complete"]
def test_a_wiped_vm_is_cloned_again_and_set_up(
world: dict[str, Any], project: str
) -> None:
model = ScriptedModel(ready(), RESTATE, ready(), [*WORK, *FINISH])
agent, transport = agent_for(world, project, model)
agent.intake()
# The VM is rebuilt: the clone and the marker are gone.
shutil.rmtree(world["repo"].root)
world["host"].execute(f'rm -f "{preflight.marker_path(project, markers(world))}"')
say(transport, world, "go")
assert any("cloned the workspace again" in f for f in facts(world))
assert (Path(world["repo"].root) / ".git").exists()
assert (
sum("setting the build VM up from the repository" in f for f in facts(world))
== 2
)
assert world["forge"].prs # and the work went on to a PR
def test_a_missing_baseline_waits_on_the_operator(
world: dict[str, Any], project: str, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(preflight, "BASELINE", ("git", "no-such-tool-xyz"))
agent, _ = agent_for(world, project, ScriptedModel())
agent.intake()
loop = loop_of(the_item(world))
assert loop.wait == ANSWER and "lacks no-such-tool-xyz" in loop.note
def test_a_changed_host_key_blocks_the_vm_until_the_operators_word(
world: dict[str, Any], project: str, monkeypatch: pytest.MonkeyPatch
) -> None:
real = preflight.check
changed = {"on": True}
def check(host: Any, name: str, root: str, *rest: Any) -> Any:
if changed["on"]:
raise HostKeyChanged("the key changed")
return real(host, name, root, *rest)
forgotten: list[str] = []
monkeypatch.setattr(preflight, "check", check)
monkeypatch.setattr(
type(world["host"]), "forget_host_key", lambda self: forgotten.append("yes")
)
agent, transport = agent_for(world, project, ScriptedModel(ready(), RESTATE))
agent.intake()
[block] = world["store"].vm_blocks().values()
assert block.cause == "host key"
[asked] = [t for t in said(transport) if "different host key" in t]
assert f"`vm ready {project}`" in asked and "impersonating" in asked
assert "has a new host key" in agent.status()
say(transport, world, "it's fine") # an answer isn't the operator's word
assert world["store"].vm_blocks() and forgotten == []
changed["on"] = False
transport.receive(DON, f"vm ready {project}")
assert forgotten == ["yes"]
assert loop_of(the_item(world)).wait == GO_AHEAD # set up, then planned
# --- M4.2: the step's modes, and the go-ahead ----------------------------------------
def test_the_setup_step_edits_nothing_in_the_repository(
world: dict[str, Any], project: str
) -> None:
step = [
call("run_command", command="echo stray > stray.txt"),
*ready(),
]
agent, _ = agent_for(world, project, ScriptedModel(step, RESTATE))
agent.intake()
[said] = [f for f in facts(world) if f.startswith("toolchain step: toolchain")]
assert "changed files it shouldn't have, left for the work: stray.txt" in said
tools = [
e.event.tool
for e in world["store"].transcript(the_item(world).id, "toolchain").read()
if e.actor == "toolchain" and hasattr(e.event, "tool")
]
assert "write_file" not in tools # not offered in setup
def test_a_requested_step_brings_the_projects_files_in_step(
world: dict[str, Any], project: str
) -> None:
requested = [
call("write_file", path="README.md", content="# demo\n\nNeeds Python 3.12.\n"),
*ready(installed=["httpx"]), # type: ignore[arg-type]
]
model = ScriptedModel(
ready(),
RESTATE,
[
call("start_milestone", name="m"),
call("toolchain_step", reason="it needs httpx, and the README says so"),
],
requested,
[
call("write_file", path="w.py", content="import httpx\n"),
call("finish_milestone", summary="uses httpx", final="yes"),
],
)
agent, transport = agent_for(world, project, model)
agent.intake()
say(transport, world, "go")
forge = world["forge"]
[pr] = [n for n, p in forge.prs.items() if not p["head"].startswith("nisse-check")]
assert sorted(forge.files(pr)) == ["README.md", "w.py"] # reviewed with the work
driver = [
e.event for e in world["store"].transcript(the_item(world).id, "driver").view()
]
results = [getattr(e, "result", "") for e in driver]
assert any(r.startswith("toolchain step done: toolchain: python") for r in results)
# The step's own turns stay in its transcript.
calls = [getattr(e, "arguments", {}).get("path") for e in driver]
assert "README.md" not in calls
def test_before_the_go_ahead_the_driver_only_looks(
world: dict[str, Any], project: str
) -> None:
looking = [
call("run_command", command="python3 --version"),
call("run_command", command="pip install httpx"),
]
restate = [call("restate", text="add count_words to text.py; it needs httpx")]
agent, _ = agent_for(world, project, ScriptedModel(ready(), looking, restate))
agent.intake()
results = [
e.event.result
for e in world["store"].transcript(the_item(world).id, "driver").read()
if isinstance(e.event, ToolResult) and e.actor == "driver"
]
assert "Python 3." in results[0] # looking runs
assert results[1].startswith("not run: not before the go-ahead")
assert loop_of(the_item(world)).wait == GO_AHEAD
# --- M4.3: a broken VM blocks its projects until it's ready ---------------------------
def reimage(reason: str = "pip crashes in every venv, even a new one") -> list[Any]:
return [call("request_reimage", reason=reason)]
def by_issue(world: dict[str, Any], number: int) -> Any:
[found] = [i for i in world["store"].work_items() if loop_of(i).issue == number]
return found
def facts_of(world: dict[str, Any], item: Any) -> list[str]:
events = world["store"].transcript(item.id, "orchestrator").read()
return [e.event.text for e in events if isinstance(e.event, Fact)]
def test_a_broken_vm_blocks_its_project_until_the_operator_says_ready(
world: dict[str, Any], project: str, monkeypatch: pytest.MonkeyPatch
) -> None:
world["forge"].issues.append(Issue(15, "Add count_lines", "count lines", "don", ()))
forgotten: list[str] = []
monkeypatch.setattr(
type(world["host"]), "forget_host_key", lambda self: forgotten.append("yes")
)
# Asked for before the go-ahead: it's a request, not an action.
model = ScriptedModel(ready(), reimage(), ready(), RESTATE, RESTATE)
agent, transport = agent_for(world, project, model)
agent.intake()
first, second = by_issue(world, 14), by_issue(world, 15)
assert "build VM reimage requested: pip crashes in every venv, even a new one" in (
facts_of(world, first)
)
[asked] = [
m for m in transport.outbound if "reimage it from its template" in m.text
]
assert asked.thread is None # top level: it can cover several projects
assert "pip crashes in every venv" in asked.text
assert f"`vm ready {project}`" in asked.text
assert any(f.startswith("Blocked: the build VM") for f in facts_of(world, second))
# Neither runs while the VM is blocked, even after a restart.
again, _ = agent_for(world, project, ScriptedModel())
assert again._queue() == []
assert "its build VM needs reimaging" in again.status()
# The operator reimages it: the clone and the marker are gone.
shutil.rmtree(world["repo"].root)
world["host"].execute(f'rm -f "{preflight.marker_path(project, markers(world))}"')
transport.receive(DON, f"vm ready {project}")
assert forgotten == ["yes"] # its new host key is trusted
assert world["store"].vm_blocks() == {}
assert any("cloned the workspace again" in f for f in facts_of(world, first))
# The item that found it went first, and set the VM up again.
assert loop_of(by_issue(world, 14)).wait == GO_AHEAD
assert loop_of(by_issue(world, 15)).wait == GO_AHEAD
setups = [
f
for item in (first, second)
for f in facts_of(world, item)
if "setting the build VM up from the repository" in f
]
assert len(setups) == 2 # the first, and after the reimage
assert setups == [f for f in facts_of(world, first) if "setting the build VM" in f]
def test_a_vm_said_ready_without_a_reimage_goes_straight_on(
world: dict[str, Any], project: str
) -> None:
model = ScriptedModel(
ready(),
RESTATE,
[call("start_milestone", name="m"), *reimage("the build hangs")],
[*WORK[1:], *FINISH],
)
agent, transport = agent_for(world, project, model)
agent.intake()
say(transport, world, "go")
assert world["store"].vm_blocks()
transport.receive(DON, "vm ready") # the only blocked VM: no name needed
# Not reimaged: the marker is still there, so no new setup step.
assert (
sum("setting the build VM up from the repository" in f for f in facts(world))
== 1
)
assert world["forge"].prs # the work went on to a PR
def test_vm_ready_with_nothing_blocked_says_so(
world: dict[str, Any], project: str
) -> None:
_, transport = agent_for(world, project, ScriptedModel())
transport.receive(DON, "vm ready")
transport.receive(DON, f"vm ready {project}")
transport.receive(DON, "vm ready nowhere")
assert said(transport) == [
"No build VM is blocked.",
f"Nothing is blocking {project}'s build VM.",
"I don't know a project called nowhere.",
]
def test_work_in_progress_is_pushed_before_a_reimage(
world: dict[str, Any], project: str
) -> None:
model = ScriptedModel(
ready(),
RESTATE,
[
call("start_milestone", name="m"),
call("write_file", path="w.py", content="x = 1\n"),
*reimage(),
],
ready(), # set up again after the reimage
[call("finish_milestone", summary="w.py, tested", final="yes")],
)
agent, transport = agent_for(world, project, model)
agent.intake()
say(transport, world, "go")
[asked] = [t for t in said(transport) if "reimage it from its template" in t]
assert "The work in progress is pushed" in asked
shutil.rmtree(world["repo"].root)
world["host"].execute(f'rm -f "{preflight.marker_path(project, markers(world))}"')
transport.receive(DON, f"vm ready {project}")
forge = world["forge"]
[pr] = [n for n, p in forge.prs.items() if not p["head"].startswith("nisse-check")]
assert "w.py" in forge.files(pr) # it survived the reimage