test(e2e/windows): real C:\Users profiles, serialized gateway phases, Git-for-Windows machines

This commit is contained in:
teknium1
2026-09-26 18:37:15 -07:00
committed by Teknium
parent 22fe26db2a
commit 16da7f1b38
8 changed files with 157 additions and 68 deletions

View File

@@ -50,9 +50,12 @@ jobs:
-m "platforms and integration" -v --tb=short -rA
env:
HERMES_E2E_WINDOWS_INSTALL: '1'
# Short machine roots: a real install lives at C:\Users\<name>\AppData\Local\hermes;
# pytest's tmp dir would add ~100 characters before node_modules.
# Machine work dirs (serve.git, transcripts) stay short under runner.temp. The fake
# user profiles live in C:\Users itself, where a real one does: the checkout carries
# 159-character paths, so a deeper profile hits MAX_PATH where no user would (the
# runner is disposable, so writing C:\Users is fine).
HERMES_E2E_MACHINE_ROOT: ${{ runner.temp }}\wm
HERMES_E2E_PROFILES_ROOT: C:\Users
HERMES_E2E_ARTIFACTS: ${{ runner.temp }}\win-update-e2e
# One journey per file, all in parallel (each is mostly network + subprocess wait).
HERMES_TEST_WORKERS: '6'
@@ -77,11 +80,14 @@ jobs:
shell: pwsh
run: |
$names = 'python.exe', 'pythonw.exe', 'hermes.exe', 'node.exe'
# Work dirs live under runner.temp\wm; profiles are C:\Users\*hermes-e2e-<id>.
$root = "${{ runner.temp }}\wm".ToLower()
$profileMark = 'hermes-e2e-'
$deadline = (Get-Date).AddSeconds(20)
do {
$left = @(Get-CimInstance Win32_Process | Where-Object {
($names -contains $_.Name.ToLower()) -and ("$($_.CommandLine) $($_.ExecutablePath)".ToLower().Contains($root))
$blob = "$($_.CommandLine) $($_.ExecutablePath)".ToLower()
($names -contains $_.Name.ToLower()) -and ($blob.Contains($root) -or $blob.Contains($profileMark))
})
if ($left.Count -eq 0) { 'no leftover processes from the E2E machines'; exit 0 }
Start-Sleep -Milliseconds 500

View File

@@ -145,8 +145,9 @@ _pf86="$(env | sed -n 's/^ProgramFiles(x86)=//p' | head -n1)"
# * CI / GITHUB_ACTIONS tell suites they run on a disposable runner (e.g.
# tests/e2e/core/upgrade runs the real updater unsandboxed only there).
# * HERMES_E2E_WINDOWS_INSTALL opts tests/e2e/core/windows_update into running
# the real install.ps1 (it writes HKCU PATH); HERMES_E2E_MACHINE_ROOT and
# HERMES_E2E_ARTIFACTS place its fake machines and the transcripts CI uploads.
# the real install.ps1 (it writes HKCU PATH); HERMES_E2E_MACHINE_ROOT,
# HERMES_E2E_PROFILES_ROOT and HERMES_E2E_ARTIFACTS place its fake machines,
# their user profiles and the transcripts CI uploads.
#
# These are test-infrastructure knobs, not credentials — same class as the
# HERMES_RUN_SLOW_PET_TESTS / HERMES_E2E_BROWSER / HERMES_RUN_E2E opt-ins
@@ -160,7 +161,7 @@ TEST_ENV=()
for _test_var in HERMES_TEST_IMAGE HERMES_TEST_WORKERS HERMES_TEST_PATHS \
HERMES_TEST_FILE_TIMEOUT HERMES_TEST_FILE_RETRIES HERMES_TEST_SLICE \
SSL_CERT_FILE SSL_CERT_DIR HERMES_GATEWAY_LOCK_DIR HERMES_E2E_REQUIRE_TUI CI GITHUB_ACTIONS \
HERMES_E2E_WINDOWS_INSTALL HERMES_E2E_MACHINE_ROOT HERMES_E2E_ARTIFACTS; do
HERMES_E2E_WINDOWS_INSTALL HERMES_E2E_MACHINE_ROOT HERMES_E2E_PROFILES_ROOT HERMES_E2E_ARTIFACTS; do
if [ -n "${!_test_var:-}" ]; then
TEST_ENV+=("$_test_var=${!_test_var}")
fi

View File

@@ -20,11 +20,17 @@ Only external edges are replaced (tests/install/README.md, "The isolation trick"
Tool and dependency downloads (uv, the managed Python, wheels, Node) use the network,
exactly like the real installer.
Each machine is a fake user profile under pytest's tmp dir: ``USERPROFILE``/``HOME``/
``LOCALAPPDATA``/``APPDATA`` point inside it and ``HERMES_HOME`` is NOT set, so the
installer and every ``hermes`` command resolve the default ``%LOCALAPPDATA%\\hermes``
the way a real user's do. The installer also prepends its bin dir to the user PATH in
HKCU; the machine restores that value on teardown.
Each machine is a fake user profile: ``USERPROFILE``/``HOME``/``LOCALAPPDATA``/``APPDATA``
point inside it and ``HERMES_HOME`` is NOT set, so the installer and every ``hermes``
command resolve the default ``%LOCALAPPDATA%\\hermes`` the way a real user's do. CI
puts the profiles in ``C:\\Users`` itself (``HERMES_E2E_PROFILES_ROOT``): the checkout
carries 159-character paths, so a profile any deeper than a real one would hit MAX_PATH
where no user does. The installer also prepends its bin dir to the user PATH in HKCU;
the machine restores that value on teardown.
Machines run in parallel, but ``hermes update`` on Windows pauses every gateway on the
host, including other installs' (#124659). Updates and gateway lifetimes therefore take
a job-wide lock (``gateway_phase``); installs, turns and everything else stay parallel.
The suite mutates HKCU and downloads a toolchain per machine, so it only runs where
``HERMES_E2E_WINDOWS_INSTALL=1`` (the CI job sets it).
@@ -32,11 +38,12 @@ The suite mutates HKCU and downloads a toolchain per machine, so it only runs wh
from __future__ import annotations
import contextlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import uuid
from dataclasses import dataclass, field
@@ -135,6 +142,10 @@ class Machine:
root: Path
profile_name: str
base_url: str
profiles_root: Path | None = None
# Git for Windows on PATH (a typical developer box). False: a clean machine, where the
# installer must stage its own pinned Git.
system_git: bool = False
path_prepend: list[str] = field(default_factory=list)
head: str = ""
next: str = ""
@@ -142,12 +153,14 @@ class Machine:
_hkcu: Any = None
_seq: int = 0
_spawned: list[subprocess.Popen] = field(default_factory=list)
_lock_depth: int = 0
timings: list[tuple[str, float]] = field(default_factory=list)
# -- layout ---------------------------------------------------------------
@property
def profile(self) -> Path:
return self.root / "Users" / self.profile_name
return (self.profiles_root or self.root / "Users") / self.profile_name
@property
def local(self) -> Path:
@@ -183,7 +196,8 @@ class Machine:
roaming = self.profile / "AppData" / "Roaming"
self.local.mkdir(parents=True, exist_ok=True)
roaming.mkdir(parents=True, exist_ok=True)
path = _strip_path(os.environ.get("PATH", ""), ("git.exe", "python.exe", "python3.exe", "uv.exe"))
drop = ("python.exe", "python3.exe", "uv.exe") + (() if self.system_git else ("git.exe",))
path = _strip_path(os.environ.get("PATH", ""), drop)
env.update({
"PATH": os.pathsep.join([*self.path_prepend, path]),
"USERPROFILE": str(self.profile),
@@ -253,11 +267,13 @@ class Machine:
"""Run to completion with the full transcript on disk (survives a timeout)."""
self._seq += 1
log = self.logs / f"{self._seq:02d}-{label}.log"
started = time.monotonic()
with log.open("wb") as fh:
proc = subprocess.Popen(argv, cwd=cwd or self.profile, env=self.env(env_extra),
stdin=subprocess.DEVNULL, stdout=fh, stderr=subprocess.STDOUT)
try:
code = proc.wait(timeout=timeout)
self.timings.append((label, round(time.monotonic() - started, 1)))
except subprocess.TimeoutExpired:
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True, timeout=60)
proc.wait(timeout=60)
@@ -290,7 +306,46 @@ class Machine:
return self._run_logged([str(self.hermes_exe), *args], name, timeout=timeout, env_extra=env_extra)
def update(self, *extra: str, label: str = "update") -> Run:
return self.hermes("update", "--yes", *extra, label=label, timeout=UPDATE_TIMEOUT)
with self.gateway_phase():
return self.hermes("update", "--yes", *extra, label=label, timeout=UPDATE_TIMEOUT)
@contextlib.contextmanager
def gateway_phase(self):
"""Job-wide mutex for updates and gateway lifetimes (reentrant within a machine).
``hermes update`` on Windows discovers gateways host-wide and stops ones it cannot map
to its own profiles (#124659), so one machine's update would kill another machine's
gateway. Real users rarely run two installs side by side; this suite always does."""
if self._lock_depth:
self._lock_depth += 1
try:
yield
finally:
self._lock_depth -= 1
return
import msvcrt
lock_dir = Path(os.environ.get("HERMES_E2E_MACHINE_ROOT") or tempfile.gettempdir())
lock_dir.mkdir(parents=True, exist_ok=True)
waited = time.monotonic()
with (lock_dir / "gateway-phase.lock").open("a+b") as fh:
while True:
try:
fh.seek(0)
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
break
except OSError:
if time.monotonic() - waited > 1800:
raise RuntimeError("harness: gateway-phase lock not acquired within 30 min") from None
time.sleep(0.5)
self.timings.append(("(waited for gateway phase)", round(time.monotonic() - waited, 1)))
self._lock_depth = 1
try:
yield
finally:
self._lock_depth = 0
fh.seek(0)
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
def installed_head(self) -> str:
try:
@@ -348,7 +403,7 @@ class Machine:
"""Live processes of this machine: exe/cwd/argv under its root, or its HERMES_HOME."""
import psutil
root = os.path.normcase(os.path.normpath(str(self.root)))
roots = {os.path.normcase(os.path.normpath(str(p))) for p in (self.root, self.profile)}
home = os.path.normcase(os.path.normpath(str(self.hermes_home)))
me = os.getpid()
owned = []
@@ -361,14 +416,14 @@ class Machine:
blob = os.path.normcase(" ".join([proc.exe() or "", proc.cwd() or "", *proc.cmdline()]))
except (psutil.Error, OSError):
continue
if hh == home or root in blob:
if hh == home or any(root in blob for root in roots):
owned.append(proc)
return owned
def evidence(self) -> str:
"""Receipts, logs and the process table: what a failure message must carry."""
parts = [f"machine root: {self.root}", f"HEAD={self.head} NEXT={self.next}",
f"installed checkout: {self.installed_head()}"]
parts = [f"machine root: {self.root}", f"profile: {self.profile}", f"HEAD={self.head} NEXT={self.next}",
f"installed checkout: {self.installed_head()}", f"timings (s): {self.timings}"]
receipt = self.hermes_home / "logs" / "update_receipts" / "latest.json"
if receipt.is_file():
parts.append(self._tail(receipt, 4000))
@@ -385,11 +440,15 @@ class Machine:
parts.append(f"process table unavailable: {exc}")
return "\n".join(parts)
def teardown(self) -> None:
def kill_owned(self) -> None:
"""Hard-stop every live process of this machine (end of a gateway phase, teardown)."""
kill_tree(self.owned_processes())
for proc in self._spawned:
if proc.poll() is None:
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True, timeout=60)
def teardown(self) -> None:
self.kill_owned()
try:
_restore_hkcu_path(self._hkcu)
except OSError:
@@ -404,17 +463,20 @@ class Machine:
shutil.copytree(src, dest / sub, dirs_exist_ok=True,
ignore=shutil.ignore_patterns("*.db", "*.db-*"))
(dest / "evidence.txt").write_text(self.evidence(), encoding="utf-8", errors="replace")
(dest / "timings.json").write_text(json.dumps(self.timings, indent=1), encoding="utf-8")
def new_machine(tmp_root: Path, base_url: str, *, label: str, profile_name: str = "e2e",
path_prepend: list[str] | None = None) -> Machine:
"""A staged machine. ``HERMES_E2E_MACHINE_ROOT`` (CI: a short runner.temp dir) keeps the
install tree near the depth of a real ``C:\\Users\\<name>\\AppData\\Local\\hermes``;
pytest's own tmp dir is already ~100 characters deep on Windows, and node_modules does
the rest of the way to MAX_PATH."""
def new_machine(tmp_root: Path, base_url: str, *, label: str, person: str = "",
system_git: bool = False) -> Machine:
"""A staged machine. Its work dir (serve.git, transcripts) lives under
``HERMES_E2E_MACHINE_ROOT`` (else ``tmp_root``); its user profile under
``HERMES_E2E_PROFILES_ROOT`` (CI: ``C:\\Users``), named ``[<person> ]hermes-e2e-<id>``."""
sfx = uuid.uuid4().hex[:4]
base = Path(os.environ.get("HERMES_E2E_MACHINE_ROOT") or tmp_root)
machine = Machine(root=base / f"{label}-{uuid.uuid4().hex[:4]}", profile_name=profile_name,
base_url=base_url, path_prepend=list(path_prepend or []))
profiles = os.environ.get("HERMES_E2E_PROFILES_ROOT")
name = f"{person} hermes-e2e-{sfx}" if person else f"hermes-e2e-{sfx}"
machine = Machine(root=base / f"{label}-{sfx}", profile_name=name, base_url=base_url,
profiles_root=Path(profiles) if profiles else None, system_git=system_git)
machine.stage()
return machine
@@ -454,6 +516,14 @@ class Journey:
raise RuntimeError(fail_with(self.machine, f"{name}: {message}", run))
def failure_line(run: Run) -> str:
"""The first line a ``hermes`` command printed as its failure (``✗ ...``), else ``""``."""
for line in run.stdout.splitlines():
if line.strip().startswith("✗"):
return line.strip()
return ""
def fail_with(machine: Machine, message: str, run: Run | None = None) -> str:
"""Assertion text: the claim first (gates match on it), then the transcript and evidence."""
tail = f"\n--- transcript rc={run.returncode} ---\n{run.stdout[-6000:]}" if run is not None else ""

View File

@@ -19,6 +19,7 @@ from tests.e2e.core.windows_update._machine import (
REQUIRES_OPT_IN,
Journey,
fail_with,
failure_line,
new_machine,
one_shot_turn,
source_completion_detour,
@@ -29,10 +30,10 @@ pytestmark = [pytest.mark.platforms("windows"), pytest.mark.integration,
pytest.mark.live_system_guard_bypass, REQUIRES_OPT_IN]
KNOWN = {
"first_launch": (
r"^first agent launch after a pristine install detoured through source-update completion",
"gated on #123314: a fresh Windows source install enters source-update completion on the "
"first agent launch",
"update": (
r"^hermes update exited 1: ✗ Update failed: \[WinError 2\]",
"gated on #124634: on a machine without system Git, the pinned Git that install.ps1 "
"staged never reaches `hermes update`'s PATH",
),
}
@@ -71,10 +72,9 @@ def test_install_lands_on_head_and_publishes_hermes(journey: Journey) -> None:
def test_first_agent_launch_runs_the_turn(journey: Journey) -> None:
m, turn = journey.machine, journey["first_turn"]
detour = source_completion_detour(turn.run)
with known_gate(KNOWN, "first_launch"):
assert detour is None, fail_with(
assert detour is None, fail_with(
m, f"first agent launch after a pristine install detoured through source-update completion "
f"(printed {detour!r})", turn.run)
f"(printed {detour!r})", turn.run)
assert turn.ok, fail_with(
m, f"first agent launch did not complete a turn (reply printed={turn.reply_id in turn.run.stdout}, "
f"prompt reached provider={turn.reached_wire})", turn.run)
@@ -82,7 +82,8 @@ def test_first_agent_launch_runs_the_turn(journey: Journey) -> None:
def test_update_moves_checkout_to_next(journey: Journey) -> None:
m, run = journey.machine, journey["update"]
assert run.returncode == 0, fail_with(m, f"hermes update exited {run.returncode}", run)
with known_gate(KNOWN, "update"):
assert run.returncode == 0, fail_with(m, f"hermes update exited {run.returncode}: {failure_line(run)}", run)
head = m.installed_head()
assert head == m.next, fail_with(m, f"after hermes update the checkout is at {head}, expected NEXT {m.next}", run)
assert (m.install_dir / NEXT_MARKER).is_file(), fail_with(m, "NEXT's marker file is missing from the checkout", run)

View File

@@ -53,10 +53,18 @@ def _alive(pid: int) -> bool:
return False
def _exits(pid: int, timeout: float) -> bool:
try:
wait_until(lambda: not _alive(pid), timeout, f"gateway pid {pid} to exit", interval=0.5)
except AssertionError:
return False
return True
@pytest.fixture(scope="module")
def journey(tmp_path_factory):
with FakeLLMServer() as srv:
machine = new_machine(tmp_path_factory.mktemp("gw"), srv.base_url, label="gw")
machine = new_machine(tmp_path_factory.mktemp("gw"), srv.base_url, label="gw", system_git=True)
j = Journey(machine)
try:
install = j.step("install", machine.install)
@@ -64,18 +72,22 @@ def journey(tmp_path_factory):
j.step("install_ok", lambda: j.require("install", install.returncode == 0,
f"install.ps1 exited {install.returncode}", install))
if j.ok("install_ok"):
j.step("spawn", machine.spawn_gateway)
before = j.step("state_before", machine.wait_gateway_running)
j.step("status_before", lambda: machine.hermes("gateway", "status", label="status-before"))
machine.advance()
j.step("update", machine.update)
old_pid = int(before.get("pid") or 0) if isinstance(before, dict) else None
j.step("state_after", lambda: machine.wait_gateway_running(not_pid=old_pid))
j.step("status_after", lambda: machine.hermes("gateway", "status", label="status-after"))
j.step("pidfile_after", lambda: (machine.hermes_home / "gateway.pid").exists())
j.step("update_again", lambda: machine.update(label="update-again"))
j.step("state_before_stop", machine.gateway_state)
j.step("stop", lambda: machine.hermes("gateway", "stop", label="stop"))
with machine.gateway_phase():
j.step("spawn", machine.spawn_gateway)
before = j.step("state_before", machine.wait_gateway_running)
j.step("status_before", lambda: machine.hermes("gateway", "status", label="status-before"))
machine.advance()
j.step("update", machine.update)
old_pid = int(before.get("pid") or 0) if isinstance(before, dict) else None
j.step("state_after", lambda: machine.wait_gateway_running(not_pid=old_pid))
j.step("status_after", lambda: machine.hermes("gateway", "status", label="status-after"))
j.step("pidfile_after", lambda: (machine.hermes_home / "gateway.pid").exists())
j.step("update_again", lambda: machine.update(label="update-again"))
last = j.step("state_before_stop", machine.gateway_state)
j.step("stop", lambda: machine.hermes("gateway", "stop", label="stop"))
last_pid = int(last.get("pid") or 0) if isinstance(last, dict) else 0
j.step("stopped", lambda: _exits(last_pid, 90))
machine.kill_owned() # nothing of this machine outlives its gateway phase
yield j
finally:
machine.teardown()
@@ -100,8 +112,7 @@ def test_update_with_running_gateway_succeeds(journey: Journey) -> None:
def test_gateway_serves_next_after_update(journey: Journey) -> None:
m, state = journey.machine, journey["state_after"]
pid, sha = int(state.get("pid") or 0), str(state.get("code_sha") or "")
assert _alive(pid), fail_with(m, f"the relaunched gateway pid {pid} is not alive")
sha = str(state.get("code_sha") or "") # state_after only resolves for a live, running pid
assert len(sha) >= 7 and m.next.startswith(sha), fail_with(
m, f"the relaunched gateway serves code_sha={sha!r}, expected NEXT {m.next}")
@@ -130,12 +141,7 @@ def test_gateway_stop_after_update(journey: Journey) -> None:
m, stop, state = journey.machine, journey["stop"], journey["state_before_stop"]
pid = int(state.get("pid") or 0)
assert pid, fail_with(m, f"no gateway recorded before stop: {state}")
try:
wait_until(lambda: not _alive(pid), 90, f"gateway pid {pid} to exit after stop", interval=0.5)
stopped = True
except AssertionError:
stopped = False
with known_gate(KNOWN, "stop"):
assert stopped, fail_with(
assert journey["stopped"], fail_with(
m, f"after update `hermes gateway stop` left the serving gateway (pid {pid}) running", stop)
assert stop.returncode == 0, fail_with(m, f"hermes gateway stop exited {stop.returncode}", stop)

View File

@@ -26,6 +26,7 @@ from tests.e2e.core.windows_update._machine import (
Journey,
Machine,
fail_with,
failure_line,
new_machine,
)
from tests.fakes.fake_llm_provider import FakeLLMServer
@@ -34,8 +35,9 @@ pytestmark = [pytest.mark.platforms("windows"), pytest.mark.integration,
pytest.mark.live_system_guard_bypass, REQUIRES_OPT_IN]
KNOWN = {
"partial_fetch": (r"^hermes update over the installer's partial clone failed: .*BUG: builtin/pack-objects",
"gated on #124323: the pinned Git's pack-objects BUG on the --filter=tree:0 clone"),
"partial_fetch": (r"^hermes update over the installer's partial clone failed: rc=1, .*"
r"✗ Update failed: \[WinError 2\]",
"gated on #124634: the pinned Git install.ps1 staged never reaches `hermes update`'s PATH"),
"holders": (r"^`hermes update --list-venv-holders` did not report the live venv holders .*"
r"\(rc=0, reported pids \[\]\)",
"gated on #123050: --list-venv-holders reads the retired hermes_cli.main stub and always "
@@ -108,7 +110,8 @@ def test_update_fetches_into_the_installers_partial_clone(journey: Journey) -> N
m, run, flt = journey.machine, journey["update"], journey["partial_filter"]
assert flt, fail_with(m, "harness: the installer's clone is not partial (no remote.origin.partialclonefilter); "
"serve.git should allow filters")
fetch_bug = next((ln.strip() for ln in run.stdout.splitlines() if "BUG:" in ln or "fatal:" in ln), "")
fetch_bug = next((ln.strip() for ln in run.stdout.splitlines() if "BUG:" in ln or "fatal:" in ln),
failure_line(run))
with known_gate(KNOWN, "partial_fetch"):
assert run.returncode == 0 and m.installed_head() == m.next, fail_with(
m, f"hermes update over the installer's partial clone failed: rc={run.returncode}, checkout at "

View File

@@ -84,7 +84,7 @@ def _under(path: str, root: Path) -> bool:
def journey(tmp_path_factory):
system_python = _system_python_dir()
with FakeLLMServer() as srv:
machine = new_machine(tmp_path_factory.mktemp("interp"), srv.base_url, label="interp")
machine = new_machine(tmp_path_factory.mktemp("interp"), srv.base_url, label="interp", system_git=True)
j = Journey(machine)
try:
install = j.step("install", machine.install)
@@ -103,11 +103,13 @@ def journey(tmp_path_factory):
j.step("update_ok", lambda: j.require("update", update.returncode == 0,
f"hermes update exited {update.returncode}", update))
if j.ok("update_ok"):
j.step("spawn", machine.spawn_gateway)
state = j.step("state", machine.wait_gateway_running)
if j.ok("state"):
time.sleep(15) # supervised workers start ~2 s after boot; give them room to die
j.step("gateway_proc", lambda: _inspect(int(state["pid"])))
with machine.gateway_phase():
j.step("spawn", machine.spawn_gateway)
state = j.step("state", machine.wait_gateway_running)
if j.ok("state"):
time.sleep(15) # supervised workers start ~2 s after boot; give them room to die
j.step("gateway_proc", lambda: _inspect(int(state["pid"])))
machine.kill_owned() # nothing of this machine outlives its gateway phase
j.step("turn", lambda: one_shot_turn(machine, srv, "turn-stale-venv"))
yield j
finally:

View File

@@ -32,7 +32,7 @@ from tests.fakes.fake_llm_provider import FakeLLMServer
pytestmark = [pytest.mark.platforms("windows"), pytest.mark.integration,
pytest.mark.live_system_guard_bypass, REQUIRES_OPT_IN]
PROFILE_NAME = "Jörg Ñúñez e2e"
PERSON = "Jörg Ñúñez" # the profile is "Jörg Ñúñez hermes-e2e-<id>"
KNOWN = {
"install": (r"^install\.ps1 failed for a profile path with non-ASCII characters and spaces: .*"
r"is not recognized as the name of a cmdlet",
@@ -138,7 +138,7 @@ def _tool_runs(hermes_home: Path) -> list[tuple[Path, int | None, str]]:
def journey(tmp_path_factory):
with FakeLLMServer() as srv:
machine = new_machine(tmp_path_factory.mktemp("paths"), srv.base_url, label="paths",
profile_name=PROFILE_NAME)
person=PERSON, system_git=True)
j = Journey(machine)
try:
install = j.step("install", machine.install)