diff --git a/scripts/compose-drift-watch.py b/scripts/compose-drift-watch.py index ba0373541..a8d76584e 100755 --- a/scripts/compose-drift-watch.py +++ b/scripts/compose-drift-watch.py @@ -112,6 +112,23 @@ # happens to carry a top-level compose.yml (this repo's own), which is # what made it look like a stack directory to a naive glob. KNOWN_NON_PROJECT_DIRS = {"honeypot-arcane", "apiary"} +# #3129: llm-worker is still a manifest entry with a persistent restart +# policy (not retired), but its single container has been deliberately kept +# down since #3023 -- it was issuing a competing /api/chat against the same +# Ollama instance the #1947 benchmark sweep needs cold, so the sweep owner +# gated it off until the benchmark run releases it. That is an operator +# decision, not the "torn down" shape retired_projects() (and #3040's Gate 1 +# below) exist to tell apart from real drift. #3040's own scope asked for +# exactly this mechanism -- "Retired/intentionally-absent services need an +# explicit allowlist with a reason, the same shape isolation-audit.sh +# already uses for its known-gap WARN tier" -- so this is the allowlist that +# issue specified, keyed by project name so it only ever silences this +# specific, documented absence. Do not add a second entry here +# without an equally-documented operator decision: that is exactly the +# blanket "no sibling, don't alarm" rule #3040 removed. +EXPECTED_ABSENT_WHILE = { + "llm-worker": "#3023 sweep-owner-gated for the #1947 benchmark run; stays down until the operator releases it", +} def fail(msg: str) -> "None": @@ -554,6 +571,15 @@ def sweep(stacks_root: Path, entries: list[dict], retired: set[str] | None) -> t if not missing: continue + # #3129: an operator-gated deliberate absence, distinct from both + # "retired" (no manifest entry at all) and ordinary drift. Checked + # before the sibling logic below since llm-worker structurally never + # has a sibling either way -- this is not a re-run of #3040's removed + # blanket rule, it only matches the one named project in + # EXPECTED_ABSENT_WHILE. + if name in EXPECTED_ABSENT_WHILE: + continue + # #3040: the old rule suppressed *every* project with no sibling # currently running, on the theory that "the whole stack isn't # deployed" is self-evident and doesn't need an alarm. That's true diff --git a/scripts/tests/test_compose_drift_watch_sweep.py b/scripts/tests/test_compose_drift_watch_sweep.py index edb50ef2f..8c38c0b30 100644 --- a/scripts/tests/test_compose_drift_watch_sweep.py +++ b/scripts/tests/test_compose_drift_watch_sweep.py @@ -65,15 +65,46 @@ def _mkproject(self, name: str) -> None: (d / "compose.yml").write_text("services: {}\n") def test_single_service_project_missing_container_alarms(self) -> None: + # #3129: llm-worker itself moved into EXPECTED_ABSENT_WHILE (see + # below), so this general "single-service project alarms" pin now + # uses a different real single-service manifest entry instead. + self._mkproject("auth-events-worker") + configs = {"auth-events-worker": ({"auth-events-worker": "unless-stopped"}, [])} + with mock.patch.object(cdw.subprocess, "run", side_effect=_fake_compose_run(configs)): + findings, unresolved = cdw.sweep(self.stacks_root, entries=[], retired=set()) + self.assertEqual(unresolved, []) + self.assertEqual(len(findings), 1) + self.assertEqual(findings[0]["project"], "auth-events-worker") + self.assertEqual(findings[0]["service"], "auth-events-worker") + self.assertEqual(findings[0]["siblings_running"], []) + + def test_expected_absent_project_stays_quiet(self) -> None: + # #3129: llm-worker is still in the manifest (not retired) with a + # persistent restart policy, but its one container has been + # deliberately down since #3023. EXPECTED_ABSENT_WHILE must silence + # exactly this named project without reopening #3040's blanket + # "no sibling, don't alarm" rule for anything else. self._mkproject("llm-worker") configs = {"llm-worker": ({"llm-worker": "unless-stopped"}, [])} with mock.patch.object(cdw.subprocess, "run", side_effect=_fake_compose_run(configs)): findings, unresolved = cdw.sweep(self.stacks_root, entries=[], retired=set()) + self.assertEqual(findings, []) self.assertEqual(unresolved, []) + + def test_unlisted_single_service_project_still_alarms_next_to_expected_absent(self) -> None: + # Control for the above: a different single-service project with the + # same zero-container, no-sibling shape as llm-worker must still + # alarm -- EXPECTED_ABSENT_WHILE only matches its one named key. + self._mkproject("llm-worker") + self._mkproject("ml-worker") + configs = { + "llm-worker": ({"llm-worker": "unless-stopped"}, []), + "ml-worker": ({"ml-worker": "unless-stopped"}, []), + } + with mock.patch.object(cdw.subprocess, "run", side_effect=_fake_compose_run(configs)): + findings, _ = cdw.sweep(self.stacks_root, entries=[], retired=set()) self.assertEqual(len(findings), 1) - self.assertEqual(findings[0]["project"], "llm-worker") - self.assertEqual(findings[0]["service"], "llm-worker") - self.assertEqual(findings[0]["siblings_running"], []) + self.assertEqual(findings[0]["project"], "ml-worker") def test_fully_retired_project_down_stays_quiet(self) -> None: self._mkproject("wordpot")