fix: preserve multi-cause reconcile warnings and align slot blocking

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-12 13:22:40 +00:00
committed by GitHub
co-authored by genichin
parent e47bebacaf
commit 45a5e4157d
16 changed files with 337 additions and 96 deletions
+54 -18
View File
@@ -359,7 +359,7 @@ class InMemoryKillSwitchStore:
class ReconcileWarningStore(Protocol):
"""Durable reconcile-warning overlay — slot_id PK (REQ-102, dcy-034)."""
"""Durable reconcile-warning overlay keyed by (slot_id, case_code)."""
def set_warned(
self,
@@ -372,7 +372,7 @@ class ReconcileWarningStore(Protocol):
) -> None:
...
def clear_warned(self, slot_id: str) -> None:
def clear_warned(self, slot_id: str, *, case_code: str | None = None) -> None:
...
def is_warned(self, slot_id: str) -> bool:
@@ -382,10 +382,14 @@ class ReconcileWarningStore(Protocol):
...
def get_warning_info(self, slot_id: str) -> dict | None:
"""Return {"portfolio_id", "case_code", "actor", "reason"} or None.
"""Return warning info dict or None.
Used by verify to determine whether the warning is case_code='C'
(verify's responsibility) vs 'B' (sync's responsibility, REQ-106 OQ-C).
Returned dict includes:
{"portfolio_id", "case_code", "case_codes", "actor", "reason"}
where case_code is a backward-compatible preferred case and case_codes is
the full active case set for the slot.
Used by verify to determine whether C-case is active (REQ-106 OQ-C).
Returns None when the slot has no active warning.
"""
...
@@ -403,8 +407,8 @@ class InMemoryReconcileWarningStore:
def __init__(self) -> None:
self._lock = threading.Lock()
# slot_id -> {"portfolio_id", "case_code", "actor", "reason"}
self._warned: dict[str, dict] = {}
# slot_id -> case_code -> {"portfolio_id", "actor", "reason"}
self._warned: dict[str, dict[str, dict]] = {}
def set_warned(
self,
@@ -416,16 +420,24 @@ class InMemoryReconcileWarningStore:
reason: str = "",
) -> None:
with self._lock:
self._warned[slot_id] = {
per_case = self._warned.setdefault(slot_id, {})
per_case[case_code] = {
"portfolio_id": portfolio_id,
"case_code": case_code,
"actor": actor,
"reason": reason,
}
def clear_warned(self, slot_id: str) -> None:
def clear_warned(self, slot_id: str, *, case_code: str | None = None) -> None:
with self._lock:
self._warned.pop(slot_id, None)
if case_code is None:
self._warned.pop(slot_id, None)
return
per_case = self._warned.get(slot_id)
if not per_case:
return
per_case.pop(case_code, None)
if not per_case:
self._warned.pop(slot_id, None)
def is_warned(self, slot_id: str) -> bool:
with self._lock:
@@ -437,12 +449,23 @@ class InMemoryReconcileWarningStore:
def get_warning_info(self, slot_id: str) -> dict | None:
with self._lock:
info = self._warned.get(slot_id)
return dict(info) if info is not None else None
per_case = self._warned.get(slot_id)
if not per_case:
return None
case_codes = sorted(per_case.keys())
preferred_case = "C" if "C" in per_case else case_codes[0]
preferred = per_case[preferred_case]
return {
"portfolio_id": preferred.get("portfolio_id", ""),
"case_code": preferred_case,
"case_codes": case_codes,
"actor": preferred.get("actor", ""),
"reason": preferred.get("reason", ""),
}
def list_c_case_warned(self) -> set[str]:
with self._lock:
return {sid for sid, info in self._warned.items() if info.get("case_code") == "C"}
return {sid for sid, per_case in self._warned.items() if "C" in per_case}
class ReconcileNotificationStore(Protocol):
@@ -544,6 +567,7 @@ def slot_to_dict(
watchdog_store=None,
pause_store: PauseStore | None = None,
warn_store: ReconcileWarningStore | None = None,
warning_cases_by_slot: dict[str, set[str]] | None = None,
watchdog_timeout_sec: int = 120,
) -> dict:
"""Merged slot row for trex.slots.req (REQ-073/074).
@@ -618,10 +642,23 @@ def slot_to_dict(
# BAT-050 / REQ-104: reconcile_warned overlay (ReconcileWarningStore) — additive field.
# Bulk list_warned() called once per RPC (caller pre-fetches; see make_slots_rpc_handler).
# warn_store=None → reconcile_warned=False (backward compatible, NFR-4).
reconcile_warned = False
if warn_store is not None:
reconcile_warned = str(s.slot_id) in warn_store.list_warned()
warning_cases: set[str] = set()
if warning_cases_by_slot is not None:
warning_cases = set(warning_cases_by_slot.get(str(s.slot_id), set()))
elif warn_store is not None:
info = warn_store.get_warning_info(str(s.slot_id))
if info is not None:
raw_cases = info.get("case_codes")
if isinstance(raw_cases, list):
warning_cases = {str(c) for c in raw_cases if c}
else:
case_code = str(info.get("case_code", ""))
if case_code:
warning_cases = {case_code}
reconcile_warned = bool(warning_cases)
d["reconcile_warned"] = reconcile_warned
d["reconcile_warning_cases"] = sorted(warning_cases)
d["trade_eligible"] = not reconcile_warned
return d
@@ -637,4 +674,3 @@ def _sanitize(message: str) -> str:
out = out[:idx] + "<redacted>"
break
return out[:200]