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,
)
+5
View File
@@ -324,6 +324,11 @@ def build_loop_agent(
for entry in (load or _from_file(config))().projects
if entry.name not in known
],
# D3, M4.1: the build VM checked, and the toolchain set up, before
# the driver plans.
preflight=True,
preflight_turns=config.tuning.preflight.max_turns,
preflight_timeout_s=config.tuning.preflight.command_timeout_s,
bug_grace_s=config.tuning.loop.bug_grace_s,
general_idle_s=config.tuning.loop.general_idle_s,
)
+4 -2
View File
@@ -82,6 +82,7 @@ class Driver:
model: str | None = None,
on_turn: Callable[[str], None] | None = None,
screen: Callable[[ToolCall], Screening] | None = None,
max_turns: int | None = None,
) -> RunOutcome:
"""``handlers`` answer control tools that don't end the run (e.g.
start_milestone): each returns a result and whether it succeeded.
@@ -95,7 +96,8 @@ class Driver:
failures: list[str] = [] # the failed commands in a row, latest last
offered = names(tools)
nudged = False
for turn in range(self._max_turns):
cap = max_turns or self._max_turns
for turn in range(cap):
# L2: stop takes effect at the next tool boundary.
if self._cancel.requested:
return RunOutcome("stopped", note=f"before turn {turn + 1}")
@@ -215,7 +217,7 @@ class Driver:
note=f"{len(failures)} commands failed in a row: "
+ "; ".join(failures),
)
return RunOutcome("capped", note=f"hit the {self._max_turns}-turn cap")
return RunOutcome("capped", note=f"hit the {cap}-turn cap")
def _brief(call: ToolCall, result: str) -> str:
+6
View File
@@ -77,6 +77,11 @@ class Loop:
# The trust dial (M3.3): why an approved PR was last left unmerged, so
# it's said once per commit and reason.
merge_held: str = ""
# The toolchain as the harness last checked it (M4.1): name, version
# command, version; and a build VM whose host key changed, waiting on
# the operator's word.
toolchain: tuple[tuple[str, str, str], ...] = ()
host_key_changed: bool = False
def to_data(self) -> dict[str, Any]:
return asdict(self)
@@ -88,6 +93,7 @@ def loop_of(item: WorkItem) -> Loop:
data["blocked_by"] = tuple(data.get("blocked_by", ()))
data["labels"] = tuple(data.get("labels", ()))
data["became"] = tuple(data.get("became", ()))
data["toolchain"] = tuple(tuple(t) for t in data.get("toolchain", ()))
return Loop(**data)
@@ -172,6 +172,14 @@ class Milestones:
# -- a new project's checks (D1, D4) -----------------------------------------
def reclone(self, account: str) -> None:
"""The workspace's clone again, after the build VM was rebuilt or
wiped (D3, M4.1): as at the start, with no credential in it."""
if not self._git.exists():
self._git.clone(self._remote, credential=self._credential)
self._git.configure_identity(account, f"{account}@users.noreply.local")
self._git.install_excludes()
def verify_access(self, account: str) -> list[str]:
"""What the operator provided, checked rather than assumed: the
agent may push a branch with its push credential, may not push to
+282
View File
@@ -0,0 +1,282 @@
"""Toolchain preflight (architecture D3; Development Plan M4.1).
A model works out what a project needs and sets it up on the build VM; code
makes sure that happens, bounds it and checks the result.
- **Ready or not** is the harness's: before a driver run it checks, in one
round trip, the baseline it relies on itself, the workspace clone, and a
marker per project (``~/.nisse/<project>.ready``) that a finished preflight
leaves. No marker means a new project, VM or wipe: preflight runs first.
- **The preflight run** is the item's driver model with its own prompt and
the execution tools (screened as ever), ending with ``toolchain_ready``.
- **Versions are the harness's facts:** it runs each tool's version command
itself (version-shaped commands only) and records what they print; it
re-checks them before later runs, and a change is a fact for the driver.
"""
import base64
import json
import shlex
from dataclasses import dataclass, field
from typing import Any
from nisse_core.actions import split_shell, unwrap
from nisse_dev.execution import SSHHost
# What the harness itself runs on a build VM (L13, D4): part of the
# template, never of a project's toolchain.
BASELINE = ("git", "base64", "realpath", "mkdir", "ssh-agent", "ssh-add", "timeout")
_VERSION_FLAGS = ("--version", "-V", "-v", "version", "-version")
@dataclass(frozen=True)
class Readiness:
missing: tuple[str, ...] # baseline commands the VM lacks
cloned: bool # the workspace clone is there
marker: dict[str, Any] | None # what the last preflight left, if any
@dataclass(frozen=True)
class Tool:
name: str
command: str # version-shaped: prints the tool's version
version: str = "" # what it printed, when the harness last ran it
def to_data(self) -> list[str]:
return [self.name, self.command, self.version]
@dataclass
class Report:
summary: str
tools: list[Tool] = field(default_factory=list)
missing: list[tuple[str, str]] = field(default_factory=list) # (name, why)
MARKERS = "$HOME/.nisse" # on the build VM
def marker_path(project: str, markers: str = MARKERS) -> str:
return f"{markers}/{project}.ready"
def check(
host: SSHHost, project: str, workspace: str, markers: str = MARKERS
) -> Readiness:
"""The baseline, the clone and the marker, in one round trip."""
probe = "; ".join(
[
*(
f"command -v {shlex.quote(c)} >/dev/null 2>&1 || echo missing:{c}"
for c in BASELINE
),
f"test -d {shlex.quote(workspace)}/.git && echo cloned",
(
f'[ -f "{marker_path(project, markers)}" ] && '
f'echo marker:$(base64 -w0 < "{marker_path(project, markers)}")'
),
"true",
]
)
result = host.execute(probe, timeout_s=30)
missing: list[str] = []
cloned = False
marker: dict[str, Any] | None = None
for line in result.stdout.splitlines():
if line.startswith("missing:"):
missing.append(line.removeprefix("missing:"))
elif line == "cloned":
cloned = True
elif line.startswith("marker:"):
try:
marker = json.loads(base64.b64decode(line.removeprefix("marker:")))
except ValueError:
marker = None # unreadable: as good as none
return Readiness(tuple(missing), cloned, marker)
def write_marker(
host: SSHHost, project: str, data: dict[str, Any], markers: str = MARKERS
) -> None:
text = base64.b64encode(json.dumps(data, sort_keys=True).encode()).decode()
host.execute(
f'mkdir -p "{markers}" && base64 -d > "{marker_path(project, markers)}"',
stdin=text,
timeout_s=30,
)
def version_shaped(command: str) -> bool:
"""One plain command that only prints a version: ``rustc --version``,
``go version``, ``python3 -V``. Nothing else is run by the harness."""
commands = split_shell(command)
if commands is None or len(commands) != 1:
return False
argv, written = commands[0]
if written or unwrap(list(argv)) != argv:
return False # no redirection, no sudo or env in front
return (
2 <= len(argv) <= 3
and argv[-1] in _VERSION_FLAGS
and not argv[0].startswith(("-", "/", "."))
and argv[0] not in ("sh", "bash", "zsh", "dash", "eval", "exec")
)
def versions(host: SSHHost, tools: list[Tool]) -> list[Tool]:
"""Each tool's version, as its command prints it now (first line, from
stdout or stderr): run by the harness, in one round trip."""
# The marker lives on the VM, where the model's commands could change
# it: only version-shaped commands are ever run, wherever they came from.
tools = [t for t in tools if version_shaped(t.command)]
if not tools:
return []
script = "; ".join(
f"echo @@{i}; if command -v {shlex.quote(t.command.split()[0])} "
f">/dev/null 2>&1; then {t.command} 2>&1 | head -n 1; fi"
for i, t in enumerate(tools)
)
result = host.execute(script, timeout_s=60)
found: dict[int, str] = {}
current = -1
for line in result.stdout.splitlines():
if line.startswith("@@") and line[2:].isdigit():
current = int(line[2:])
found[current] = ""
elif current >= 0 and not found[current]:
found[current] = line.strip()
return [
Tool(t.name, t.command, found.get(i, "") or "not found")
for i, t in enumerate(tools)
]
def drift(before: list[Tool], now: list[Tool]) -> list[str]:
"""What changed, as "name: old → new"."""
was = {t.name: t.version for t in before}
return [
f"{t.name}: {was[t.name]} → {t.version}"
for t in now
if t.name in was and was[t.name] != t.version
]
def tools_of(data: Any) -> list[Tool]:
"""Tools from stored data (a marker's or an item's)."""
out: list[Tool] = []
for entry in data or []:
if isinstance(entry, list | tuple) and len(entry) == 3:
out.append(Tool(str(entry[0]), str(entry[1]), str(entry[2])))
return out
def said(tools: list[Tool]) -> str:
return ", ".join(f"{t.name} {t.version}" for t in tools) or "nothing recorded"
TOOLCHAIN_READY = {
"type": "function",
"function": {
"name": "toolchain_ready",
"description": (
"The project's toolchain is set up on this build VM (or as far as "
"it can be): report it. Ends the preflight."
),
"parameters": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "What you found and did, in a sentence or two.",
},
"toolchain": {
"type": "array",
"description": (
"Each tool the project needs whose version matters, "
"with the command that prints its version (e.g. "
"'python3 --version', 'rustc -V', 'go version')."
),
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"version_command": {"type": "string"},
},
"required": ["name", "version_command"],
},
},
"installed": {
"type": "array",
"items": {"type": "string"},
"description": "What you installed, if anything.",
},
"missing": {
"type": "array",
"description": "What the project needs that you couldn't set up.",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"why": {"type": "string"},
},
"required": ["name", "why"],
},
},
},
"required": ["summary", "toolchain"],
},
},
}
SYSTEM = """You are nisse-dev, getting a project's toolchain ready on its build VM
before any work starts. The project is {repo}; its clone is the workspace.
{workspace}
Read the project's own files to work out what it needs to build, test and run:
its language runtimes and compilers, package and build tools, and anything else
it calls (system libraries, CLIs, a database for its tests). Honour its pins
(.python-version, rust-toolchain.toml, requires-python, engines, lockfiles).
{last}
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
change anything for other hosts, and never install, store or ask for
credentials: the harness hands those out when they're needed.
Leave out the basics every machine has (ls, cat, git, curl, ssh, tar): those
are the VM template's. Leave out tools that act on other systems (gh, tea,
docker push, cloud CLIs): they aren't how work is done here.
When you're done, or can't get further, call toolchain_ready with what you
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:
this is setup, not the work."""
def report_of(arguments: dict[str, Any]) -> Report:
tools = [
Tool(str(t.get("name", "")).strip(), str(t.get("version_command", "")).strip())
for t in arguments.get("toolchain") or []
if isinstance(t, dict)
]
missing = [
(str(m.get("name", "")).strip(), str(m.get("why", "")).strip())
for m in arguments.get("missing") or []
if isinstance(m, dict)
]
return Report(str(arguments.get("summary", "")).strip(), tools, missing)
def report_problem(arguments: dict[str, Any]) -> str | None:
"""A toolchain_ready the harness can't use: said back to the model."""
report = report_of(arguments)
bad = [t.command for t in report.tools if not version_shaped(t.command)]
if bad:
return (
"these aren't plain version commands, which is all I'll run: "
+ "; ".join(bad)
+ ". Use the tool's own --version (or -V, version)."
)
if any(not t.name for t in report.tools):
return "every toolchain entry needs a name"
return None
+1
View File
@@ -112,6 +112,7 @@ WAITING = frozenset(
"reply",
"propose_work",
"propose_project",
"toolchain_ready",
}
)