fix(ttp): E.3.18a worker hydrates per-lifter rule indexes via watch_store

Each per-source lifter holds its own RuleIndex and exposes an
`async watch_store()` that loads the corpus and drains store change
events forever. Until this commit nothing called `watch_store()` in
production — every dispatch index stayed empty and no rule fired.

- Add `WatchableTagger` runtime-checkable Protocol in `decnet.ttp.base`.
- `CompositeTagger.iter_watchables()` yields lifters that satisfy it.
- `run_ttp_worker_loop` fans out one task per watchable, cancelled
  and awaited alongside pump/heartbeat/control in the existing finally.
- Watch failures log and exit the watch task without taking the
  worker down — mirrors the pump-task tolerance contract.
This commit is contained in:
2026-05-02 01:25:15 -04:00
parent 9a31d0e50c
commit 44ade3eb63
4 changed files with 217 additions and 3 deletions

View File

@@ -23,7 +23,14 @@ import logging
import os
from typing import Final
from decnet.ttp.base import KNOWN_SOURCE_KINDS, Tagger, TaggerEvent
from collections.abc import Iterator
from decnet.ttp.base import (
KNOWN_SOURCE_KINDS,
Tagger,
TaggerEvent,
WatchableTagger,
)
from decnet.web.db.models.ttp import TTPTag
_log = logging.getLogger(__name__)
@@ -66,6 +73,19 @@ class CompositeTagger(Tagger):
self._warned_known: set[str] = set()
self._informed_unknown: set[str] = set()
def iter_watchables(self) -> Iterator[WatchableTagger]:
"""Yield every child lifter that hot-reloads from a RuleStore.
The worker (E.3.14) starts one ``asyncio.Task`` per yielded
lifter so its dispatch index hydrates at startup; without this
every index stays empty and no rule fires in production.
Filtering on the structural :class:`WatchableTagger` protocol
keeps the worker free of per-lifter type knowledge.
"""
for lifter in self._lifters:
if isinstance(lifter, WatchableTagger):
yield lifter
async def tag(self, event: TaggerEvent) -> list[TTPTag]:
lifters = self._by_kind.get(event.source_kind, [])
if not lifters: