minor
#29952
React-View-Schicht: Fenster folgen Commits anderer Threads ohne eigene Konfiguration – ersetzt CommitSource und den Refresh-Teil von AgentViewRefreshService
Kontext
Ein Browserfenster erfährt von einem Commit, der außerhalb seiner eigenen Requests passiert (Hintergrund-Thread, Scheduler, andere Session), erst dann, wenn seine Model-Events synthetisiert werden (ReactWindowRegistry.synthesizeModelEvents). Das geschieht beim nächsten Request des Fensters oder beim SSE-Heartbeat (SSEUpdateQueue, alle 15 s). Ohne Interaktion zeigt ein Fenster eine Hintergrundänderung also bis zu 15 s verspätet an.
Für dieses Problem gibt es derzeit zwei Lösungen:
- CommitSource (com.top_logic.layout.view.channel, eingeführt mit #29947): eine ChannelSource für einen <source-channel>. Sie meldet jeden Commit, der Objekte der konfigurierten Typen anlegt, ändert oder löscht. Der Kanalwert selbst wird praktisch nie gelesen. Wirksam ist der Nebeneffekt: Jede Auslieferung über den ChannelPublisher läuft durch ReactWindowReplay.inWindow() und synthetisiert dabei die Model-Events des Fensters.
- AgentViewRefreshService (tl-chat, com.top_logic.ai.chat.view): ein eigener Dienst. Er hört auf Änderungen des AgentExecutionStore, bündelt sie (refresh-delay-millis, PendingRefreshes) und synthetisiert dann die Model-Events für jedes registrierte Fenster des betroffenen Accounts. Die Fenster registriert ChatViewBinding in einem eigenen schwachen Register. Die Klasse ist im JavaDoc als „TEMPORARY FORK“ markiert.
Beide Lösungen lösen dieselbe allgemeine Lücke an der falschen Stelle:
- Bei CommitSource muss jeder View einen Kanal deklarieren, den niemand liest. Er wiederholt in types die Typen, die seine Tabellen und abgeleiteten Kanäle (observed-types) ohnehin schon beobachten. Außerdem registriert jede angezeigte View-Instanz je Fenster einen eigenen UpdateListener an der KnowledgeBase.
- Bei AgentViewRefreshService hängt das Nachführen an einem fachlichen Dienst und an einem eigenen Fensterregister.
Die eigentliche Anforderung ist allgemein: Ein Fenster, dessen Model-Listener von einem Commit betroffen sind, soll diesen Commit zeitnah verarbeiten, egal welcher Thread ihn gemacht hat. Das gehört in die Fenster- bzw. SSE-Schicht der Engine.
Umsetzung
Engine (`com.top_logic.layout.react`)
- Ein einziger UpdateListener an der KnowledgeBase (nicht einer je Fenster oder View) meldet jeden Commit an die offenen Fenster.
- Jedes Fenster prüft anhand seines ModelScope, ob es Listener hat, die der Commit betrifft. Der ModelScope ist ein DefaultModelScope mit Listenern je Objekt und je Typ. Ein Fenster ohne betroffene Listener tut nichts.
- Ein betroffenes Fenster plant die Synthese seiner Model-Events. Sie läuft gebündelt, höchstens einmal je konfigurierbarem Intervall (z. B. 1 s), auf einem Scheduler-Thread, in der Sub-Session des Fensters und unter seiner Interaction, wie heute ReactWindowReplay.inWindow(). Sie läuft nie auf dem committenden Thread. Ein Commit, der während eines laufenden Intervalls eintrifft, wird nicht verworfen, sondern im nächsten Lauf mitverarbeitet. Das deckt das Szenario „viele Änderungen kurz hintereinander“ aus tl-ai #497 ab.
- Fenster ohne verbundenen Client und Sessions ohne lebenden Benutzer werden übersprungen. synthesizeModelEvents verweigert das für Sessions ohne lebenden Benutzer bereits heute. Beim nächsten Verbindungsaufbau holt das Fenster die Events nach wie bisher.
- Zu klären:
- Wie der zentrale Listener die offenen Fenster aller Sessions erreicht. Heute gibt es nur ReactWindowRegistry.forSession(session); es fehlt ein Register der lebenden Registries oder ein Anknüpfen an den SessionService.
- Wie Listener ohne Typ- oder Objektbezug (addModelListener(listener)) gewertet werden. Wahrscheinlich als „immer betroffen“.
- Damit folgt jeder View, der Objekte oder Typen beobachtet, Hintergrund-Commits innerhalb des Intervalls, ohne zusätzliche Konfiguration.
Abbau
- CommitSource entfällt samt Abschnitt in view-layer/channels.md und Verwendung in tl-factory (projects.view.xml, Kanal commits). Wird #29947 erst nach diesem Ticket gemergt, wird CommitSource dort gar nicht erst eingeführt.
- Der Refresh-Teil von AgentViewRefreshService entfällt: PendingRefreshes, refresh-delay-millis, lock-timeout-millis und das Registrieren der Fenster in ChatViewBinding, soweit es nur dem Refresh dient. TestAgentViewRefresh wird durch einen Engine-Test ersetzt: Ein Commit in einem fremden Thread erreicht die Listener eines Fensters innerhalb des Intervalls, ein nicht betroffenes Fenster synthetisiert nicht.
Schreiben in ein fremdes Fenster (`ChatPresentTool`)
ChatPresentTool setzt über AgentViewRefreshService.hasWindow() / inWindow() (mit write-timeout-millis) einen Kanalwert in einem fremden Fenster und braucht dafür das Fensterregister. Zu klären ist, ob dafür derselbe Engine-Mechanismus taugt:
- Aufzählen der Fenster einer Session (ReactWindowRegistry.windowNames() existiert bereits)
- Ausführen in der Interaction eines Fensters, ggf. auf Basis von ChannelPublisher
Wenn ja, entfällt AgentViewRefreshService ganz. Wenn nicht, bleibt nur dieser Teil als kleinere, entsprechend benannte Klasse in tl-chat übrig.
Test
- Agentenlauf im Chat starten: Die Task-Liste und die übrigen modellgebundenen Elemente folgen dem Lauf ohne Browser-Interaktion innerhalb des Intervalls, auch bei vielen Änderungen kurz hintereinander. Es geht keine Änderung verloren.
- tl-factory-app: Live-Log und Status eines Workspaces folgen der Hintergrundoperation ohne Kanal commits.
- Ein Fenster, das die geänderten Typen nicht beobachtet, löst keine Synthese aus.
- ChatPresentTool funktioniert weiterhin.
Lösung
Engine
- Zentraler Dienst in com.top_logic.layout.react: ein einziger UpdateListener an der KnowledgeBase. Für jeden Commit wird einmal eine Änderungszusammenfassung gebildet (geänderte Objekt-Schlüssel und Typen; Link-Änderungen werden wie im EventBuilder auf die Referenzenden abgebildet, dieselbe Logik wird wiederverwendet). Die offenen Fenster aller Sessions erreicht der Dienst über das vorhandene Register der lebenden `ReactWindowRegistry`s.
- Betroffenheit: DefaultModelScope beantwortet, ob die Zusammenfassung einen seiner Listener betrifft (Objekt-Listener auf einen geänderten Schlüssel, Typ-Listener auf einen geänderten Typ oder einen seiner Obertypen; ein Listener ohne Typ- oder Objektbezug gilt als immer betroffen). Die Prüfung läuft auf dem committenden Thread und liest nur Schlüssel; die Listener-Tabellen des Scopes werden dafür threadsicher.
- Gebündelte Synthese: Ein betroffenes Fenster mit verbundenem Client plant über seine SSEUpdateQueue die Synthese seiner Model-Events auf einem Scheduler-Thread, höchstens einmal je Intervall (Default 500 ms), in seiner Sub-Session und unter seiner Interaction (wie der SSE-Heartbeat). Ein Commit während eines Laufs wird im nächsten Lauf mitverarbeitet. Ist das Fenster gerade beschäftigt (eine andere Interaction läuft), wird der Lauf ein Intervall später wiederholt statt verworfen.
- Schreiben in ein fremdes Fenster: Die Engine findet das Fenster zu einer Sub-Session, zählt die lebenden Fenster auf und führt eine Aktion in einem Fenster mit Zeitlimit aus (Ergebnis: ausgeführt / kein Fenster / beschäftigt).
Abbau
- CommitSource entfällt samt Doku (view-layer/channels.md, factory/index.md) und dem Kanal commits in tl-factory (projects.view.xml).
- AgentViewRefreshService verliert den Refresh-Teil (PendingRefreshes, refresh-delay-millis, lock-timeout-millis) und sein eigenes Fensterregister. Übrig bleibt ein kleiner, umbenannter Dienst in tl-chat, der RunWindow über die Engine-API installiert, gelöschte Chat-Sessions aus den Fenstern ihres Besitzers entfernt und write-timeout-millis für ChatPresentTool hält.
- TestAgentViewRefresh wird durch einen Engine-Test ersetzt.