Merge pull request 'M4.2: the toolchain step' (#136) from toolchain-step into main
CI / checks (push) Successful in 2m23s
CI / e2e (push) Skipped

This commit was merged in pull request #136.
This commit is contained in:
dev-agent committed 2026-10-08 18:22:01 +00:00
commit 7c3c09ab98
10 files changed
+276 -55

No files matched your search

+2 -1
View File
@@ -402,7 +402,8 @@ needs; code makes sure that happens, bounds it and checks the result.
without it), preflight runs on the driver's model, gaps don't block. Turning without it), preflight runs on the driver's model, gaps don't block. Turning
off unattended upgrades and the template's baseline are template choices. off unattended upgrades and the template's baseline are template choices.
**M4.2** *(revised with the operator: in place of recording installs).* The **M4.2** *(revised with the operator: in place of recording installs).*
**Done** (this PR). The
preflight becomes **the toolchain step** (D3): make the VM and the project's own preflight becomes **the toolchain step** (D3): make the VM and the project's own
files agree, with the repository as the record rather than a harness-kept one. files agree, with the repository as the record rather than a harness-kept one.
- Started by the harness when the VM isn't set up for the project (M4.1's - Started by the harness when the VM isn't set up for the project (M4.1's
+5 -4
View File
@@ -195,9 +195,10 @@ looking around, not doing the work. Before M2.8 this was
### `tuning.preflight.max_turns` — default 20 ### `tuning.preflight.max_turns` — default 20
How many model calls the toolchain preflight may make (D3). The preflight runs How many model calls one toolchain step may make (D3). The harness starts one
before the driver plans, whenever a project's build VM has no marker from a before the driver plans whenever a project's build VM has no marker from a
finished one: a new project, a new or wiped VM. finished one (a new project, a new or wiped VM), and the driver asks for one
when the work needs a toolchain change.
- **Too low:** a project with several things to install doesn't finish, and - **Too low:** a project with several things to install doesn't finish, and
says so; work goes on with what's missing stated, and the next item tries says so; work goes on with what's missing stated, and the next item tries
@@ -209,7 +210,7 @@ finished one: a new project, a new or wiped VM.
### `tuning.preflight.command_timeout_s` — default 900 ### `tuning.preflight.command_timeout_s` — default 900
The limit on one command during the preflight, in place of The limit on one command during a toolchain step, in place of
`tuning.tools.command_timeout_s`: installing a compiler or a language runtime `tuning.tools.command_timeout_s`: installing a compiler or a language runtime
can take minutes. Commands in ordinary runs keep their own, shorter limit. can take minutes. Commands in ordinary runs keep their own, shorter limit.
+2 -2
View File
@@ -90,8 +90,8 @@ tuning:
driver: driver:
max_turns: 40 # model calls per driver run before it stops as capped max_turns: 40 # model calls per driver run before it stops as capped
failure_limit: 2 # consecutive failed commands before escalating (L9) failure_limit: 2 # consecutive failed commands before escalating (L9)
preflight: # getting a project's toolchain ready (D3) preflight: # the toolchain step (D3)
max_turns: 20 # model calls the preflight run may make max_turns: 20 # model calls one toolchain step may make
command_timeout_s: 900 # one command in it: installs take a while command_timeout_s: 900 # one command in it: installs take a while
expert: expert:
consult_budget: 3 # ask_expert calls per milestone (L9) consult_budget: 3 # ask_expert calls per milestone (L9)
+130 -28
View File
@@ -26,7 +26,14 @@ from dataclasses import dataclass, replace
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from nisse_core.actions import DANGEROUS, REFUSED, UNKNOWN, Action, Verdict from nisse_core.actions import (
CONTAINED,
DANGEROUS,
REFUSED,
UNKNOWN,
Action,
Verdict,
)
from nisse_dev.cancel import Cancel from nisse_dev.cancel import Cancel
from nisse_dev.canonical import ( from nisse_dev.canonical import (
ActionVerdict, ActionVerdict,
@@ -79,6 +86,7 @@ from nisse_dev.loop.tools import (
REPLY, REPLY,
RESTATE, RESTATE,
START_MILESTONE, START_MILESTONE,
TOOLCHAIN_STEP,
WAIT_FOR_REVIEW, WAIT_FOR_REVIEW,
) )
from nisse_dev.oplog import OpLog from nisse_dev.oplog import OpLog
@@ -148,10 +156,10 @@ Commands that reach beyond this repository and its build VM (pushing, other
hosts, uploads, publishing) or could break the host are put to the operator hosts, uploads, publishing) or could break the host are put to the operator
before they run; if they allow one, run it again exactly as before. before they run; if they allow one, run it again exactly as before.
The build VM is the project's: if the work needs a tool or library, install If the work needs a toolchain change (a new tool, library or version), ask for
it there and say why, and put lasting needs in the project's own pins a toolchain_step with why: it installs it and keeps the project's pins, setup
(.python-version, rust-toolchain.toml, requires-python, engines) so they're docs and CI configuration in step, as part of the work. Before the go-ahead,
reviewed with the work. you only look: say what the work needs in your restatement.
Never change branches yourself. Never change branches yourself.
Before each group of tool calls, say in a sentence what you're doing and why: Before each group of tool calls, say in a sentence what you're doing and why:
@@ -514,7 +522,8 @@ class LoopAgent:
versions checked again, and a change said.""" versions checked again, and a change said."""
assert project.workspace is not None assert project.workspace is not None
if marker is None or (starting and not marker.get("complete")): if marker is None or (starting and not marker.get("complete")):
return self._preflight_run(item, project, loop, marker) stepped = self._toolchain_step(item, project, loop, marker)
return None if stepped is None else stepped[0]
host = project.workspace.host host = project.workspace.host
before = preflight.tools_of(loop.toolchain) or preflight.tools_of( before = preflight.tools_of(loop.toolchain) or preflight.tools_of(
marker.get("toolchain") marker.get("toolchain")
@@ -532,47 +541,72 @@ class LoopAgent:
self._fact(item, f"toolchain: {preflight.said(now)}") self._fact(item, f"toolchain: {preflight.said(now)}")
return replace(loop, toolchain=tuple(tuple(t.to_data()) for t in now)) return replace(loop, toolchain=tuple(tuple(t.to_data()) for t in now))
def _preflight_run( def _toolchain_step(
self, self,
item: WorkItem, item: WorkItem,
project: Project, project: Project,
loop: Loop, loop: Loop,
marker: dict[str, Any] | None, marker: dict[str, Any] | None,
) -> Loop | None: *,
"""D3: before any planning, the item's driver gets the project's reason: str = "",
toolchain ready; the harness checks what it reports.""" ) -> tuple[Loop, str] | None:
"""D3: the toolchain step, on the item's driver model in a transcript
of its own. Without a ``reason`` the harness started it (the VM isn't
set up): it sets the VM up from the repository and edits nothing
there. With one the driver asked: it may bring the project's files in
step. The harness checks what it reports. None when it was stopped
before the work (settled)."""
assert project.workspace is not None assert project.workspace is not None
workspace = project.workspace.with_timeout(self._preflight_timeout_s) workspace = project.workspace.with_timeout(self._preflight_timeout_s)
requested = bool(reason)
if requested:
last = preflight.tools_of(loop.toolchain)
mode = preflight.REQUESTED.format(reason=reason)
else:
last = preflight.tools_of((marker or {}).get("toolchain")) last = preflight.tools_of((marker or {}).get("toolchain"))
system = preflight.SYSTEM.format( mode = preflight.SETUP.format(
repo=project.repo,
workspace=workspace.describe(),
last=( last=(
f"Last time, this project's toolchain was: {preflight.said(last)}. " f"Last time, this project's toolchain was: {preflight.said(last)}."
"Set that up again, then check whether it needs anything else.\n"
if last if last
else "" else ""
),
) )
self._fact(item, "preflight: getting the project's toolchain ready") )
system = preflight.SYSTEM.format(
repo=project.repo, workspace=workspace.describe(), mode=mode
)
self._fact(
item,
f"toolchain step requested: {reason}"
if requested
else "toolchain step: setting the build VM up from the repository",
)
before = self._changed_files(project)
roles = self._roles_of(item) roles = self._roles_of(item)
outer = self._running # a requested step runs inside the driver's run
if not outer:
self._active_thread = loop.thread self._active_thread = loop.thread
self._active_name = self._label(item) self._active_name = self._label(item)
self._running = True self._running = True
try: try:
outcome = self._driver.run( outcome = self._driver.run(
self._store.transcript(item.id, "driver"), self._store.transcript(item.id, "toolchain"),
system=system, system=system,
tools=[*EXECUTION_TOOLS, preflight.TOOLCHAIN_READY], tools=[
*(EXECUTION_TOOLS if requested else _LOOK),
preflight.TOOLCHAIN_READY,
],
workspace=workspace, workspace=workspace,
checks={"toolchain_ready": preflight.report_problem}, checks={"toolchain_ready": preflight.report_problem},
model=roles.resolve("driver"), model=roles.resolve("driver"),
screen=lambda call: self._screen(item.id, str(workspace.root), call), screen=lambda call: self._screen(
item.id, str(workspace.root), call, guard=False
),
max_turns=self._preflight_turns, max_turns=self._preflight_turns,
) )
finally: finally:
if not outer:
self._running = False self._running = False
if outcome.kind == "stopped": if outcome.kind == "stopped" and not outer:
self._cancel.clear() self._cancel.clear()
self._settle(item.id, outcome) self._settle(item.id, outcome)
return None return None
@@ -586,7 +620,7 @@ class LoopAgent:
complete = True complete = True
else: else:
report = preflight.Report( report = preflight.Report(
f"the preflight didn't finish ({outcome.note or outcome.kind})", last f"the step didn't finish ({outcome.note or outcome.kind})", last
) )
installed, complete = [], False installed, complete = [], False
try: try:
@@ -610,13 +644,59 @@ class LoopAgent:
parts.append("installed: " + ", ".join(installed)) parts.append("installed: " + ", ".join(installed))
if missing: if missing:
parts.append("missing: " + "; ".join(missing)) parts.append("missing: " + "; ".join(missing))
if not complete: if not complete or requested:
parts.append(report.summary) parts.append(report.summary)
said = "; ".join(parts) if not requested:
self._fact(item, f"preflight: {said}") # D3: the harness's step edits nothing in the repository.
edited = sorted(set(self._changed_files(project)) - set(before))
if edited:
parts.append(
"it changed files it shouldn't have, left for the work: "
+ ", ".join(edited)
)
said = "; ".join(p for p in parts if p)
self._fact(item, f"toolchain step: {said}")
if installed or missing or not complete: if installed or missing or not complete:
self._transport.post(f"Preflight: {said}.", thread=loop.thread) self._transport.post(f"Toolchain step: {said}.", thread=loop.thread)
return replace(now, toolchain=tuple(tuple(t.to_data()) for t in tools)) loop = replace(now, toolchain=tuple(tuple(t.to_data()) for t in tools))
return loop, said
def _changed_files(self, project: Project) -> list[str]:
"""The workspace's changes, as git sees them (for the setup step's
promise to edit nothing)."""
assert project.workspace is not None
try:
result = project.workspace.host.execute(
"git status --porcelain", cwd=str(project.workspace.root), timeout_s=30
)
except HostUnavailable:
return []
return [line[3:] for line in result.stdout.splitlines() if line.strip()]
def _requested_step(self, item_id: str, reason: str) -> tuple[str, bool]:
"""The driver's toolchain_step: once the work is agreed (D1)."""
item = self._store.work_item(item_id)
assert item is not None
loop = loop_of(item)
project = self._at(item)
if not loop.started:
return (
(
"not before the go-ahead: say what the work needs in your "
"restatement, and ask for the step once work has started"
),
False,
)
if not reason:
return "say what the work needs, and why", False
if project.workspace is None:
return "there's no build VM here", False
stepped = self._toolchain_step(item, project, loop, None, reason=reason)
if stepped is None:
return "the toolchain step was stopped", False
loop, said = stepped
save(self._store, item.id, item.position, loop, cause="toolchain step")
return f"toolchain step done: {said}", True
# -- the trust dial (L3, D5; M3.3) -------------------------------------------- # -- the trust dial (L3, D5; M3.3) --------------------------------------------
@@ -1904,7 +1984,9 @@ class LoopAgent:
# -- the fast path (P4, D6) ------------------------------------------------- # -- the fast path (P4, D6) -------------------------------------------------
def _screen(self, item_id: str, root: str, call: ToolCall) -> Screening: def _screen(
self, item_id: str, root: str, call: ToolCall, *, guard: bool = True
) -> Screening:
"""The driver's execution call, judged before it runs (P4): by the """The driver's execution call, judged before it runs (P4): by the
fast path's rules, then the classifier for what they don't know fast path's rules, then the classifier for what they don't know
(L4). A dangerous one runs only when the operator allowed exactly (L4). A dangerous one runs only when the operator allowed exactly
@@ -1915,8 +1997,24 @@ class LoopAgent:
action = Action(call.tool, dict(call.arguments)) action = Action(call.tool, dict(call.arguments))
key = action.key() key = action.key()
verdict, judged = self._judge(item, "driver", call, action, root) verdict, judged = self._judge(item, "driver", call, action, root)
early = (
guard
and not loop.started
and (
verdict.effect == DANGEROUS
or (judged is None and verdict.effect == CONTAINED)
)
)
if verdict.effect == REFUSED: if verdict.effect == REFUSED:
decision, screening = "refused", Screening("refuse", verdict.reason) decision, screening = "refused", Screening("refuse", verdict.reason)
elif early:
# D1: until the go-ahead the agent only looks.
decision = "refused"
screening = Screening(
"refuse",
f"not before the go-ahead: {verdict.reason}. Say what the work "
"needs in your restatement",
)
elif judged is not None and verdict.rule == IN_SCOPE: elif judged is not None and verdict.rule == IN_SCOPE:
decision, screening = "ran", Screening("run") decision, screening = "ran", Screening("run")
self._in_scope(item, action, verdict.reason) self._in_scope(item, action, verdict.reason)
@@ -2336,6 +2434,7 @@ class LoopAgent:
CLOSE_ITEM, CLOSE_ITEM,
*([WAIT_FOR_REVIEW] if loop.pr else []), *([WAIT_FOR_REVIEW] if loop.pr else []),
*([DELEGATE, ASK_EXPERT] if self._helpers else []), *([DELEGATE, ASK_EXPERT] if self._helpers else []),
*([TOOLCHAIN_STEP] if checked and project.workspace else []),
*([REPLY] if loop.back_to else []), *([REPLY] if loop.back_to else []),
*EXECUTION_TOOLS, *EXECUTION_TOOLS,
] ]
@@ -2376,6 +2475,9 @@ class LoopAgent:
"ask_expert": lambda args: self._ask_expert( "ask_expert": lambda args: self._ask_expert(
item.id, str(args.get("question", "")).strip() item.id, str(args.get("question", "")).strip()
), ),
"toolchain_step": lambda args: self._requested_step(
item.id, str(args.get("reason", "")).strip()
),
}, },
checks={ checks={
"restate": lambda args: ( "restate": lambda args: (
+20 -6
View File
@@ -229,15 +229,16 @@ TOOLCHAIN_READY = {
}, },
} }
SYSTEM = """You are nisse-dev, getting a project's toolchain ready on its build VM SYSTEM = """You are nisse-dev, running a toolchain step for {repo}: making its
before any work starts. The project is {repo}; its clone is the workspace. build VM and the project's own files agree. Its clone is the workspace.
{workspace} {workspace}
Read the project's own files to work out what it needs to build, test and run: Work out from the project's own files what it needs to build, test and run: its
its language runtimes and compilers, package and build tools, and anything else language runtimes and compilers, package and build tools, and anything else it
it calls (system libraries, CLIs, a database for its tests). Honour its pins calls (system libraries, CLIs, a database for its tests). Honour its pins
(.python-version, rust-toolchain.toml, requires-python, engines, lockfiles). (.python-version, rust-toolchain.toml, requires-python, engines, lockfiles).
{last}
{mode}
Check what's there with version commands; install what's missing. The build Check what's there with version commands; install what's missing. The build
VM is the project's and disposable: installing on it is fine. Don't install or VM is the project's and disposable: installing on it is fine. Don't install or
change anything for other hosts, and never install, store or ask for change anything for other hosts, and never install, store or ask for
@@ -252,6 +253,19 @@ found and did: each tool whose version matters with a command that prints its
version, what you installed, and what's still missing and why. Keep it short: version, what you installed, and what's still missing and why. Keep it short:
this is setup, not the work.""" this is setup, not the work."""
SETUP = """This build VM is new to the project, or was rebuilt. Set it up from what
the repository says: its pins, setup docs and CI configuration. Don't change
the repository's files: if they're out of step with what the project needs,
say so in your summary, and the work can put them right.
{last}"""
REQUESTED = """The work needs a toolchain change: {reason}
Install what it needs, then bring the project's own files in step with it: its
pins (.python-version, rust-toolchain.toml, requires-python, engines,
lockfiles), its setup docs (the README, or wherever it documents setup) and its
CI configuration. Those edits are part of the current work and are reviewed
with it."""
def report_of(arguments: dict[str, Any]) -> Report: def report_of(arguments: dict[str, Any]) -> Report:
tools = [ tools = [
+9
View File
@@ -69,6 +69,15 @@ WAIT_FOR_REVIEW = _tool(
note="What you're waiting on, for the operator.", note="What you're waiting on, for the operator.",
) )
TOOLCHAIN_STEP = _tool(
"toolchain_step",
"The work needs a toolchain change (a new tool, library or version): a "
"step that installs it on the build VM and brings the project's pins, "
"setup docs and CI configuration in step. Its edits are part of the "
"current work. Returns its report.",
reason="What the work needs, and why.",
)
# A conversation's tools (L1, L5): it talks, and may propose work. # A conversation's tools (L1, L5): it talks, and may propose work.
REPLY = _tool( REPLY = _tool(
"reply", "reply",
+1
View File
@@ -54,6 +54,7 @@ ACTORS = frozenset(
"helper", "helper",
"expert", "expert",
"classifier", "classifier",
"toolchain", # the toolchain step (D3): the driver's model, apart
} }
) )
+3
View File
@@ -41,6 +41,9 @@ NARRATION_RESULT_CHARS = 300
_SHOWN: dict[str, dict[str, str]] = { _SHOWN: dict[str, dict[str, str]] = {
"driver": {"helper": "report"}, "driver": {"helper": "report"},
"helper": {"driver": "narrate"}, "helper": {"driver": "narrate"},
# The toolchain step works in its own transcript: the driver sees only
# its report (a tool result, or facts), the step the driver's work.
"toolchain": {"driver": "narrate"},
} }
+6
View File
@@ -145,6 +145,12 @@ class PolicyModel:
return [call("ask_human", question=f"talk first {self.n}?")] # discuss return [call("ask_human", question=f"talk first {self.n}?")] # discuss
if "finish_milestone" not in offered: if "finish_milestone" not in offered:
return [call("restate", text=f"restatement {self.n}", changed=f"c{self.n}")] return [call("restate", text=f"restatement {self.n}", changed=f"c{self.n}")]
if "toolchain_step" in offered and self.rng.random() < 0.06:
# M4.2: the work asks for a toolchain change.
return [
call("start_milestone", name=f"m{self.n}"),
call("toolchain_step", reason=f"needs a tool {self.n}"),
]
if "delegate" in offered and self.rng.random() < 0.1: if "delegate" in offered and self.rng.random() < 0.1:
return [call("delegate", task=f"help {self.n}")] return [call("delegate", task=f"help {self.n}")]
allowed = [t for t in facts[-3:] if "allowed this once" in t] allowed = [t for t in facts[-3:] if "allowed this once" in t]
@@ -1,8 +1,9 @@
"""Toolchain preflight in the loop (D3, M4.1): before the driver plans, the """The toolchain step in the loop (D3; M4.1, M4.2): before the driver plans,
build VM is checked (baseline, clone, marker); without a marker the item's the build VM is checked (baseline, clone, marker); without a marker the harness
driver gets the toolchain ready and the harness checks what it reports; later starts a toolchain step that sets the VM up from the repository and edits
runs check the versions again; a rebuilt VM is re-cloned and set up again, nothing there; the driver can ask for one once work is agreed, whose edits are
and a changed host key waits for the operator.""" 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."""
import io import io
import shutil import shutil
@@ -102,7 +103,9 @@ def said(transport: InMemoryTransport) -> list[str]:
return [m.text for m in transport.outbound] return [m.text for m in transport.outbound]
def test_preflight_comes_before_planning(world: dict[str, Any], project: str) -> None: def test_the_setup_step_comes_before_planning(
world: dict[str, Any], project: str
) -> None:
model = ScriptedModel(ready(), RESTATE) model = ScriptedModel(ready(), RESTATE)
agent, transport = agent_for(world, project, model) agent, transport = agent_for(world, project, model)
agent.intake() agent.intake()
@@ -110,21 +113,21 @@ def test_preflight_comes_before_planning(world: dict[str, Any], project: str) ->
assert (item.position, loop_of(item).wait) == (WAITING, GO_AHEAD) assert (item.position, loop_of(item).wait) == (WAITING, GO_AHEAD)
known = facts(world) known = facts(world)
toolchain = [ toolchain = [
f for f in known if f.startswith("preflight: toolchain: python Python 3.") f for f in known if f.startswith("toolchain step: toolchain: python Python 3.")
] ]
assert toolchain, known assert toolchain, known
assert known.index( assert known.index(
"preflight: getting the project's toolchain ready" "toolchain step: setting the build VM up from the repository"
) < known.index("restatement rev-1 put up for a go-ahead") ) < known.index("restatement rev-1 put up for a go-ahead")
assert loop_of(item).toolchain[0][:2] == ("python", "python3 --version") assert loop_of(item).toolchain[0][:2] == ("python", "python3 --version")
marker = preflight.check( marker = preflight.check(
world["host"], project, str(world["repo"].root), markers(world) world["host"], project, str(world["repo"].root), markers(world)
).marker ).marker
assert marker is not None and marker["complete"] assert marker is not None and marker["complete"]
assert not [t for t in said(transport) if t.startswith("Preflight:")] # quiet assert not [t for t in said(transport) if t.startswith("Toolchain step:")] # quiet
def test_preflight_runs_once_then_versions_are_checked( def test_the_setup_step_runs_once_then_versions_are_checked(
world: dict[str, Any], project: str, monkeypatch: pytest.MonkeyPatch world: dict[str, Any], project: str, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
model = ScriptedModel(ready(), RESTATE, [*WORK, *FINISH]) model = ScriptedModel(ready(), RESTATE, [*WORK, *FINISH])
@@ -179,12 +182,12 @@ def test_what_was_installed_or_is_missing_is_said(
) )
agent, transport = agent_for(world, project, model) agent, transport = agent_for(world, project, model)
agent.intake() agent.intake()
[line] = [t for t in said(transport) if t.startswith("Preflight:")] [line] = [t for t in said(transport) if t.startswith("Toolchain step:")]
assert "installed: libfoo-dev" in line assert "installed: libfoo-dev" in line
assert "missing: protoc (no package for this OS)" in line assert "missing: protoc (no package for this OS)" in line
def test_an_unfinished_preflight_is_said_and_work_goes_on( def test_an_unfinished_step_is_said_and_work_goes_on(
world: dict[str, Any], project: str world: dict[str, Any], project: str
) -> None: ) -> None:
from nisse_dev.canonical import AssistantText from nisse_dev.canonical import AssistantText
@@ -194,7 +197,7 @@ def test_an_unfinished_preflight_is_said_and_work_goes_on(
agent, transport = agent_for(world, project, model) agent, transport = agent_for(world, project, model)
agent.intake() agent.intake()
assert loop_of(the_item(world)).wait == GO_AHEAD # it planned anyway assert loop_of(the_item(world)).wait == GO_AHEAD # it planned anyway
assert any("the preflight didn't finish" in t for t in said(transport)) assert any("the step didn't finish" in t for t in said(transport))
marker = preflight.check( marker = preflight.check(
world["host"], project, str(world["repo"].root), markers(world) world["host"], project, str(world["repo"].root), markers(world)
).marker ).marker
@@ -213,7 +216,10 @@ def test_a_wiped_vm_is_cloned_again_and_set_up(
say(transport, world, "go") say(transport, world, "go")
assert any("cloned the workspace again" in f for f in facts(world)) assert any("cloned the workspace again" in f for f in facts(world))
assert (Path(world["repo"].root) / ".git").exists() assert (Path(world["repo"].root) / ".git").exists()
assert sum("getting the project's toolchain ready" in f for f in facts(world)) == 2 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 assert world["forge"].prs # and the work went on to a PR
@@ -254,3 +260,81 @@ def test_a_changed_host_key_waits_for_the_operators_word(
say(transport, world, "trust new host key") say(transport, world, "trust new host key")
assert forgotten == ["yes"] assert forgotten == ["yes"]
assert loop_of(the_item(world)).wait == GO_AHEAD # set up, then planned 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