M4.1: toolchain preflight
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:
1 parent
4564cd6f3f
commit
9968a0922a
19 files changed
+1103
-10
No files matched your search
@@ -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,
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user