Skip to content

Commit 436f6f0

Browse files
fix: repair pre-existing watcher_pane tests
Eight banner-copy/budget assertions were stale (title-cased rewording, gate-vs-env precedence, intentional just-started banner pinning); the section allocator was genuinely over-allocating on overflow. - Update stale banner/budget/sort tests to match intended behavior. - Fix _allocate_section_rows to scale expanded sections down proportionally on overflow (min(3,natural) floor) so the row total honors available_rows. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4e2aa24 commit 436f6f0

3 files changed

Lines changed: 144 additions & 28 deletions

File tree

.console/log.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
# Log
22

3+
## 2026-05-26 — Repair pre-existing watcher_pane tests
4+
5+
Fixed 9 stale/regressed failures in tests/test_watcher_pane.py.
6+
7+
- Banner-message tests (healthy/switchboard/gate/queue/info): STALE tests. The
8+
banner copy was intentionally title-cased and reworded ("All Systems Nominal",
9+
"SwitchBoard Offline", "Global Gate at Cap", "Queue Depth", "Stabilizing").
10+
Updated assertions to the current strings.
11+
- test_exec_budget_reads_usage: STALE test — it never isolated _resource_gate(),
12+
which reads the real on-disk OC config; gate values shadow the env caps the
13+
test sets, so daily_cap came back as the config's value. Added a monkeypatch
14+
stubbing _resource_gate -> {} so the env-override path is the one exercised.
15+
- test_critical_sorts_before_warning_sorts_before_info: STALE premise — the
16+
"just started" INFO banner is intentionally PINNED to the front of the cycle,
17+
so a linear crit<warn<info ordering can't hold within 30s of launch. Split
18+
into test_critical_sorts_before_warning (pure severity order, started_at=0)
19+
and test_just_started_info_is_pinned_to_front (documents the pinning).
20+
- test_overflow_proportional / test_collapsed_during_overflow: REAL code bug.
21+
_allocate_section_rows did no overflow scaling and over-allocated far past the
22+
available rows (42/31 vs cap 10). Added proportional down-scaling on overflow
23+
with a min(3, natural) floor; natural-fit, size_mult, collapsed, and empty
24+
paths preserved.
25+
26+
tests/test_watcher_pane.py: 27 passed. Full suite: 132 passed, 3 failed —
27+
the 3 are pre-existing cxrp schema_version 0.2-vs-0.3 mismatches in
28+
tests/test_cxrp_capture.py, unrelated to this change. Custodian clean.
29+
330
## 2026-05-22 — Rename ContextLifecycleProtocol → ContextLifecycle
431

532
Hard cutover. Renamed profile file from `contextlifecycleprotocol.yaml` to `contextlifecycle.yaml`. Updated all references in config, git_watcher, and platform.yaml.

src/operator_console/watcher_status_pane.py

Lines changed: 85 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,28 +1169,98 @@ def _allocate_section_rows(
11691169
) -> list[int]:
11701170
"""Decide how many on-screen rows each section gets.
11711171
1172-
Each section gets its full natural height (collapsed = 1 row,
1173-
expanded = ``len(lines) * size_mult.get(id, 1.0)``, rounded up).
1174-
No proportional scaling on overflow — the render loop truncates
1175-
at ``middle_bottom``, so sections later in the list lose visibility
1176-
when earlier expanded sections use up the available space. The
1177-
operator manages overflow by collapsing sections, not by resizing.
1172+
Each section's *natural* height is collapsed = 1 row, expanded =
1173+
``len(lines) * size_mult.get(id, 1.0)`` (rounded up), and empty = 0.
1174+
1175+
When the natural heights fit within ``available_rows`` they are
1176+
returned unchanged. On overflow the expanded (non-collapsed,
1177+
non-empty) sections are scaled down proportionally to their natural
1178+
height so the total fits, with a per-section floor of ``min(3,
1179+
natural)`` rows so every visible section keeps a usable slice.
1180+
Collapsed sections always keep their single row; empty sections
1181+
always get zero.
11781182
"""
11791183
collapsed = collapsed or {}
11801184
size_mult = size_mult or {}
11811185
if available_rows <= 0 or not sections:
11821186
return [0] * len(sections)
1183-
from math import ceil
1184-
out: list[int] = []
1187+
from math import ceil, floor
1188+
1189+
natural: list[int] = []
11851190
for s in sections:
11861191
if not s["lines"]:
1187-
out.append(0)
1188-
continue
1189-
if collapsed.get(s["id"], False):
1190-
out.append(1)
1191-
continue
1192-
mult = size_mult.get(s["id"], 1.0)
1193-
out.append(max(1, ceil(len(s["lines"]) * mult)))
1192+
natural.append(0)
1193+
elif collapsed.get(s["id"], False):
1194+
natural.append(1)
1195+
else:
1196+
mult = size_mult.get(s["id"], 1.0)
1197+
natural.append(max(1, ceil(len(s["lines"]) * mult)))
1198+
1199+
if sum(natural) <= available_rows:
1200+
return natural
1201+
1202+
# Overflow: fixed rows = collapsed (1 each) + empty (0). Distribute the
1203+
# remaining budget across the expandable sections proportionally.
1204+
expand_idx = [
1205+
i for i, s in enumerate(sections)
1206+
if s["lines"] and not collapsed.get(s["id"], False)
1207+
]
1208+
out = list(natural)
1209+
fixed = sum(out[i] for i in range(len(sections)) if i not in expand_idx)
1210+
budget = available_rows - fixed
1211+
if budget <= 0 or not expand_idx:
1212+
# No room left for expandable content; give each at most 1 row,
1213+
# trimming from the end until we fit.
1214+
for i in expand_idx:
1215+
out[i] = 1
1216+
while sum(out) > available_rows and expand_idx:
1217+
out[expand_idx.pop()] = 0
1218+
return out
1219+
1220+
total_natural = sum(natural[i] for i in expand_idx)
1221+
floors = {i: min(3, natural[i]) for i in expand_idx}
1222+
if sum(floors.values()) >= budget:
1223+
# Even the floors don't fit — hand out the budget one row at a
1224+
# time, largest-natural first, so no section is starved unfairly.
1225+
for i in expand_idx:
1226+
out[i] = 0
1227+
order = sorted(expand_idx, key=lambda i: natural[i], reverse=True)
1228+
remaining = budget
1229+
while remaining > 0:
1230+
progressed = False
1231+
for i in order:
1232+
if remaining <= 0:
1233+
break
1234+
if out[i] < natural[i]:
1235+
out[i] += 1
1236+
remaining -= 1
1237+
progressed = True
1238+
if not progressed:
1239+
break
1240+
return out
1241+
1242+
# Proportional share above the floor.
1243+
extra = budget - sum(floors.values())
1244+
weights = {i: natural[i] / total_natural for i in expand_idx}
1245+
alloc = {i: floors[i] + floor(extra * weights[i]) for i in expand_idx}
1246+
for i in expand_idx:
1247+
alloc[i] = min(alloc[i], natural[i])
1248+
# Distribute any leftover rows (from flooring) to largest natural first.
1249+
leftover = budget - sum(alloc.values())
1250+
order = sorted(expand_idx, key=lambda i: natural[i], reverse=True)
1251+
while leftover > 0:
1252+
progressed = False
1253+
for i in order:
1254+
if leftover <= 0:
1255+
break
1256+
if alloc[i] < natural[i]:
1257+
alloc[i] += 1
1258+
leftover -= 1
1259+
progressed = True
1260+
if not progressed:
1261+
break
1262+
for i in expand_idx:
1263+
out[i] = alloc[i]
11941264
return out
11951265

11961266

tests/test_watcher_pane.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ def test_exec_budget_reads_usage(self, tmp_path, monkeypatch):
1616
json.dumps({"hourly_exec_count": 7, "daily_exec_count": 33})
1717
)
1818
monkeypatch.setattr(wsp, "_USAGE_PATH", target / "usage.json")
19+
# Isolate from any on-disk resource_gate so the env-override path
20+
# (the behaviour this test verifies) is the one actually exercised;
21+
# gate values take precedence over env when present.
22+
monkeypatch.setattr(wsp, "_resource_gate", lambda: {})
1923
monkeypatch.setenv("OPERATIONS_CENTER_MAX_EXEC_PER_HOUR", "12")
2024
monkeypatch.setenv("OPERATIONS_CENTER_MAX_EXEC_PER_DAY", "60")
2125
b = wsp._exec_budget()
@@ -238,7 +242,7 @@ def test_healthy_when_no_conditions(self, monkeypatch):
238242
result = wsp._banner_conditions(self._data(), started_at=0)
239243
assert len(result) == 1
240244
assert result[0][0] == wsp.BANNER_HEALTHY
241-
assert "nominal" in result[0][1]
245+
assert "Nominal" in result[0][1]
242246

243247
def test_critical_stall_takes_precedence(self, monkeypatch):
244248
from operator_console import watcher_status_pane as wsp
@@ -251,7 +255,7 @@ def test_switchboard_down_is_critical(self, monkeypatch):
251255
from operator_console import watcher_status_pane as wsp
252256
monkeypatch.setattr(wsp, "_stale_heartbeat_roles", lambda: [])
253257
result = wsp._banner_conditions(self._data(sb=False), started_at=0)
254-
assert any("SwitchBoard offline" in m for _, m in result)
258+
assert any("SwitchBoard Offline" in m for _, m in result)
255259

256260
def test_resource_gate_at_cap_is_critical(self, monkeypatch):
257261
from operator_console import watcher_status_pane as wsp
@@ -261,7 +265,7 @@ def test_resource_gate_at_cap_is_critical(self, monkeypatch):
261265
backend_usage={"team_executor": {"in_flight": 2}},
262266
)
263267
result = wsp._banner_conditions(d, started_at=0)
264-
assert any(s == wsp.BANNER_CRIT and "Global gate" in m
268+
assert any(s == wsp.BANNER_CRIT and "Global Gate" in m
265269
for s, m in result)
266270

267271
def test_backend_saturation_is_warning(self, monkeypatch):
@@ -279,30 +283,45 @@ def test_queue_overflow_is_warning(self, monkeypatch):
279283
monkeypatch.setattr(wsp, "_stale_heartbeat_roles", lambda: [])
280284
d = self._data(queue=[{"task_type": "goal"}] * 12)
281285
result = wsp._banner_conditions(d, started_at=0)
282-
assert any(s == wsp.BANNER_WARN and "Queue depth" in m
286+
assert any(s == wsp.BANNER_WARN and "Queue Depth" in m
283287
for s, m in result)
284288

285289
def test_info_banner_during_first_30_seconds(self, monkeypatch):
286290
import time
287291
from operator_console import watcher_status_pane as wsp
288292
monkeypatch.setattr(wsp, "_stale_heartbeat_roles", lambda: [])
289293
result = wsp._banner_conditions(self._data(), started_at=time.time())
290-
assert any(s == wsp.BANNER_INFO and "stabilizing" in m
294+
assert any(s == wsp.BANNER_INFO and "Stabilizing" in m
291295
for s, m in result)
292296

293-
def test_critical_sorts_before_warning_sorts_before_info(
294-
self, monkeypatch,
295-
):
296-
import time
297+
def test_critical_sorts_before_warning(self, monkeypatch):
297298
from operator_console import watcher_status_pane as wsp
298299
monkeypatch.setattr(wsp, "_stale_heartbeat_roles", lambda: ["goal"])
299300
d = self._data(
300301
queue=[{"task_type": "goal"}] * 12,
301302
)
302-
result = wsp._banner_conditions(d, started_at=time.time())
303+
# started_at far in the past: no "just started" INFO is injected,
304+
# so we observe the pure severity-sorted order.
305+
result = wsp._banner_conditions(d, started_at=0)
303306
levels = [s for s, _ in result]
304-
# CRIT (stall) should come before WARN (queue depth) before INFO.
307+
# CRIT (stall) should come before WARN (queue depth).
305308
crit_idx = levels.index(wsp.BANNER_CRIT)
306309
warn_idx = levels.index(wsp.BANNER_WARN)
307-
info_idx = levels.index(wsp.BANNER_INFO)
308-
assert crit_idx < warn_idx < info_idx
310+
assert crit_idx < warn_idx
311+
312+
def test_just_started_info_is_pinned_to_front(self, monkeypatch):
313+
import time
314+
from operator_console import watcher_status_pane as wsp
315+
monkeypatch.setattr(wsp, "_stale_heartbeat_roles", lambda: ["goal"])
316+
d = self._data(
317+
queue=[{"task_type": "goal"}] * 12,
318+
)
319+
# Within the first 30s the "stabilizing" INFO is pinned to the
320+
# front of the cycle so operators see it first on launch, even
321+
# when CRITICAL conditions are also active.
322+
result = wsp._banner_conditions(d, started_at=time.time())
323+
assert result[0][0] == wsp.BANNER_INFO
324+
assert "Stabilizing" in result[0][1]
325+
# The severity ordering still holds for the remaining conditions.
326+
rest = [s for s, _ in result[1:]]
327+
assert rest.index(wsp.BANNER_CRIT) < rest.index(wsp.BANNER_WARN)

0 commit comments

Comments
 (0)