fix(update): the receipt's build stage fails only for a build follow-up (C3)

Both completion paths marked the build stage failed whenever ANY tail follow-up
was owed, so a run whose only debt was config_migration (or maintenance /
dependencies) reported a failed build. record_build_stage marks it failed only
for launchers/build (what the build stage publishes) or completion (the tail
raised before proving them); other owed steps stay their own follow-ups.
This commit is contained in:
Teknium
2026-10-04 02:00:09 -07:00
parent aaef8a4d6a
commit 8988d3efd0
6 changed files with 22 additions and 4 deletions

View File

@@ -263,7 +263,8 @@ def _complete_selected(request: dict) -> bool:
from hermes_cli import main, update_cmd, update_cmd_config
from hermes_cli.source_completion import complete_source_checkout
from hermes_cli.update_inventory import RuntimeRecord, UpdatePlan
from hermes_cli.update_receipt import TAIL_FOLLOWUPS, record_followup, record_skip, record_stage
from hermes_cli.update_receipt import (
TAIL_FOLLOWUPS, record_build_stage, record_followup, record_skip, record_stage)
root = Path(request["source"])
main.PROJECT_ROOT = root
@@ -294,7 +295,7 @@ def _complete_selected(request: dict) -> bool:
record_followup("completion", reason)
followups.append(("completion", reason))
tail_owed = any(step in TAIL_FOLLOWUPS for step, _ in followups)
record_stage("build", "failed" if tail_owed else "success")
record_build_stage(followups)
if not tail_owed:
from hermes_cli.venv_sync import clear_completion
clear_completion(root)

View File

@@ -22,7 +22,7 @@ def finish_update(*, root, assume_yes, gateway_mode, pre_update_snapshot_id,
_restart_gateway_fleet_after_update, _verify_fleet_after_update,
_write_gateway_update_exit_code, _resume_windows_gateways_and_merge_outcome,
)
from hermes_cli.update_receipt import TAIL_FOLLOWUPS, record_followup, record_stage
from hermes_cli.update_receipt import TAIL_FOLLOWUPS, record_build_stage, record_followup
owed = followups if followups is not None else []
runtime_safe = False
@@ -38,7 +38,7 @@ def finish_update(*, root, assume_yes, gateway_mode, pre_update_snapshot_id,
record_followup("maintenance", reason)
owed.append(("maintenance", reason))
tail_owed = any(step in TAIL_FOLLOWUPS for step, _ in owed)
record_stage("build", "failed" if tail_owed else "success")
record_build_stage(owed)
if not tail_owed:
from hermes_cli.source_stamp import write_source_stamp
from hermes_cli.venv_sync import clear_completion

View File

@@ -317,6 +317,17 @@ def persist_running_receipt() -> None:
#: Follow-up steps whose failure leaves the source-update tail owed (``source-completion-pending``).
TAIL_FOLLOWUPS = frozenset({"dependencies", "launchers", "build", "maintenance", "config_migration", "completion"})
#: Follow-ups that mean the BUILD stage did not succeed (C3: the receipt names what actually failed):
#: the products or the launchers the build publishes failed, or the tail raised before proving them.
#: Owed dependencies, config migration or maintenance keep the tail armed but say nothing about the
#: build; they are reported as their own follow-ups.
BUILD_FOLLOWUPS = frozenset({"launchers", "build", "completion"})
def record_build_stage(followups) -> None:
"""Mark the build stage from the owed ``(step, reason)`` follow-ups: failed only for a build one."""
record_stage("build", "failed" if any(step in BUILD_FOLLOWUPS for step, _ in followups) else "success")
def record_followup(step: str, reason: str, *, retry: str = "the next launch or `hermes update` retries it") -> None:
"""A post-commit step failed: print ⚠, keep the run a success, and say what is still owed.

View File

@@ -155,6 +155,7 @@ def transition(tmp_path):
"_current = contextvars.ContextVar('receipt', default=None)\n"
"class UpdateReceipt: pass\n"
"def record_stage(*args, **kwargs): pass\n"
"def record_build_stage(*args, **kwargs): pass\n"
"def record_skip(*args, **kwargs): pass\n"
"TAIL_FOLLOWUPS = frozenset({'dependencies', 'launchers', 'build', 'maintenance', 'config_migration', 'completion'})\n"
"def record_followup(step, reason, **kwargs):\n"

View File

@@ -428,6 +428,8 @@ def test_real_compiler_failure_is_owed_build_and_completion_continues(completion
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"]

View File

@@ -87,6 +87,7 @@ _CHILD = textwrap.dedent('''
(cell / "summary.json").write_text(json.dumps({
"code": code, "outcome": latest.get("outcome"),
"followups": [row["step"] for row in latest.get("followups", [])],
"stages": {mark["name"]: mark["outcome"] for mark in latest.get("stages", [])},
"config_version": yaml.safe_load((home / "config.yaml").read_text())["_config_version"],
"latest_config_version": DEFAULT_CONFIG["_config_version"],
"pending": completion_pending_path(source).is_file(),
@@ -153,6 +154,8 @@ def test_config_format_write_failure_is_owed_not_reported_complete(tmp_path):
assert summary["followups"] == ["config_migration"]
assert summary["pending"] is True
assert summary["stamp"] is False
# C3: the receipt names what actually failed. The build succeeded; only the config is owed.
assert summary["stages"].get("build") == "success", summary["stages"]
_RECEIPT_CHILD = textwrap.dedent('''