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

+64 -6
View File
@@ -12,6 +12,7 @@ import json
import os
import random
import re
import shutil
import subprocess
import uuid
from pathlib import Path
@@ -94,6 +95,18 @@ class PolicyModel:
"the design has merged" in t for t in facts
)
path = f"docs/d{self.n}.md" if design else f"f{self.n}.py"
if "toolchain_ready" in offered:
# A preflight (M4.1): usually reported, sometimes badly or not.
if self.rng.random() < 0.15:
return [AssistantText(text="setting up", model=model)]
command = self.rng.choice(["python3 --version"] * 4 + ["python3 -c x"])
return [
call(
"toolchain_ready",
summary=f"ready {self.n}",
toolchain=[{"name": "python", "version_command": command}], # type: ignore[arg-type]
)
]
roll = self.rng.random()
if roll < 0.08:
return [call("ask_human", question=f"question {self.n}?")]
@@ -175,6 +188,26 @@ class PolicyModel:
]
class Watched:
"""The driver's model, watched: a run on a project item (not its
preflight) must find the project's marker on the VM."""
def __init__(self, walk: "Walk", inner: Any) -> None:
self.walk = walk
self.inner = inner
def complete(self, events, tools, model, system=None) -> list[Any]:
offered = {t["function"]["name"] for t in tools}
item = self.walk.world["store"].active_item()
working = "toolchain_ready" not in offered and "propose_work" not in offered
marked = (
item is not None and (self.walk.markers / f"{item.scope}.ready").exists()
)
if item is not None and item.scope != "general" and working and not marked:
self.walk.unready.append(item.id)
return self.inner.complete(events, tools, model, system)
class ClassifierModel:
"""A classifier answering at random, junk and failures included: the
loop must stay safe whatever it says (L4 fails closed)."""
@@ -247,6 +280,11 @@ class Walk:
# PRs, behind protection that refuses anything else.
self.merge = "approved" if seed % 2 == 0 else "operator"
self.agent_merges: list[tuple[int, bool]] = [] # (PR, approved at head)
# M4.1: markers in the walk's own directory; project runs seen
# without one.
self.markers = Path(self.world["repo"].root).parent / "markers"
self.unready: list[str] = []
self.wiped_at: dict[str, int] = {} # project -> last event before a wipe
if self.merge == "approved":
for w in worlds.values():
self._watch_merges(w["forge"])
@@ -303,7 +341,7 @@ class Walk:
operators={DON},
gitea_operators={"don"},
driver=Driver(
PolicyModel(self.rng, list(self.worlds)), # type: ignore[arg-type]
Watched(self, PolicyModel(self.rng, list(self.worlds))), # type: ignore[arg-type]
"m",
cancel=cancel,
),
@@ -322,6 +360,9 @@ class Walk:
),
bug_grace_s=600,
clock=lambda: self.now,
preflight=True,
preflight_turns=3,
markers=str(self.markers),
)
transport.on_message(agent.on_message)
return agent, transport
@@ -344,6 +385,7 @@ class Walk:
weights = {
"intake": 2 if not open_items else 1,
"crash": 1,
"wipe": 1,
"issue": 1,
"chat": 1,
}
@@ -374,6 +416,13 @@ class Walk:
self.steps.append(f"{action}{self.name(item)}" if item else action)
if action == "intake":
self.agent.intake()
elif action == "wipe":
# The build VM is rebuilt (D3): the clone and its marker gone.
w = self.worlds[project]
shutil.rmtree(w["repo"].root, ignore_errors=True)
events = self.world["store"].all_events()
self.wiped_at[project] = events[-1].id if events else 0
(self.markers / f"{project}.ready").unlink(missing_ok=True)
elif action == "done" and loop:
# The operator says a new project is ready; no entry was added.
self.transport.receive(DON, "done", thread=loop.thread)
@@ -535,6 +584,8 @@ class Walk:
def check(self) -> None:
where = f"seed {self.seed}, steps {self.steps}"
# M4.1: the driver never works on a project whose VM has no marker.
assert not self.unready, f"ready_before_work: {self.unready}: {where}"
# D5: the agent asks Gitea to merge only what an operator approved
# as it is (Gitea would refuse the rest; the harness mustn't ask).
unapproved = [pr for pr, approved in self.agent_merges if not approved]
@@ -647,7 +698,8 @@ class Walk:
helping = [
b
for b in [
*w["repo"].local_branches(),
# A wiped VM has no clone (and so no local branches).
*(w["repo"].local_branches() if w["repo"].exists() else []),
*_git(
w["bare"], "for-each-ref", "--format=%(refname:short)"
).split(),
@@ -738,11 +790,15 @@ class Walk:
every branch of an open item that isn't on the workspace points at is
in its project's forge."""
root, bare = world["repo"].root, world["bare"]
current = _git(root, "rev-parse", "--abbrev-ref", "HEAD")
cloned = world["repo"].exists() # not while a wiped VM waits for a run
current = _git(root, "rev-parse", "--abbrev-ref", "HEAD") if cloned else ""
ids = [i.id for i in self.items() if i.status == "open" and i.scope == name]
for line in _git(
root, "for-each-ref", "--format=%(refname:short)", "refs/heads"
).splitlines():
heads = (
_git(root, "for-each-ref", "--format=%(refname:short)", "refs/heads")
if cloned
else ""
)
for line in heads.splitlines():
if line == current or not any(line.startswith(f"{i}-") for i in ids):
continue
local = _git(root, "rev-parse", line)
@@ -773,6 +829,8 @@ class Walk:
if isinstance(e.event, ToolCall)
and e.event.tool == "write_file"
and e.actor == "driver"
# D3: a wiped VM loses what wasn't pushed, accepted.
and e.id > self.wiped_at.get(name, 0)
}
here = current.startswith(f"{item.id}-")
for path in written: