diff --git a/hermes_cli/update_cmd_fleet.py b/hermes_cli/update_cmd_fleet.py index e9f1c92875..b8ebab13f3 100644 --- a/hermes_cli/update_cmd_fleet.py +++ b/hermes_cli/update_cmd_fleet.py @@ -78,14 +78,27 @@ def _current_checkout_sha() -> str | None: def _receipt_looks_unfinished(receipt: dict) -> bool: - """True when *receipt* is from an update that did not finish cleanly.""" + """True when *receipt* is from an update that did not finish cleanly. + + ``stop_reason`` records *how* the command boundary closed the receipt + (``completed at command boundary``, ``sys.exit(0)``, even KeyboardInterrupt + on an otherwise successful run). A truthy stop_reason must not make a + successful receipt look unfinished, or the next ``hermes update`` retriggers + ``fleet_restart_pending`` from pre-pull plan SHAs. + """ + exit_code = receipt.get("exit_code") + outcome = receipt.get("outcome") + if exit_code not in (0, None): + return True + if outcome in ("failed", "partial", "running"): + return True gateway_restart = receipt.get("gateway_restart") - return bool( - receipt.get("stop_reason") - or receipt.get("exit_code") not in (0, None) - or receipt.get("outcome") in ("failed", "partial", "running") - or (isinstance(gateway_restart, dict) and gateway_restart.get("incomplete")) - ) + if isinstance(gateway_restart, dict) and gateway_restart.get("incomplete"): + return True + stop_reason = receipt.get("stop_reason") + if stop_reason and outcome != "success" and exit_code != 0: + return True + return False def _receipt_reports_stale_runtime(expected_sha: str | None = None) -> bool: diff --git a/tests/hermes_cli/test_update_fleet_restart_pending.py b/tests/hermes_cli/test_update_fleet_restart_pending.py index 156573a92e..49fcf3705b 100644 --- a/tests/hermes_cli/test_update_fleet_restart_pending.py +++ b/tests/hermes_cli/test_update_fleet_restart_pending.py @@ -254,6 +254,67 @@ def test_successful_receipt_with_pre_update_plan_shas_does_not_retrigger( assert update_cmd._pending_fleet_restart_needed() is False +def test_successful_command_boundary_receipt_without_fleet_does_not_retrigger( + monkeypatch, +): + """A normal command-boundary stop is not an interrupted update.""" + disk_sha = "n" * 40 + old_sha = "o" * 40 + monkeypatch.setattr(update_cmd, "_current_checkout_sha", lambda: disk_sha) + monkeypatch.setattr(update_cmd_fleet, "_current_checkout_sha", lambda: disk_sha) + + receipt_dir = get_hermes_home() / "logs" / "update_receipts" + receipt_dir.mkdir(parents=True) + (receipt_dir / "latest.json").write_text( + json.dumps( + { + "exit_code": 0, + "outcome": "success", + "stop_reason": "completed at command boundary", + "plan": { + "expected_sha": old_sha, + "runtimes": [ + { + "kind": "gateway", + "profile": "default", + "pid": 1, + "code_sha": old_sha, + } + ], + }, + "fleet": [], + "gateway_restart": {}, + } + ), + encoding="utf-8", + ) + + assert update_cmd._pending_fleet_restart_needed() is False + + +@pytest.mark.parametrize( + "receipt", + [ + {"outcome": "success", "exit_code": 0, "stop_reason": "sys.exit(0)"}, + {"outcome": "success", "stop_reason": "KeyboardInterrupt: "}, + {"exit_code": 0, "stop_reason": "sys.exit(0)"}, + ], +) +def test_successful_non_boundary_stop_reasons_are_finished(receipt): + """Successful sys.exit(0)/KeyboardInterrupt must not look unfinished.""" + assert update_cmd._receipt_looks_unfinished(receipt) is False + + +def test_failed_interrupt_stop_reason_is_unfinished(): + assert update_cmd._receipt_looks_unfinished( + { + "outcome": "failed", + "exit_code": 1, + "stop_reason": "KeyboardInterrupt: ", + } + ) is True + + def test_stale_fleet_matrix_on_latest_receipt_is_pending(monkeypatch): disk_sha = "n" * 40 monkeypatch.setattr(update_cmd, "_current_checkout_sha", lambda: disk_sha)