diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6200c463f..b370f3881 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -303,6 +303,32 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false + # Keep a small but real pytest lane on Darwin. The full four-shard suite is + # intentionally Linux-only for cost, but platform-gated CLI/proxy code must + # execute somewhere on macOS; otherwise Darwin regressions can merge green + # while being unreachable in every pytest job (#3065). + test-macos: + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: ${{ env.PY_VERSION }} + - name: Install package and focused test dependencies + run: | + python -m pip install --upgrade pip + pip install -e . pytest pytest-asyncio + - name: Run macOS CLI and install tests + run: | + pytest \ + tests/test_cli \ + tests/test_cli_*.py \ + tests/test_install \ + --tb=short -q + test-extras: needs: [changes, build-wheel] if: needs.changes.outputs.code == 'true' diff --git a/headroom/cli/install.py b/headroom/cli/install.py index a81a7d941..f6f20db85 100644 --- a/headroom/cli/install.py +++ b/headroom/cli/install.py @@ -811,14 +811,20 @@ def install_status(profile: str) -> None: manifest = _require_manifest(profile) payload = probe_json(manifest.health_url.replace("/readyz", "/health")) + healthy = probe_ready(manifest.health_url) + # The health endpoint is the authoritative process signal. Detached runtimes + # can outlive a missing/stale PID file (for example after cleanup or a + # supervisor restart), while Docker's local CLI view can also be temporarily + # unavailable. Never report a reachable, ready proxy as "stopped" (#3072). + status = "running" if healthy else runtime_status(manifest) click.echo(f"Profile: {manifest.profile}") click.echo(f"Preset: {manifest.preset}") click.echo(f"Runtime: {manifest.runtime_kind}") click.echo(f"Supervisor: {manifest.supervisor_kind}") click.echo(f"Scope: {manifest.scope}") click.echo(f"Port: {manifest.port}") - click.echo(f"Status: {runtime_status(manifest)}") - click.echo(f"Healthy: {'yes' if probe_ready(manifest.health_url) else 'no'}") + click.echo(f"Status: {status}") + click.echo(f"Healthy: {'yes' if healthy else 'no'}") if payload and isinstance(payload, dict): click.echo(f"Health URL: {manifest.health_url.replace('/readyz', '/health')}") # `config` may be a non-dict (null / string / list) if a different or diff --git a/tests/test_cli/test_install_cli.py b/tests/test_cli/test_install_cli.py index 1817312f0..b1c5b6def 100644 --- a/tests/test_cli/test_install_cli.py +++ b/tests/test_cli/test_install_cli.py @@ -458,6 +458,42 @@ def test_install_status_includes_backend_from_health_probe(monkeypatch) -> None: assert "Backend: anthropic" in result.output +def test_install_status_treats_ready_proxy_as_running(monkeypatch) -> None: + """Health wins when PID/container bookkeeping incorrectly says stopped. + + Regression for #3072 bug 2: status previously printed the contradictory + pair ``Status: stopped`` and ``Healthy: yes`` while doctor correctly + reported the same proxy as running. + """ + manifest = _status_manifest("default") + monkeypatch.setattr(inst, "load_manifest", lambda profile: manifest) + monkeypatch.setattr(inst, "probe_json", lambda url: None) + monkeypatch.setattr(inst, "probe_ready", lambda url: True) + monkeypatch.setattr(inst, "runtime_status", lambda deployment: "stopped") + + result = CliRunner().invoke(main, ["install", "status"]) + + assert result.exit_code == 0, result.output + assert "Status: running" in result.output + assert "Healthy: yes" in result.output + assert "Status: stopped" not in result.output + + +def test_install_status_keeps_runtime_status_when_not_ready(monkeypatch) -> None: + """An unhealthy live process remains distinguishable from a stopped one.""" + manifest = _status_manifest("default") + monkeypatch.setattr(inst, "load_manifest", lambda profile: manifest) + monkeypatch.setattr(inst, "probe_json", lambda url: None) + monkeypatch.setattr(inst, "probe_ready", lambda url: False) + monkeypatch.setattr(inst, "runtime_status", lambda deployment: "running") + + result = CliRunner().invoke(main, ["install", "status"]) + + assert result.exit_code == 0, result.output + assert "Status: running" in result.output + assert "Healthy: no" in result.output + + def test_install_status_survives_non_dict_config(monkeypatch) -> None: """A health payload whose `config` is a non-dict (e.g. a different service answering on the port returns config: null) must not crash the command."""