Files
hermes-agent/tests/hermes_cli/test_update_finish.py
Teknium c7db1c0819 fix(update): the gateway /update status follows a committed run's receipt
After the run's receipt closed as `success`, three boundaries could still
write 1 to .update_exit_code (and the completion child could still answer
130), so the gateway watcher reported a failed update the receipt calls a
success:

- cmd_update's catch-alls (main.py, unchanged) call
  _write_gateway_update_exit_code(False) for any escaping exception. The
  writer now keeps 0 when this command's run is closed as a success
  (update_receipt.committed_success: the receipt scope records the terminal
  outcome it finalized, or adopted from the completion child).
- The parent adopts the child's terminal receipt and drops its stale
  pre-child context as soon as the result is accepted, before the parent-
  side Windows resume, so nothing after it can write that context.
- update_completion._finish: an interrupt that lands after verification
  finalized the run answers 0 (the receipt's verdict); the 1 for a parked
  stash (partial) or an interrupt before the run closed is unchanged, and
  the status is written once the code is settled.
- update_finish (historical takeover): the same rule for its finally.

An interrupted run (receipt `interrupted`, exit 130) still writes 1, as
test_interrupt_after_child_success_demotes_gateway_marker_at_boundary pins.

Review: kshitijk4poor 5411222454 regression 3 / open thread F4.
2026-10-06 03:44:49 -07:00

529 lines
31 KiB
Python

"""Historical takeover completion in a fresh, dependency-selected process.
PM preparation has its own worker tests. Here the tool provisioner supplies
the real test Node/npm and icons are pre-prepared. npm ci, both compilers,
application imports, maintenance/config migration and receipts are real.
Only fleet discovery/restarts and machine-wide post-update repairs are seams.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import textwrap
import pytest
ROOT = Path(__file__).resolve().parents[2]
def _put(root, name, content):
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
@pytest.fixture
def completion(tmp_path, monkeypatch):
from hermes_cli.config_defaults import DEFAULT_CONFIG
from pm.environments import install_state_dir, runtime_facts_path, site_packages
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("HERMES_HOME", str(home / ".hermes"))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store"))
source = tmp_path / "selected source"
source.mkdir()
# Copies give bootstrap/main a genuine selected checkout identity. No file
# in the working checkout is modified, even by import-time self-heals.
for path in ROOT.glob("*.py"):
shutil.copy2(path, source / path.name)
for name in ("hermes_cli", "hermes_platform", "pm", "agent", "gateway", "tools", "cron"):
shutil.copytree(ROOT / name, source / name,
ignore=shutil.ignore_patterns("__pycache__", "web_dist", "tui_dist"))
shutil.copytree(ROOT / "scripts/build", source / "scripts/build",
ignore=shutil.ignore_patterns("__pycache__"))
_put(source, "pyproject.toml", '[project]\nname="takeover-fixture"\nversion="2.0"\n')
# Successful completion stamps the selected checkout's own git identity.
git_env = {**os.environ, "GIT_AUTHOR_NAME": "fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid",
"GIT_COMMITTER_NAME": "fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid"}
for command in (["init", "-q"], ["add", "--all"], ["-c", "commit.gpgsign=false", "commit", "-qm", "selected"]):
subprocess.run(["git", *command], cwd=source, env=git_env, check=True, capture_output=True)
# The selected interpreter is dependency-free, not the pytest environment. A symlink, not a
# copy: a relocatable build (python-build-standalone) locates its stdlib beside the resolved
# executable, so a lone copied binary cannot even import ``encodings``.
python = tmp_path / "store/python/bin/python3"
python.parent.mkdir(parents=True)
python.symlink_to(Path(sys._base_executable).resolve())
_put(tmp_path / "store", "facts.json", json.dumps({"packages": {"python": {"entry": "python"}}}))
# A real venv: isolated build children (icon generation, npm lifecycle scripts) run on the
# selected generation's own interpreter, not on the store Python that hosts the completion.
generation = install_state_dir(source) / "environments/prepared"
subprocess.run([str(python), "-m", "venv", "--without-pip", str(generation)],
check=True, capture_output=True)
site = site_packages(generation)
assert site.is_dir()
# Add the already prepared *real* test dependencies through a selected
# generation .pth, as editable PM generations do. -I ignores PYTHONPATH.
dependency_sites = [p for p in sys.path if Path(p).name in ("site-packages", "dist-packages")]
assert dependency_sites
_put(site, "dependencies.pth", "\n".join(dependency_sites) + "\n")
_put(site, "selected_dependency.py", "VALUE = 'selected generation'\n")
_put(runtime_facts_path(source).parent, "facts.json", json.dumps({
"packages": {"venv": {"environment": str(generation)}}}))
_put(home / ".hermes", "config.yaml",
f"_config_version: {DEFAULT_CONFIG['_config_version'] - 1}\nmodel:\n default: retained-model\n")
env = {key: value for key, value in os.environ.items()
if not key.startswith(("HERMES_", "PYTHON", "PYTEST_", "VIRTUAL_ENV", "npm_", "NPM_"))}
env.update(HOME=str(home), HERMES_HOME=str(home / ".hermes"),
HERMES_RUNTIME_DIR=str(tmp_path / "store"),
PYTHONPATH="/obsolete/pre-pm/site-packages", PYTHONHOME="/obsolete/python",
NPM_CONFIG_OFFLINE="true", NPM_CONFIG_CACHE=str(tmp_path / "npm-cache"))
probe = subprocess.run([str(python), "-I", "-c",
"import importlib.util; assert importlib.util.find_spec('yaml') is None"],
env=env, capture_output=True, text=True)
assert probe.returncode == 0, probe.stderr
request = {
"root": str(source), "desktop": False, "assume_yes": True, "gateway_mode": False,
"pre_update_snapshot_id": "original-snapshot", "pre_update_version": "1.0",
"update_id": "historical-parent-correlation",
"receipt": {"update_id": "historical-parent-correlation", "started_at": "before-the-swap",
"pre_update": {"sha": "old-sha"},
"steps": [{"name": "pull", "ok": True, "detail": "original", "at": "before"}],
"plan": {"expected_sha": "old-sha"}},
"pm_receipt": {"update_id": "historical-parent-correlation", "outcome": "success",
"exit_code": 0, "venv_rebuild": {"environment": str(generation)}},
"plan": {"install_method": "git", "expected_sha": "old-sha", "expected_version": "1.0",
"profiles": ["default"], "runtimes": [{
"kind": "serve", "profile": "default", "pid": 99999999,
"supervisor": "manual-serve", "code_sha": "old-sha", "code_version": "1.0",
"restart_via": "respawn-argv", "detail": {"argv": ["original", "serve"]}}]},
"windows_resume": {"resume_needed": False, "profiles": {"default": {"argv": ["original"]}},
"unmapped": [], "services": [], "service_profiles": {}},
}
context = _put(tmp_path, "context.json", json.dumps(request))
result = tmp_path / "result.json"
runner = _put(tmp_path, "run-completion.py", textwrap.dedent('''
import importlib.abc
import importlib.machinery
import json
from pathlib import Path
import runpy
import sys
context, result, fault = sys.argv[1:]
request = json.loads(Path(context).read_text(encoding="utf-8-sig"))
root = Path(request['root'])
sys.path.insert(0, str(root))
class CompletionImports(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname == 'pre_pm_only':
raise AssertionError('historical process module leaked into completion')
if fullname == 'pm.recovery':
raise RuntimeError('PM recovery is unavailable in this completion fixture')
if fullname == 'pm.receipt' and fault == 'receipt':
spec = importlib.machinery.PathFinder.find_spec(fullname, path)
original = spec.loader.exec_module
def execute(module):
original(module)
def broken(*args, **kwargs):
raise RuntimeError('cannot accept prepared receipt')
module.accept_worker_receipt = broken
spec.loader.exec_module = execute
return spec
if fullname != 'hermes_cli.main':
return None
import selected_dependency
assert selected_dependency.VALUE == 'selected generation'
if fault == 'import':
raise RuntimeError('application import failed before build')
spec = importlib.machinery.PathFinder.find_spec(fullname, path)
original = spec.loader.exec_module
def execute(module):
original(module)
# Install only service/machine boundaries after REAL CLI
# startup; all dependencies must be available by now.
from hermes_cli import update_cmd as update
from hermes_cli import update_cmd_maint as maint
from hermes_cli import update_cmd_fleet as fleet
from hermes_cli import update_cmd_fleet_verify as fleet_verify
from hermes_cli import gateway_migrate
from hermes_cli import macos_tcc_anchor
from hermes_cli import source_build
from hermes_cli.update_inventory import UpdatePlan, RuntimeRecord
from pm.package import Runner
import pm
import os
import shutil
from pm.environments import project_python
def provision(name, *, base_env, explicit):
assert name == 'npm' and explicit
assert shutil.which('node', path=base_env['PATH'])
assert shutil.which('npm', path=base_env['PATH'])
assert base_env['HERMES_PYTHON'] == str(project_python(root))
assert base_env['PYTHON'] == base_env['HERMES_PYTHON']
(root / 'build-environment.json').write_text(json.dumps({
'python': base_env['HERMES_PYTHON'], 'selected': selected_dependency.__file__,
'argv': sys.argv, 'old_module': 'pre_pm_only' in sys.modules,
'pid': os.getpid(), 'parent': os.getppid(),
}))
return Runner(name, base_env)
pm.ensure = provision
web = source_build.build_source_web
source_build.build_source_web = lambda root, **kwargs: web(root, icons=root, **kwargs)
build = source_build.build_update_products
def build_products(root, *, desktop):
(root / 'desktop-build.json').write_text(json.dumps(desktop), encoding='utf-8')
return build(root, desktop=desktop)
source_build.build_update_products = build_products
maintenance = update._run_post_update_maintenance
def maintain(**kwargs):
(root / 'desktop-maintenance.json').write_text(
json.dumps(kwargs['had_desktop_app_before_update']), encoding='utf-8')
return maintenance(**kwargs)
update._run_post_update_maintenance = maintain
# Ancillary system changes are not the acceptance target.
macos_tcc_anchor.ensure_tcc_anchor = lambda: None
maint._print_post_update_notices_and_self_heals = lambda: None
maint._sync_profiles_after_update = lambda: None
maint._print_bundled_skills_sync_report = lambda: None
fleet_verify._print_legacy_units_warning = lambda: None
maint._refresh_dashboard_after_update = lambda **kwargs: None
gateway_migrate.maybe_auto_migrate_after_update = lambda: None
update._surviving_pre_update_serve_runtimes = lambda plan: []
fleet_verify._collect_fleet_snapshot = lambda *args: []
def restart(plan, gateway_mode):
if fault == 'restart':
raise RuntimeError('systemctl restart timed out')
assert isinstance(plan, UpdatePlan)
assert isinstance(plan.runtimes[0], RuntimeRecord)
assert plan.to_dict() == request['plan'] | {
'updatable_in_place': True, 'update_mechanism': 'hermes update'}
(root / 'restarted-plan.json').write_text(json.dumps(plan.to_dict()))
return fleet._GatewayRestartOutcome(
incomplete=False, phase_errors=[], pre_restart_gateway_pids=[],
restarted_services=[], failed_or_stale_units=[], relaunched_profiles=[],
externally_supervised_profiles=[], killed_pids=set())
update._restart_gateway_fleet_after_update = restart
if fault == 'interrupt':
verify = update._verify_fleet_after_update
def verify_then_interrupt(*args, **kwargs):
verify(*args, **kwargs) # closes the run as a success
raise KeyboardInterrupt()
update._verify_fleet_after_update = verify_then_interrupt
merge = update._resume_windows_gateways_and_merge_outcome
def resume(outcome, token, gateway_mode):
assert token == request['windows_resume']
(root / 'resumed-token.json').write_text(json.dumps(token))
return merge(outcome, token, gateway_mode)
update._resume_windows_gateways_and_merge_outcome = resume
if fault == 'scm':
# The Windows service manager refuses the paused gateway's restart.
from hermes_cli import update_cmd_windows
def scm(token):
if token and token.get('resume_needed'):
raise RuntimeError('Could not restart Windows gateway service(s): HermesGatewayProbe')
module._resume_windows_gateways_after_update = scm
update_cmd_windows._resume_windows_gateways_after_update = scm
spec.loader.exec_module = execute
return spec
sys.meta_path.insert(0, CompletionImports())
sys.argv = [str(root / 'hermes_cli/update_finish.py'), context, result]
runpy.run_path(sys.argv[0], run_name='__main__')
'''))
def run(fault=""):
return subprocess.run([str(python), "-I", "-B", str(runner), str(context), str(result), fault],
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=90)
return source, home / ".hermes", request, context, result, run
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("fault", ["import", "activation", "plan", "receipt"])
def test_failure_preserves_original_receipt_before_build(completion, fault):
source, home, request, context, result, run = completion
request["gateway_mode"] = True
context.write_text(json.dumps(request), encoding="utf-8")
message = "application import failed before build"
if fault == "activation":
from pm.environments import selected_venv, site_packages
shutil.rmtree(site_packages(selected_venv(source)))
message = "dependency environment has no site-packages"
elif fault == "plan":
request["plan"]["runtimes"] = [{"profile": "missing required kind"}]
context.write_text(json.dumps(request), encoding="utf-8")
message = "kind"
elif fault == "receipt":
message = "cannot accept prepared receipt"
request["receipt"]["steps"][0]["detail"] = "日本 café"
context.write_text(json.dumps(request, ensure_ascii=False), encoding="utf-8-sig")
before = context.read_bytes()
child = run(fault)
assert child.returncode == 1, child.stdout + child.stderr
assert message in child.stderr
receipt = json.loads((home / "logs/update_receipts/latest.json").read_text())
assert receipt["update_id"] == request["update_id"]
assert receipt["started_at"] == request["receipt"]["started_at"]
assert receipt["pre_update"] == request["receipt"]["pre_update"]
assert receipt["steps"][0] == request["receipt"]["steps"][0]
if fault != "receipt":
assert receipt["pm_venv_rebuild"] == request["pm_receipt"]["venv_rebuild"]
assert receipt["outcome"] == "failed"
assert receipt["exit_code"] == child.returncode
assert json.loads(result.read_text())["receipt_handled"] is True
assert (home / ".update_exit_code").read_text().strip() == "1"
assert context.read_bytes() == before
assert not (source / "build-environment.json").exists()
@pytest.mark.platforms("posix")
def test_refused_gateway_resume_after_commit_is_a_followup_not_exit_1(completion):
"""C3 for the historical takeover child: the code is committed, so a Windows gateway resume
that the service manager refuses is an owed ``windows_resume`` follow-up, never exit 1."""
source, home, request, context, result, run = completion
request["gateway_mode"] = True
# A real start time names the run's archive file, so the running and terminal records share one
# file (the fixture's placeholder falls back to the clock and can split them across a second).
request["receipt"]["started_at"] = "2026-10-04T12:00:00+00:00"
request["windows_resume"] = {"resume_needed": True, "profiles": {}, "unmapped": [],
"services": ["HermesGatewayProbe"],
"service_profiles": {"HermesGatewayProbe": "default"}}
context.write_text(json.dumps(request), encoding="utf-8")
child = run("scm")
assert child.returncode == 0, child.stdout + child.stderr
receipt = json.loads((home / "logs/update_receipts/latest.json").read_text())
assert receipt["update_id"] == request["update_id"]
assert receipt["outcome"] == "success"
assert "windows_resume" in [row["step"] for row in receipt["followups"]], child.stdout + child.stderr
assert (home / ".update_exit_code").read_text().strip() == "0"
# The attempt is reported: the historical parent must not replay it at its own exit.
assert json.loads(result.read_text())["resume_handled"] is True
@pytest.mark.platforms("posix")
def test_raising_fleet_restart_after_commit_is_a_followup_not_exit_1(completion):
"""Review P3 (invariant 3): the takeover child's restart/verify run after the commit point, so
a raising fleet restart is an owed ``gateway_restart`` follow-up and exit 0, never exit 1 with a
``failed`` receipt and a gateway watcher told 1."""
source, home, request, context, result, run = completion
request["gateway_mode"] = True
request["receipt"]["started_at"] = "2026-10-04T12:00:00+00:00"
context.write_text(json.dumps(request), encoding="utf-8")
child = run("restart")
assert child.returncode == 0, child.stdout + child.stderr
receipt = json.loads((home / "logs/update_receipts/latest.json").read_text(encoding="utf-8-sig"))
assert receipt["update_id"] == request["update_id"]
assert receipt["outcome"] == "success"
owed = {row["step"]: row["reason"] for row in receipt["followups"]}
assert "systemctl restart timed out" in owed.get("gateway_restart", ""), child.stdout + child.stderr
assert (home / ".update_exit_code").read_text(encoding="utf-8-sig").strip() == "0"
@pytest.mark.platforms("posix")
def test_interrupt_after_verification_closed_the_run_keeps_gateway_status_0(completion):
"""Review regression 3: verification already finalized the takeover's run as ``success``; an
interrupt landing after it must not rewrite the gateway /update status to 1."""
source, home, request, context, result, run = completion
request["gateway_mode"] = True
request["receipt"]["started_at"] = "2026-10-04T12:00:00+00:00"
context.write_text(json.dumps(request), encoding="utf-8")
child = run("interrupt")
receipt = json.loads((home / "logs/update_receipts/latest.json").read_text(encoding="utf-8-sig"))
assert receipt["update_id"] == request["update_id"]
assert receipt["outcome"] == "success", child.stdout + child.stderr
assert (home / ".update_exit_code").read_text(encoding="utf-8-sig").strip() == "0", child.stdout + child.stderr
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("captured, installed, expected", [
(None, None, False),
(None, "renderer", True),
(None, "packaged", True),
(False, "packaged", False),
(True, None, True),
])
def test_missing_desktop_observation_uses_installed_products(completion, captured, installed, expected):
source, home, request, context, result, run = completion
request["desktop"] = captured
context.write_text(json.dumps(request), encoding="utf-8")
if installed == "renderer":
_put(source, "apps/desktop/dist/index.html", "old renderer")
elif installed == "packaged":
executable = ("mac-arm64/Hermes.app/Contents/MacOS/Hermes" if sys.platform == "darwin"
else "linux-unpacked/hermes")
_put(source, f"apps/desktop/release/{executable}", "old packaged app")
before = context.read_bytes()
child = run()
assert child.returncode == 0, child.stdout + child.stderr
assert json.loads((source / "desktop-build.json").read_text()) is expected
assert json.loads((source / "desktop-maintenance.json").read_text()) is expected
assert context.read_bytes() == before
assert json.loads(result.read_text())["receipt_handled"] is True
def _npm_graph(source):
# Use the same prepared compilers as the JS builder acceptance suite,
# while npm itself installs a tiny, entirely local workspace graph.
tools = {}
node = shutil.which("node")
assert node, "source completion acceptance requires Node and npm"
for name in ("esbuild", "typescript", "vite"):
probe = subprocess.run([node, "-p", f"require.resolve('{name}/package.json')"],
cwd=ROOT, capture_output=True, text=True)
if probe.returncode:
# The Python lane runs without `npm ci`. The real completion path runs in the
# install E2E workflow (source updates rebuild the products there).
pytest.skip(f"source completion acceptance requires {name} from the checkout's node_modules")
tools[name] = str(Path(probe.stdout.strip()).parent)
root = {"name": "completion-graph", "version": "1.0.0", "private": True, "type": "module",
"workspaces": ["ui-tui", "web", "packages/value"],
"scripts": {"postinstall": "node prepare-tools.mjs"}}
packages = {"": root}
for directory, name in (("ui-tui", "fixture-tui"), ("web", "fixture-web"),
("packages/value", "fixture-value")):
manifest = {"name": name, "version": "1.0.0", "type": "module"}
if name == "fixture-value":
manifest["exports"] = "./index.js"
else:
manifest["dependencies"] = {"fixture-value": "*"}
_put(source, f"{directory}/package.json", json.dumps(manifest))
packages[directory] = manifest
packages[f"node_modules/{name}"] = {"resolved": directory, "link": True}
_put(source, "package.json", json.dumps(root))
_put(source, "package-lock.json", json.dumps({"name": root["name"], "version": "1.0.0",
"lockfileVersion": 3, "packages": packages}))
_put(source, "prepare-tools.mjs", textwrap.dedent(f'''
import {{ mkdirSync, symlinkSync, writeFileSync }} from 'node:fs';
import {{ execFileSync }} from 'node:child_process';
const tools = {json.dumps(tools)};
for (const [name, target] of Object.entries(tools)) {{
const workspace = name === 'esbuild' ? 'ui-tui' : 'web';
mkdirSync(workspace + '/node_modules', {{recursive: true}});
symlinkSync(target, workspace + '/node_modules/' + name, 'dir');
}}
// A real npm lifecycle child, not an assertion about a constructed env.
writeFileSync('npm-python.json', execFileSync(process.env.HERMES_PYTHON,
['-I', '-c', 'import json, os, sys, selected_dependency; print(json.dumps(dict(python=sys.executable, configured=os.environ["PYTHON"])))']));
'''))
_put(source, "packages/value/index.js", "export const value = 'compiled local graph';\n")
_put(source, "packages/value/index.d.ts", "export const value: string;\n")
_put(source, "ui-tui/src/entry.tsx", "import { value } from 'fixture-value'; console.log(value);\n")
_put(source, "web/src/main.ts", "import { value } from 'fixture-value'; document.body.textContent = value;\n")
_put(source, "web/index.html", '<html><body><script type="module" src="/src/main.ts"></script></body></html>')
_put(source, "web/tsconfig.json", json.dumps({"compilerOptions": {
"target": "ES2022", "module": "ESNext", "moduleResolution": "bundler",
"types": [], "noEmit": True}, "include": ["src"]}))
_put(source, "web/vite.config.ts", "export default {};\n")
_put(source, "web/public/favicon.ico", "prepared icon input")
return node
@pytest.mark.platforms("posix")
def test_selected_child_builds_and_finalizes_under_parent_lock(completion):
from hermes_cli.config_defaults import DEFAULT_CONFIG
from hermes_cli.update_lock import UpdateLock
import hermes_yaml
source, home, request, context, result, run = completion
node = _npm_graph(source)
before = context.read_bytes()
with UpdateLock() as lock:
assert lock.acquired
marker = lock.path.read_bytes()
child = run()
assert child.returncode == 0, child.stdout + child.stderr
assert lock.path.read_bytes() == marker, "completion must not release its parent's lock"
built = subprocess.run([node, str(source / "ui-tui/dist/entry.js")],
capture_output=True, text=True, check=True)
assert built.stdout.strip() == "compiled local graph"
assert (source / "hermes_cli/web_dist/index.html").is_file()
assert any("compiled local graph" in p.read_text() for p in (source / "hermes_cli/web_dist/assets").glob("*.js"))
assert (source / "node_modules/.hermes-node-deps").is_file()
environment = json.loads((source / "build-environment.json").read_text())
assert "repairing the recorded dependency environment" not in child.stderr
npm_python = json.loads((source / "npm-python.json").read_text())
assert npm_python["python"] == npm_python["configured"] == environment["python"]
assert environment["parent"] == os.getpid()
assert environment["old_module"] is False
assert environment["argv"] == [str(source / "hermes"), "update"]
assert context.read_bytes() == before
assert json.loads((source / "restarted-plan.json").read_text())["expected_sha"] == "old-sha"
assert json.loads((source / "resumed-token.json").read_text()) == request["windows_resume"]
config = hermes_yaml.safe_load((home / "config.yaml").read_text())
assert config["_config_version"] == DEFAULT_CONFIG["_config_version"], child.stdout + child.stderr
assert config["model"]["default"] == "retained-model"
receipt = json.loads((home / "logs/update_receipts/latest.json").read_text())
assert receipt["outcome"] == "success"
assert receipt["update_id"] == request["update_id"]
assert receipt["pre_update"] == request["receipt"]["pre_update"]
assert receipt["steps"][0] == request["receipt"]["steps"][0]
assert receipt["pm_venv_rebuild"] == request["pm_receipt"]["venv_rebuild"]
assert json.loads(result.read_text()) == {"resume_handled": True, "receipt_handled": True}
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=source, text=True).strip()
assert json.loads((source / "install-stamp.json").read_text())["commit"] == head
@pytest.mark.platforms("posix")
def test_real_compiler_failure_is_owed_build_and_completion_continues(completion):
"""Contract C3 for the historical takeover child: the code is committed, so a failed product
build is an owed ``build`` follow-up, never a failed update. Maintenance (config migration)
and the fleet restart still run, the exit is 0, and the tail stays pending until it builds."""
from hermes_cli.config_defaults import DEFAULT_CONFIG
from hermes_cli.venv_sync import completion_pending_path
import hermes_yaml
source, home, request, context, result, run = completion
_npm_graph(source)
# A real TypeScript error: dependency installation and TUI succeed; the web product fails.
_put(source, "web/src/main.ts", "const broken: string = 42; document.body.textContent = broken;\n")
context_before = context.read_bytes()
child = run()
assert child.returncode == 0, child.stdout + child.stderr
assert "TypeScript build failed" in child.stdout + child.stderr
assert (source / "npm-python.json").is_file()
assert (source / "ui-tui/dist/entry.js").is_file()
assert not (source / "hermes_cli/web_dist/index.html").exists()
# Maintenance and the fleet restart were not skipped by the build failure.
config = hermes_yaml.safe_load((home / "config.yaml").read_text())
assert config["_config_version"] == DEFAULT_CONFIG["_config_version"]
assert config["model"]["default"] == "retained-model"
assert (source / "restarted-plan.json").is_file()
assert context.read_bytes() == context_before
receipt = json.loads((home / "logs/update_receipts/latest.json").read_text())
assert receipt["outcome"] == "success"
assert [row["step"] for row in receipt["followups"]] == ["build"]
# Only a build follow-up marks the build stage failed (an owed config/dependency step does not).
assert {mark["name"]: mark["outcome"] for mark in receipt["stages"]}.get("build") == "failed"
assert receipt["update_id"] == request["update_id"]
assert receipt["steps"][0] == request["receipt"]["steps"][0]
assert receipt["pm_venv_rebuild"] == request["pm_receipt"]["venv_rebuild"]
# The owed tail is retried by the next launch; nothing claims the tree was built.
assert completion_pending_path(source).is_file()
assert not (source / "install-stamp.json").exists()
assert json.loads(result.read_text()) == {"resume_handled": True, "receipt_handled": True}
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("argument, code", [("--help", 0), ("--no-such-update-option", 2)])
def test_pre_pull_restart_enters_current_cli_with_original_arguments(completion, argument, code):
source, home, request, context, result, run = completion
# Exercise the REAL parser without contacting a repository or supervisor.
# The full update command's apply path is covered by its own tests.
request.update(restart_update=True, argv=[str(source / "hermes"), "update", argument])
context.write_text(json.dumps(request), encoding="utf-8")
child = run()
assert child.returncode == code, child.stdout + child.stderr
output = child.stdout + child.stderr
assert "usage:" in output and "update" in output
assert not (source / "build-environment.json").exists()
assert json.loads(result.read_text()) == {"resume_handled": True, "receipt_handled": True}