diff --git a/CHANGELOG.md b/CHANGELOG.md index 910332c..43514e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,14 @@ All notable changes to MessageFoundry are documented here. The format follows ON). Residuals in [SERVICE.md](docs/SERVICE.md). ### Fixed +- **The harness message list could livelock and stop updating entirely.** `MessagesPanel._apply` cleared + its in-flight guard, re-fired any refresh latched during the read, then returned *before* rendering — + discarding the snapshot as superseded. Whenever refreshes arrive faster than a read completes there is + always a latch waiting when the read lands, so every snapshot was discarded and the table never + updated: permanently stuck, not merely slow (measured: 391 reads served, 0 rendered). It now renders + first and drains afterwards, costing at most one read of staleness while still converging on the + latest filter. This surfaced as an intermittent CI failure that no timeout or retry could fix, because + neither addresses a livelock. - **Two config blocks in the off-loopback runbook aborted at load** — `[diagnostics].audit_all_authz` and `[ai].data_class` had been relocated by ADR 0118, so an operator copy-pasting either block got an immediate start failure. Corrected to `[security].audit_all_authorization_decisions` and diff --git a/scripts/hooks/push_guard.py b/scripts/hooks/push_guard.py index d9da0de..d2b1598 100644 --- a/scripts/hooks/push_guard.py +++ b/scripts/hooks/push_guard.py @@ -16,9 +16,13 @@ WHAT THIS IS NOT. A guardrail, not a security boundary: ``git push --no-verify`` skips it, and it is local-only, so a different machine is unprotected. It removes the ACCIDENT, not the capability. The -durable server-side fix is ``enforce_admins=true``, which is deliberately NOT in place yet because a -known flaky test (BACKLOG #17) has blocked two consecutive PRs, and removing the override while a -flake can strand a merge trades an accidental-push risk for a cannot-ship risk. +durable server-side fix is ``enforce_admins=true``, which was deliberately NOT enabled while an +intermittent harness-monitor failure was blocking consecutive PRs -- removing the admin override while +a flake can strand a merge trades an accidental-push risk for a cannot-ship risk. That failure turned +out to be a livelock in ``MessagesPanel._apply`` rather than a flake, and is fixed +(``tests/test_console_messages_refresh.py``), so the argument against ``enforce_admins`` is weaker now +than when this was written. (An earlier revision of this note cited "BACKLOG #17" for it; that is the +py3.11 pytest/aiosqlite deadlock, OBSOLETE and unrelated.) Stdlib only, like the other gates -- most worktrees have no project .venv. diff --git a/tests/test_console_messages_refresh.py b/tests/test_console_messages_refresh.py index 83c1d26..0d755ed 100644 --- a/tests/test_console_messages_refresh.py +++ b/tests/test_console_messages_refresh.py @@ -1,6 +1,12 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 MessageFoundry Organization and contributors -"""MessagesPanel refresh convergence — the BACKLOG #17 livelock. +"""MessagesPanel refresh convergence — the discard-every-snapshot livelock. + +NOT BACKLOG #17, despite what earlier comments here and in test_harness_monitor.py said. #17 is the +py3.11 pytest/aiosqlite cancellation deadlock (OBSOLETE since the 3.14-only migration); following that +citation lands on a closed item about an unrelated bug. The misattribution was inherited from a comment +on the flaky test and then repeated across new files while fixing something else entirely. This +livelock has no backlog entry — it was found, fixed and covered in one pass. ``refresh()`` reads the message list off the main thread and guards against pile-up with ``_loading``; a refresh requested mid-flight is latched in ``_pending`` and re-fired when the read lands. ``_apply`` @@ -75,7 +81,7 @@ def test_a_latched_refresh_no_longer_discards_the_snapshot(qapp: Any) -> None: panel._apply(_MessagesSnapshot([_msg("m1")], "1 shown of 1", False, None)) assert panel._table.rowCount() == 1, ( - "snapshot discarded as 'superseded' — this is the BACKLOG #17 livelock" + "snapshot discarded as 'superseded' — this is the refresh livelock" ) # Dropping the render must not come back as dropping the re-fire either. assert panel._pending is None, "the latched refresh was never drained" diff --git a/tests/test_harness_monitor.py b/tests/test_harness_monitor.py index 7eb708a..d5c81f4 100644 --- a/tests/test_harness_monitor.py +++ b/tests/test_harness_monitor.py @@ -162,13 +162,17 @@ def test_monitor_panel_builds_disconnected(qapp: Any) -> None: # Override the global 60s per-test watchdog: the two waits share a 60s budget, and fixture/engine # startup sits outside it, so a slow-but-passing CI run could otherwise brush the 60s cap. # -# NO reruns=2 here any more, deliberately. It was added to let BACKLOG #17 "self-heal" within a run, -# on the theory that this was a residual timing flake on loaded Windows runners. It was not: the -# message list was livelocking (MessagesPanel._apply discarded every snapshot as superseded while this -# test polled refresh() at 50ms — see tests/test_console_messages_refresh.py), which is why neither -# 10s→30s nor the reruns ever fixed it. That is now fixed at the source. Keeping the retry would only -# hide the next real defect the same way it hid this one, and would make the fix unmeasurable — a -# masked failure looks exactly like a working one. +# NO reruns=2 here any more, deliberately. It was added to let this "self-heal" within a run, on the +# theory that it was a residual timing flake on loaded Windows runners. It was not: the message list +# was livelocking (MessagesPanel._apply discarded every snapshot as superseded while this test polled +# refresh() at 50ms — see tests/test_console_messages_refresh.py), which is why neither 10s→30s nor the +# reruns ever fixed it. That is now fixed at the source. Keeping the retry would only hide the next real +# defect the same way it hid this one, and would make the fix unmeasurable — a masked failure looks +# exactly like a working one. +# +# The comment this replaced blamed "BACKLOG #17". That is the py3.11 pytest/aiosqlite cancellation +# deadlock, OBSOLETE since the 3.14-only migration and unrelated to anything here — a wrong citation +# that survived long enough to be copied into new files. This defect has no backlog entry. @pytest.mark.timeout(120) def test_monitor_observes_engine(qapp: Any, server: tuple[str, Path]) -> None: url, inbox = server @@ -201,7 +205,7 @@ def ctx() -> str: def test_monitor_observes_a_message_that_arrives_after_connect( qapp: Any, server: tuple[str, Path] ) -> None: - """The deterministic form of what CI kept hitting, and the reason BACKLOG #17 looked like a flake. + """The deterministic form of what CI kept hitting, and the reason this looked like a flake at all. The sibling test writes the message BEFORE the panel connects, so the single initial ``refresh()`` in ``_build_inner`` normally renders it and the polling path below is never exercised at all. That