M4.1: toolchain preflight
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m59s
CI / e2e (pull_request) Successful in 10m33s

A model works out and sets up what a project needs on its build VM;
code makes sure that happens, bounds it and checks the result (D3).

- Before each driver run on a project, one round trip checks the build
  VM: the harness's own baseline (missing: the item waits, a template
  problem), the workspace clone (missing: cloned again and said; the
  item resumes from what it pushed) and a per-project marker.
- No marker (a new project, a new or wiped VM): the item's driver model
  runs a bounded preflight first, with its own prompt, the execution
  tools (screened as ever) and toolchain_ready. The harness runs each
  reported version command itself (version-shaped commands only, even
  from the marker, which the model's commands could edit) and writes
  the marker. Gaps and an unfinished preflight are facts and work goes
  on; the thread hears only of installs and gaps. A rebuilt VM starts
  from the last toolchain.
- Later runs check the versions again; a change is a fact and a thread
  line. The PR body says what the work was tested with.
- A changed host key is its own failure (HostKeyChanged): the item
  waits for the operator's `trust new host key`, which drops the pin;
  then the VM is set up again.
- A prompt line: install what the work needs on the build VM, say why,
  and put lasting needs in the project's own pins.
- tuning.preflight: max_turns, command_timeout_s.
- The property walk gains a VM wipe and ready_before_work (checked
  against a mutant that skips preflight); parked_work_safe accepts that
  a wipe loses what wasn't pushed (D3).

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:
dongarandClaude Opus 5.5 committed 2026-10-08 15:43:30 +00:00
1 parent 4564cd6f3f
commit 9968a0922a
19 files changed
+1103 -10

No files matched your search

+239 -2
View File
@@ -24,6 +24,7 @@ import uuid
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from typing import Any
from nisse_core.actions import DANGEROUS, REFUSED, UNKNOWN, Action, Verdict
from nisse_dev.cancel import Cancel
@@ -40,10 +41,12 @@ from nisse_dev.canonical import (
)
from nisse_dev.config import GENERAL
from nisse_dev.delegation import DELEGATION_INTENT, Delegation
from nisse_dev.execution import HostKeyChanged, HostUnavailable
from nisse_dev.expert import BudgetExhausted, ExpertDesk
from nisse_dev.git import GitError
from nisse_dev.gitea import GiteaClient, GiteaError, Issue, PullRequest
from nisse_dev.llm import LLMError, RoleMap
from nisse_dev.loop import preflight
from nisse_dev.loop.actions import classify, commits
from nisse_dev.loop.classifier import IN_SCOPE, ActionClassifier, Scope
from nisse_dev.loop.driver import ASK_ALLOW, Driver, RunOutcome, Screening
@@ -98,7 +101,7 @@ _COMMITTED = (
"finish_milestone, which pushes the milestone's branch and opens or "
"updates its PR. There is no PR until then."
)
_NEEDS_ITEM = frozenset({"go", "approve", "abandon", "allow"})
_NEEDS_ITEM = frozenset({"go", "approve", "abandon", "allow", "trust new host key"})
# Near-misses for abandon: asked about, never taken for it or for a reply.
_NEAR_ABANDON = frozenset(
{
@@ -144,6 +147,11 @@ never say you have stopped or closed anything yourself.
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
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
it there and say why, and put lasting needs in the project's own pins
(.python-version, rust-toolchain.toml, requires-python, engines) so they're
reviewed with the work.
Never change branches yourself.
Before each group of tool calls, say in a sentence what you're doing and why:
@@ -281,6 +289,10 @@ class LoopAgent:
reload: Callable[[set[str]], Sequence[Project]] | None = None,
helpers: Helpers | None = None,
classifier: ActionClassifier | None = None,
preflight: bool = False,
preflight_turns: int = 20,
preflight_timeout_s: int = 900,
markers: str = preflight.MARKERS,
clock: Callable[[], float] = time.time,
intake_interval_s: float = INTAKE_INTERVAL_S,
) -> None:
@@ -305,6 +317,12 @@ class LoopAgent:
self._helpers = helpers
# L4: judges what the fast path doesn't know; without one, it runs.
self._classifier = classifier
# Toolchain preflight (M4.1): off where a test drives the loop
# without it.
self._preflight = preflight
self._preflight_turns = preflight_turns
self._preflight_timeout_s = preflight_timeout_s
self._markers = markers # where the build VM keeps them
# The trust dial (M3.3): projects whose protection was checked, and
# whether the harness may merge their approved PRs.
self._merging: dict[str, bool] = {}
@@ -422,6 +440,184 @@ class LoopAgent:
thread=loop.thread,
)
# -- the build VM: ready, and its toolchain (D3; M4.1) ----------------------
def _readiness(
self, item: WorkItem, project: Project, loop: Loop
) -> tuple[Loop, dict[str, Any] | None] | None:
"""The build VM as the harness relies on it: reachable with the key
it pinned, its baseline there, the workspace cloned (again, if the
VM was rebuilt or wiped). None when the item waits on the operator."""
assert project.workspace is not None and project.milestones is not None
host = project.workspace.host
root = str(project.workspace.root)
try:
ready = preflight.check(host, project.name, root, self._markers)
except HostKeyChanged:
note = (
f"the build VM for {project.name} ({host.describe()}) answered "
"with a different host key than the one I pinned. If you "
"rebuilt it, say `trust new host key` and I'll set it up "
"again; if you didn't, something is impersonating it."
)
self._wait(
item, replace(loop, wait=ANSWER, note=note, host_key_changed=True), note
)
self._store.set_active(None, cause="host key changed")
return None
except HostUnavailable as exc:
note = f"I can't reach the build VM: {exc}. Reply to retry."
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
self._store.set_active(None, cause="build VM unreachable")
return None
if ready.missing:
note = (
f"the build VM for {project.name} lacks {', '.join(ready.missing)}, "
"which I rely on there: that's for its template. Reply once it's "
"fixed."
)
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
self._store.set_active(None, cause="build VM baseline missing")
return None
if not ready.cloned:
try:
project.milestones.reclone(self._account)
except Exception as exc: # noqa: BLE001 -- the item waits, not the loop
note = f"I couldn't clone the workspace again: {exc}. Reply to retry."
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
self._store.set_active(None, cause="re-clone failed")
return None
if loop.prepared:
said = (
"the build VM was rebuilt or wiped: I cloned the workspace "
"again; work that wasn't pushed is gone, and pushed branches "
"come back from Gitea"
)
self._fact(item, said)
self._transport.post(
said[0].upper() + said[1:] + ".", thread=loop.thread
)
# Resuming takes the item's branch from Gitea when it was
# pushed, or starts it again from the default branch.
return loop, ready.marker
def _toolchain(
self,
item: WorkItem,
project: Project,
loop: Loop,
marker: dict[str, Any] | None,
starting: bool,
) -> Loop | None:
"""Preflight when the VM has no marker for the project (or only an
unfinished one, at an item's start); otherwise the toolchain's
versions checked again, and a change said."""
assert project.workspace is not None
if marker is None or (starting and not marker.get("complete")):
return self._preflight_run(item, project, loop, marker)
host = project.workspace.host
before = preflight.tools_of(loop.toolchain) or preflight.tools_of(
marker.get("toolchain")
)
try:
now = preflight.versions(host, before)
except HostUnavailable:
return loop # the run will find out, and say so
changed = preflight.drift(before, now)
if changed:
said = "the toolchain changed since it was checked: " + "; ".join(changed)
self._fact(item, said)
self._transport.post(said[0].upper() + said[1:] + ".", thread=loop.thread)
if starting:
self._fact(item, f"toolchain: {preflight.said(now)}")
return replace(loop, toolchain=tuple(tuple(t.to_data()) for t in now))
def _preflight_run(
self,
item: WorkItem,
project: Project,
loop: Loop,
marker: dict[str, Any] | None,
) -> Loop | None:
"""D3: before any planning, the item's driver gets the project's
toolchain ready; the harness checks what it reports."""
assert project.workspace is not None
workspace = project.workspace.with_timeout(self._preflight_timeout_s)
last = preflight.tools_of((marker or {}).get("toolchain"))
system = preflight.SYSTEM.format(
repo=project.repo,
workspace=workspace.describe(),
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
else ""
),
)
self._fact(item, "preflight: getting the project's toolchain ready")
roles = self._roles_of(item)
self._active_thread = loop.thread
self._active_name = self._label(item)
self._running = True
try:
outcome = self._driver.run(
self._store.transcript(item.id, "driver"),
system=system,
tools=[*EXECUTION_TOOLS, preflight.TOOLCHAIN_READY],
workspace=workspace,
checks={"toolchain_ready": preflight.report_problem},
model=roles.resolve("driver"),
screen=lambda call: self._screen(item.id, str(workspace.root), call),
max_turns=self._preflight_turns,
)
finally:
self._running = False
if outcome.kind == "stopped":
self._cancel.clear()
self._settle(item.id, outcome)
return None
host = workspace.host
now = loop_of(self._store.work_item(item.id) or item)
if outcome.kind == "wait" and outcome.tool == "toolchain_ready":
report = preflight.report_of(outcome.arguments or {})
installed = [
str(i) for i in (outcome.arguments or {}).get("installed") or []
]
complete = True
else:
report = preflight.Report(
f"the preflight didn't finish ({outcome.note or outcome.kind})", last
)
installed, complete = [], False
try:
tools = preflight.versions(host, report.tools)
preflight.write_marker(
host,
project.name,
markers=self._markers,
data={
"toolchain": [t.to_data() for t in tools],
"complete": complete,
"item": item.id,
"at": datetime.now(UTC).isoformat(timespec="seconds"),
},
)
except HostUnavailable:
tools = report.tools
missing = [f"{name} ({why})" for name, why in report.missing]
parts = [f"toolchain: {preflight.said(tools)}"]
if installed:
parts.append("installed: " + ", ".join(installed))
if missing:
parts.append("missing: " + "; ".join(missing))
if not complete:
parts.append(report.summary)
said = "; ".join(parts)
self._fact(item, f"preflight: {said}")
if installed or missing or not complete:
self._transport.post(f"Preflight: {said}.", thread=loop.thread)
return replace(now, toolchain=tuple(tuple(t.to_data()) for t in tools))
# -- the trust dial (L3, D5; M3.3) --------------------------------------------
def _merge_if_approved(
@@ -1096,6 +1292,27 @@ class LoopAgent:
),
cause="the operator asked a question",
)
elif command == "trust new host key":
project = self._at(item)
if loop.host_key_changed and project.workspace is not None:
project.workspace.host.forget_host_key()
self._fact(
item,
f"the operator ({msg.sender}) trusted the build VM's new "
"host key: it will be set up again",
)
save(
self._store,
item.id,
READY,
replace(loop, wait="", note="", host_key_changed=False),
cause=f"new host key trusted by {msg.sender}",
)
else:
self._transport.post(
f"Nothing on {self._label(item)} is waiting for that.",
thread=loop.thread,
)
elif command == "allow":
if item.position == WAITING and loop.pending_action:
action = json.loads(loop.pending_action)
@@ -2061,6 +2278,15 @@ class LoopAgent:
milestones = project.milestones
self._store.set_active(item.id, cause="ready")
self._park_elsewhere(project.name)
checked = self._preflight and project.workspace is not None
marker: dict[str, Any] | None = None
starting = not loop.prepared
if checked and milestones is not None:
ready = self._readiness(item, project, loop)
if ready is None:
return # waiting on the operator
loop, marker = ready
starting = not loop.prepared
if milestones is not None:
keep = self._keep(project.name)
try:
@@ -2086,6 +2312,12 @@ class LoopAgent:
self._transport.post(said, thread=loop.thread)
loop = replace(loop, prepared=True)
save(self._store, item.id, RUNNING, loop, cause="driver run")
if checked and milestones is not None:
toolchain = self._toolchain(item, project, loop, marker, starting)
if toolchain is None:
return # preflight was stopped: settled
loop = toolchain
save(self._store, item.id, RUNNING, loop, cause="toolchain checked")
# D1: a `discuss` item talks before it restates.
hold = (
"discuss" in loop.labels and not loop.talked
@@ -2497,11 +2729,16 @@ class LoopAgent:
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
return
loop = replace(loop, milestone=number, branch=branch)
tested = preflight.tools_of(loop.toolchain)
published = milestones.publish(
item_id=item.id,
branch=loop.branch,
title=title or f"{item.title} (milestone {loop.milestone})",
summary=summary,
summary=(
f"{summary}\n\nTested with: {preflight.said(tested)}"
if tested
else summary
),
issue=loop.issue,
final=final,
)