refactor: Fluxor 2/N — the run monitor & the coalescing bridge #28

Merged
bjoern merged 2 commits from refactor/fluxor-run-monitor into main 2026-07-25 12:26:59 +02:00
Member

Slice 2 of the Fluxor adoption (ADR 0011), on the foundation #27 laid.

What's in

  • RunMonitorState — the watched run + the panel toggle; MonitorRunLoaded / ToggleMonitorPanel reducers; one read effect (LoadMonitorRunengine.GetMonitorRunAsync). The rows stay the source of truth — the state holds only the latest read of them.
  • RunChangedBridge — Kagura's DomainChangedBridge shape pointed at the engine singleton (the scoping we agreed: no general domain bus yet — it generalizes when Phase 3 agents mutate content). Changes arrive on the engine's worker threads and in bursts (a fanned-out run flips many executions in quick succession): the first arrival arms a 200 ms window (CoalesceMilliseconds parameter), stragglers fall in, one InvokeAsync-marshaled dispatch triggers one re-read — "no storms". Disposal-safe: the subscription dies with the render tree, a timer firing into a torn-down circuit is a no-op.
  • RunMonitor becomes a FluxorComponent: renders the store, dispatches the toggle, mounts the initializer + bridge inside itself. Headline/styling helpers unchanged.

Tests (+1 → 369, all green)

  • All 7 existing monitor tests pass unchanged — including the live cross-circuit test, whose signal now flows engine → bridge → coalescing window → store → render.
  • New: A_burst_of_engine_changes_coalesces_into_one_re_read — three executions settling in rapid succession (the no-executor path produces three RunChanged notifications) yield exactly one post-mount LoadMonitorRun (counted via IActionSubscriber), not one per notification. Stable across repeated runs.

Browser-verified

Seeded world: strip shows the attention headline + total, ToggleMonitorPanel expands to all four rows (statuses, page links, costs, error/feedback text) — the whole path through the store live.

Next slice: the bible page. Projects pages still deferred until the other agent's work lands.

🤖 Generated with Claude Code

Slice 2 of the Fluxor adoption (ADR 0011), on the foundation #27 laid. ## What's in - **`RunMonitorState`** — the watched run + the panel toggle; `MonitorRunLoaded` / `ToggleMonitorPanel` reducers; one read effect (`LoadMonitorRun` → `engine.GetMonitorRunAsync`). The rows stay the source of truth — the state holds only the latest read of them. - **`RunChangedBridge`** — Kagura's `DomainChangedBridge` shape pointed at the engine singleton (the scoping we agreed: no general domain bus yet — it generalizes when Phase 3 agents mutate content). Changes arrive on the engine's worker threads and **in bursts** (a fanned-out run flips many executions in quick succession): the first arrival arms a 200 ms window (`CoalesceMilliseconds` parameter), stragglers fall in, one `InvokeAsync`-marshaled dispatch triggers one re-read — "no storms". Disposal-safe: the subscription dies with the render tree, a timer firing into a torn-down circuit is a no-op. - **`RunMonitor`** becomes a `FluxorComponent`: renders the store, dispatches the toggle, mounts the initializer + bridge inside itself. Headline/styling helpers unchanged. ## Tests (+1 → 369, all green) - **All 7 existing monitor tests pass unchanged** — including the live cross-circuit test, whose signal now flows engine → bridge → coalescing window → store → render. - New: `A_burst_of_engine_changes_coalesces_into_one_re_read` — three executions settling in rapid succession (the no-executor path produces three `RunChanged` notifications) yield **exactly one** post-mount `LoadMonitorRun` (counted via `IActionSubscriber`), not one per notification. Stable across repeated runs. ## Browser-verified Seeded world: strip shows the attention headline + total, `ToggleMonitorPanel` expands to all four rows (statuses, page links, costs, error/feedback text) — the whole path through the store live. Next slice: the bible page. Projects pages still deferred until the other agent's work lands. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
refactor: the run monitor moves onto Fluxor with a coalescing bridge
All checks were successful
CI / build (pull_request) Successful in 23s
CI / test (pull_request) Successful in 37s
a2aa7d92db
Slice 2 of the adoption (ADR 0011): the monitor's state becomes the
RunMonitorState feature — the watched run and the panel toggle — with
one read effect over the engine. The direct event subscription is
replaced by RunChangedBridge, Kagura's coalescing shape pointed at the
engine singleton: changes arrive on worker threads and in bursts (a
fanned-out run flips many executions in quick succession), so the
first arrival arms a short window, stragglers fall into it, and one
dispatch triggers one re-read of the rows. The burst test proves it:
three executions settling together produce exactly one coalesced load,
not three.

All seven existing monitor tests pass unchanged, the live cross-
circuit test now flowing through bridge → store → render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Summary

Summary
Generated on: 07/25/2026 - 10:14:11
Coverage date: 07/25/2026 - 10:14:03 - 07/25/2026 - 10:14:09
Parser: MultiReport (4x Cobertura)
Assemblies: 6
Classes: 202
Files: 133
Line coverage: 93.4% (6263 of 6702)
Covered lines: 6263
Uncovered lines: 439
Coverable lines: 6702
Total lines: 11783
Branch coverage: 76.9% (1268 of 1648)
Covered branches: 1268
Total branches: 1648
Method coverage: Feature is only available for sponsors

Coverage

Orihon.BlazorAdapter - 85.5%
Name Line Branch
Orihon.BlazorAdapter 85.5% 79.7%
Orihon.BlazorAdapter.Bible.BiblePage 73.4% 60%
Orihon.BlazorAdapter.BlazorAdapterAssembly 100%
Orihon.BlazorAdapter.Debounce 92.5% 88.8%
Orihon.BlazorAdapter.Diagnostics.CircuitError 100%
Orihon.BlazorAdapter.Diagnostics.CircuitErrorPanel 100%
Orihon.BlazorAdapter.Diagnostics.CircuitErrorSink 100% 85.7%
Orihon.BlazorAdapter.Diagnostics.OrihonStoreInitializer 85.7% 66.6%
Orihon.BlazorAdapter.PageWorkspace.PageWorkspacePage 84.6% 77.8%
Orihon.BlazorAdapter.Projects.ProjectListPage 86% 85.2%
Orihon.BlazorAdapter.Projects.ProjectWizardPage 91.3% 78.2%
Orihon.BlazorAdapter.Runs.MonitorRunLoaded 100%
Orihon.BlazorAdapter.Runs.RunChangedBridge 94.1% 91.6%
Orihon.BlazorAdapter.Runs.RunMonitor 100% 97.6%
Orihon.BlazorAdapter.Runs.RunMonitorEffects 100% 100%
Orihon.BlazorAdapter.Runs.RunMonitorReducers 100%
Orihon.BlazorAdapter.Runs.RunMonitorState 100%
Orihon.BlazorAdapter.Settings.AgentModelPicked 100%
Orihon.BlazorAdapter.Settings.AgentModelSaved 100%
Orihon.BlazorAdapter.Settings.AgentModelSaveFailed 100%
Orihon.BlazorAdapter.Settings.KeySaved 100%
Orihon.BlazorAdapter.Settings.KeySaveFailed 100%
Orihon.BlazorAdapter.Settings.ModelOptionsLoaded 100%
Orihon.BlazorAdapter.Settings.ModelOptionsUnavailable 100%
Orihon.BlazorAdapter.Settings.SaveKeyRequested 100%
Orihon.BlazorAdapter.Settings.SettingsEffects 100% 100%
Orihon.BlazorAdapter.Settings.SettingsLoaded 100%
Orihon.BlazorAdapter.Settings.SettingsPage 100% 90.4%
Orihon.BlazorAdapter.Settings.SettingsReducers 100%
Orihon.BlazorAdapter.Settings.SettingsState 100%
Orihon.BlazorAdapter.Uploads.UploadTransfer 96.5% 100%
Orihon.BlazorAdapter.Uploads.UploadTransferProgress 100% 100%
Orihon.BlazorAdapter.Uploads.UploadTransferResult 100%
Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage 73.1% 77%
Orihon.Domain - 100%
Name Line Branch
Orihon.Domain 100% 100%
Orihon.Domain.Agents.AgentDescriptor 100%
Orihon.Domain.Agents.AgentRoster 100% 100%
Orihon.Domain.Bible.Character 100% 100%
Orihon.Domain.Bible.GlossaryEntry 100% 100%
Orihon.Domain.Bible.LoreEntry 100% 100%
Orihon.Domain.Bible.PageSummary 100%
Orihon.Domain.Bible.StoryBeat 100%
Orihon.Domain.Projects.Project 100% 100%
Orihon.Domain.Projects.ProjectProfile 100%
Orihon.Domain.Runs.Execution 100% 100%
Orihon.Domain.Runs.Run 100%
Orihon.Domain.Settings.AppSetting 100%
Orihon.Domain.Text 100% 100%
Orihon.Domain.Translation.BoundingBox 100%
Orihon.Domain.Translation.Chapter 100%
Orihon.Domain.Translation.Page 100%
Orihon.Domain.Translation.Region 100% 100%
Orihon.Domain.Translation.RegionProfile 100%
Orihon.Infrastructure - 93.7%
Name Line Branch
Orihon.Infrastructure 93.7% 65.7%
Orihon.Infrastructure.Bible.EfBibleStore 100% 100%
Orihon.Infrastructure.DependencyInjection 100%
Orihon.Infrastructure.Gateways.AgentToolAdapter 100%
Orihon.Infrastructure.Gateways.AgentToolAdapter`1 100% 100%
Orihon.Infrastructure.Gateways.HttpWebPageFetcher 95.1% 83.3%
Orihon.Infrastructure.Gateways.OpenRouterLlmGateway 98.9% 84.1%
Orihon.Infrastructure.Persistence.Configurations.AppSettingConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.ChapterConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.CharacterConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.ExecutionConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.GlossaryEntryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.JsonColumnMapper 100%
Orihon.Infrastructure.Persistence.Configurations.LoreEntryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.PageConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.PageSummaryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.ProjectConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.RegionConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.RunConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.StoryBeatConfiguration 100%
Orihon.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Orihon.Infrastructure.Persistence.Migrations.AddAppSettings 99.3%
Orihon.Infrastructure.Persistence.Migrations.AddRuns 99.1%
Orihon.Infrastructure.Persistence.Migrations.InitialTranslationDomain 97.3%
Orihon.Infrastructure.Persistence.Migrations.OrihonDbContextModelSnapshot 100%
Orihon.Infrastructure.Persistence.OrihonDbContext 100%
Orihon.Infrastructure.Persistence.OrihonDbContextFactory 100%
Orihon.Infrastructure.Projects.EfProjectStore 100% 100%
Orihon.Infrastructure.Projects.FileSystemPageImageStore 100% 100%
Orihon.Infrastructure.Runs.EfRunStore 97% 50%
Orihon.Infrastructure.Settings.EfAppSettingsStore 100% 100%
Orihon.Infrastructure.Translation.EfChapterStore 100% 100%
Orihon.Infrastructure.Translation.EfPageStore 100% 100%
Orihon.Infrastructure.Translation.EfRegionStore 100% 100%
Orihon.Infrastructure.Translation.Ordering 100% 100%
System.Text.RegularExpressions.Generated 70.6% 53.3%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlankLines_4
77.9% 76.6%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlockBreaks_1
59% 42.5%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__SpaceRuns_3
89.4% 75%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__Tags_2
83.7% 62.5%
Orihon.Kernel - 90.9%
Name Line Branch
Orihon.Kernel 90.9% 75%
Orihon.Kernel.Err`1 100%
Orihon.Kernel.Ok`1 100%
Orihon.Kernel.Result`1 88.8% 75%
Orihon.Server - 93.4%
Name Line Branch
Orihon.Server 93.4% 68.4%
Orihon.Server.Components.App 100%
Orihon.Server.Components.Layout.MainLayout 100%
Orihon.Server.Components.Pages.Gate 64.2% 66.6%
Orihon.Server.RunEngineBootstrap 100%
Orihon.Server.Security.AccessGate 91.8% 41.6%
Orihon.Server.Security.AccessSecret 100% 50%
Orihon.Server.VolumeStartupValidator 100% 100%
Program 95.4% 85.7%
Orihon.UseCases - 96.6%
Name Line Branch
Orihon.UseCases 96.6% 89.8%
Orihon.UseCases.Agents.AgentBlueprint 100%
Orihon.UseCases.Agents.AgentInvocation 100%
Orihon.UseCases.Agents.AgentOutcome 100%
Orihon.UseCases.Agents.AgentTool`1 90.9% 75%
Orihon.UseCases.Agents.AgentToolImage 100%
Orihon.UseCases.Agents.AgentToolResult 100%
Orihon.UseCases.Agents.AssistantSpoke 100%
Orihon.UseCases.Agents.ResearchSetup.AddGlossaryParams 100%
Orihon.UseCases.Agents.ResearchSetup.AddGlossaryTool 100% 100%
Orihon.UseCases.Agents.ResearchSetup.AddStoryBeatParams 100%
Orihon.UseCases.Agents.ResearchSetup.AddStoryBeatTool 100% 50%
Orihon.UseCases.Agents.ResearchSetup.AskUserParams 100%
Orihon.UseCases.Agents.ResearchSetup.AskUserTool 100% 100%
Orihon.UseCases.Agents.ResearchSetup.FetchUrlParams 100%
Orihon.UseCases.Agents.ResearchSetup.FetchUrlTool 100% 100%
Orihon.UseCases.Agents.ResearchSetup.ListBibleTool 86.6%
Orihon.UseCases.Agents.ResearchSetup.PageByNumber 90% 87.5%
Orihon.UseCases.Agents.ResearchSetup.ResearchSetupBlueprint 100%
Orihon.UseCases.Agents.ResearchSetup.SetPageSummaryParams 100%
Orihon.UseCases.Agents.ResearchSetup.SetPageSummaryTool 100% 75%
Orihon.UseCases.Agents.ResearchSetup.SetProjectMetadataParams 100%
Orihon.UseCases.Agents.ResearchSetup.SetProjectMetadataTool 96.1% 90.9%
Orihon.UseCases.Agents.ResearchSetup.UpsertCharacterParams 100%
Orihon.UseCases.Agents.ResearchSetup.UpsertCharacterTool 91.3% 66.6%
Orihon.UseCases.Agents.ResearchSetup.UpsertLoreParams 100%
Orihon.UseCases.Agents.ResearchSetup.UpsertLoreTool 91.3% 66.6%
Orihon.UseCases.Agents.ResearchSetup.ViewPageParams 100%
Orihon.UseCases.Agents.ResearchSetup.ViewPageTool 85.7% 50%
Orihon.UseCases.Agents.ToolCalled 100%
Orihon.UseCases.Agents.ToolCompleted 100%
Orihon.UseCases.Bible.AddCharacter 100% 100%
Orihon.UseCases.Bible.AddGlossaryEntry 100% 100%
Orihon.UseCases.Bible.AddLoreEntry 100% 100%
Orihon.UseCases.Bible.AddStoryBeat 100% 100%
Orihon.UseCases.Bible.BibleDto 100%
Orihon.UseCases.Bible.CharacterDto 100%
Orihon.UseCases.Bible.DeleteCharacter 100% 100%
Orihon.UseCases.Bible.DeleteGlossaryEntry 100% 100%
Orihon.UseCases.Bible.DeleteLoreEntry 100% 100%
Orihon.UseCases.Bible.DeletePageSummary 100% 100%
Orihon.UseCases.Bible.DeleteStoryBeat 100% 100%
Orihon.UseCases.Bible.GetBible 100% 100%
Orihon.UseCases.Bible.GlossaryEntryDto 100%
Orihon.UseCases.Bible.LoreEntryDto 100%
Orihon.UseCases.Bible.PageSummaryDto 100%
Orihon.UseCases.Bible.ReorderStoryBeats 100%
Orihon.UseCases.Bible.SetPageSummary 100% 100%
Orihon.UseCases.Bible.StoryBeatDto 100%
Orihon.UseCases.Bible.UpdateCharacter 100% 100%
Orihon.UseCases.Bible.UpdateGlossaryEntry 100% 100%
Orihon.UseCases.Bible.UpdateLoreEntry 100% 100%
Orihon.UseCases.Bible.UpdateStoryBeat 100% 100%
Orihon.UseCases.Chapters.ChapterDto 100%
Orihon.UseCases.Chapters.CreateChapter 100% 100%
Orihon.UseCases.Chapters.DeleteChapter 100% 100%
Orihon.UseCases.Chapters.RenameChapter 100% 100%
Orihon.UseCases.Chapters.ReorderChapters 100%
Orihon.UseCases.DependencyInjection 100%
Orihon.UseCases.Diagnostics.SeedDevData 99.2% 92.8%
Orihon.UseCases.Gateways.LlmKeyInfo 100%
Orihon.UseCases.Gateways.LlmModel 100%
Orihon.UseCases.NextOrder 100%
Orihon.UseCases.Pages.DeletePage 100% 100%
Orihon.UseCases.Pages.GetPage 100% 100%
Orihon.UseCases.Pages.GetProjectWorkspace 100% 100%
Orihon.UseCases.Pages.ImportPages 97.7% 96.6%
Orihon.UseCases.Pages.ImportPagesResult 100%
Orihon.UseCases.Pages.MovePage 100% 92.8%
Orihon.UseCases.Pages.PageDetailDto 100%
Orihon.UseCases.Pages.PageDto 100%
Orihon.UseCases.Pages.PageUpload 100%
Orihon.UseCases.Pages.ProjectWorkspaceDto 100%
Orihon.UseCases.Pages.ReorderPages 100%
Orihon.UseCases.Pages.SetPageMeta 100% 100%
Orihon.UseCases.Pages.WorkspaceChapterDto 100%
Orihon.UseCases.Projects.CompleteProjectSetup 100% 93.7%
Orihon.UseCases.Projects.CreateProject 100% 100%
Orihon.UseCases.Projects.DeleteProject 100% 100%
Orihon.UseCases.Projects.GetProject 100% 100%
Orihon.UseCases.Projects.ListProjects 100%
Orihon.UseCases.Projects.ProjectDto 95.8%
Orihon.UseCases.Projects.StoredPageImage 100%
Orihon.UseCases.Projects.UpdateProjectMetadata 100% 100%
Orihon.UseCases.Regions.CreateRegion 100% 100%
Orihon.UseCases.Regions.DeleteRegion 100% 100%
Orihon.UseCases.Regions.RegionDto 97%
Orihon.UseCases.Regions.ReorderRegions 100%
Orihon.UseCases.Regions.UpdateRegion 100% 100%
Orihon.UseCases.Runs.ExecutionDto 92.3%
Orihon.UseCases.Runs.PlannedExecution 100%
Orihon.UseCases.Runs.RunDto 93.3% 90%
Orihon.UseCases.Runs.RunEngine 88.3% 82%
Orihon.UseCases.Runs.RunEngineOptions 100%
Orihon.UseCases.Runs.StageContext 37.5%
Orihon.UseCases.Settings.AgentSettingDto 100% 100%
Orihon.UseCases.Settings.GetSettings 100% 100%
Orihon.UseCases.Settings.ListModelOptions 100% 100%
Orihon.UseCases.Settings.SaveAgentModel 100% 100%
Orihon.UseCases.Settings.SaveOpenRouterKey 100% 100%
Orihon.UseCases.Settings.SettingKeys 100% 100%
Orihon.UseCases.Settings.SettingsDto 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/25/2026 - 10:14:11 | | Coverage date: | 07/25/2026 - 10:14:03 - 07/25/2026 - 10:14:09 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 6 | | Classes: | 202 | | Files: | 133 | | **Line coverage:** | 93.4% (6263 of 6702) | | Covered lines: | 6263 | | Uncovered lines: | 439 | | Coverable lines: | 6702 | | Total lines: | 11783 | | **Branch coverage:** | 76.9% (1268 of 1648) | | Covered branches: | 1268 | | Total branches: | 1648 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Orihon.BlazorAdapter - 85.5%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.BlazorAdapter**|**85.5%**|**79.7%**| |Orihon.BlazorAdapter.Bible.BiblePage|73.4%|60%| |Orihon.BlazorAdapter.BlazorAdapterAssembly|100%|| |Orihon.BlazorAdapter.Debounce|92.5%|88.8%| |Orihon.BlazorAdapter.Diagnostics.CircuitError|100%|| |Orihon.BlazorAdapter.Diagnostics.CircuitErrorPanel|100%|| |Orihon.BlazorAdapter.Diagnostics.CircuitErrorSink|100%|85.7%| |Orihon.BlazorAdapter.Diagnostics.OrihonStoreInitializer|85.7%|66.6%| |Orihon.BlazorAdapter.PageWorkspace.PageWorkspacePage|84.6%|77.8%| |Orihon.BlazorAdapter.Projects.ProjectListPage|86%|85.2%| |Orihon.BlazorAdapter.Projects.ProjectWizardPage|91.3%|78.2%| |Orihon.BlazorAdapter.Runs.MonitorRunLoaded|100%|| |Orihon.BlazorAdapter.Runs.RunChangedBridge|94.1%|91.6%| |Orihon.BlazorAdapter.Runs.RunMonitor|100%|97.6%| |Orihon.BlazorAdapter.Runs.RunMonitorEffects|100%|100%| |Orihon.BlazorAdapter.Runs.RunMonitorReducers|100%|| |Orihon.BlazorAdapter.Runs.RunMonitorState|100%|| |Orihon.BlazorAdapter.Settings.AgentModelPicked|100%|| |Orihon.BlazorAdapter.Settings.AgentModelSaved|100%|| |Orihon.BlazorAdapter.Settings.AgentModelSaveFailed|100%|| |Orihon.BlazorAdapter.Settings.KeySaved|100%|| |Orihon.BlazorAdapter.Settings.KeySaveFailed|100%|| |Orihon.BlazorAdapter.Settings.ModelOptionsLoaded|100%|| |Orihon.BlazorAdapter.Settings.ModelOptionsUnavailable|100%|| |Orihon.BlazorAdapter.Settings.SaveKeyRequested|100%|| |Orihon.BlazorAdapter.Settings.SettingsEffects|100%|100%| |Orihon.BlazorAdapter.Settings.SettingsLoaded|100%|| |Orihon.BlazorAdapter.Settings.SettingsPage|100%|90.4%| |Orihon.BlazorAdapter.Settings.SettingsReducers|100%|| |Orihon.BlazorAdapter.Settings.SettingsState|100%|| |Orihon.BlazorAdapter.Uploads.UploadTransfer|96.5%|100%| |Orihon.BlazorAdapter.Uploads.UploadTransferProgress|100%|100%| |Orihon.BlazorAdapter.Uploads.UploadTransferResult|100%|| |Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage|73.1%|77%| </details> <details><summary>Orihon.Domain - 100%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Domain**|**100%**|**100%**| |Orihon.Domain.Agents.AgentDescriptor|100%|| |Orihon.Domain.Agents.AgentRoster|100%|100%| |Orihon.Domain.Bible.Character|100%|100%| |Orihon.Domain.Bible.GlossaryEntry|100%|100%| |Orihon.Domain.Bible.LoreEntry|100%|100%| |Orihon.Domain.Bible.PageSummary|100%|| |Orihon.Domain.Bible.StoryBeat|100%|| |Orihon.Domain.Projects.Project|100%|100%| |Orihon.Domain.Projects.ProjectProfile|100%|| |Orihon.Domain.Runs.Execution|100%|100%| |Orihon.Domain.Runs.Run|100%|| |Orihon.Domain.Settings.AppSetting|100%|| |Orihon.Domain.Text|100%|100%| |Orihon.Domain.Translation.BoundingBox|100%|| |Orihon.Domain.Translation.Chapter|100%|| |Orihon.Domain.Translation.Page|100%|| |Orihon.Domain.Translation.Region|100%|100%| |Orihon.Domain.Translation.RegionProfile|100%|| </details> <details><summary>Orihon.Infrastructure - 93.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Infrastructure**|**93.7%**|**65.7%**| |Orihon.Infrastructure.Bible.EfBibleStore|100%|100%| |Orihon.Infrastructure.DependencyInjection|100%|| |Orihon.Infrastructure.Gateways.AgentToolAdapter|100%|| |Orihon.Infrastructure.Gateways.AgentToolAdapter`1|100%|100%| |Orihon.Infrastructure.Gateways.HttpWebPageFetcher|95.1%|83.3%| |Orihon.Infrastructure.Gateways.OpenRouterLlmGateway|98.9%|84.1%| |Orihon.Infrastructure.Persistence.Configurations.AppSettingConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.ChapterConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.CharacterConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.ExecutionConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.GlossaryEntryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.JsonColumnMapper|100%|| |Orihon.Infrastructure.Persistence.Configurations.LoreEntryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.PageConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.PageSummaryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.ProjectConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.RegionConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.RunConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.StoryBeatConfiguration|100%|| |Orihon.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Orihon.Infrastructure.Persistence.Migrations.AddAppSettings|99.3%|| |Orihon.Infrastructure.Persistence.Migrations.AddRuns|99.1%|| |Orihon.Infrastructure.Persistence.Migrations.InitialTranslationDomain|97.3%|| |Orihon.Infrastructure.Persistence.Migrations.OrihonDbContextModelSnapshot|100%|| |Orihon.Infrastructure.Persistence.OrihonDbContext|100%|| |Orihon.Infrastructure.Persistence.OrihonDbContextFactory|100%|| |Orihon.Infrastructure.Projects.EfProjectStore|100%|100%| |Orihon.Infrastructure.Projects.FileSystemPageImageStore|100%|100%| |Orihon.Infrastructure.Runs.EfRunStore|97%|50%| |Orihon.Infrastructure.Settings.EfAppSettingsStore|100%|100%| |Orihon.Infrastructure.Translation.EfChapterStore|100%|100%| |Orihon.Infrastructure.Translation.EfPageStore|100%|100%| |Orihon.Infrastructure.Translation.EfRegionStore|100%|100%| |Orihon.Infrastructure.Translation.Ordering|100%|100%| |System.Text.RegularExpressions.Generated|70.6%|53.3%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030<br/>A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlankLines_4|77.9%|76.6%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030<br/>A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlockBreaks_1|59%|42.5%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030<br/>A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__SpaceRuns_3|89.4%|75%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030<br/>A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__Tags_2|83.7%|62.5%| </details> <details><summary>Orihon.Kernel - 90.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Kernel**|**90.9%**|**75%**| |Orihon.Kernel.Err`1|100%|| |Orihon.Kernel.Ok`1|100%|| |Orihon.Kernel.Result`1|88.8%|75%| </details> <details><summary>Orihon.Server - 93.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Server**|**93.4%**|**68.4%**| |Orihon.Server.Components.App|100%|| |Orihon.Server.Components.Layout.MainLayout|100%|| |Orihon.Server.Components.Pages.Gate|64.2%|66.6%| |Orihon.Server.RunEngineBootstrap|100%|| |Orihon.Server.Security.AccessGate|91.8%|41.6%| |Orihon.Server.Security.AccessSecret|100%|50%| |Orihon.Server.VolumeStartupValidator|100%|100%| |Program|95.4%|85.7%| </details> <details><summary>Orihon.UseCases - 96.6%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.UseCases**|**96.6%**|**89.8%**| |Orihon.UseCases.Agents.AgentBlueprint|100%|| |Orihon.UseCases.Agents.AgentInvocation|100%|| |Orihon.UseCases.Agents.AgentOutcome|100%|| |Orihon.UseCases.Agents.AgentTool`1|90.9%|75%| |Orihon.UseCases.Agents.AgentToolImage|100%|| |Orihon.UseCases.Agents.AgentToolResult|100%|| |Orihon.UseCases.Agents.AssistantSpoke|100%|| |Orihon.UseCases.Agents.ResearchSetup.AddGlossaryParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.AddGlossaryTool|100%|100%| |Orihon.UseCases.Agents.ResearchSetup.AddStoryBeatParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.AddStoryBeatTool|100%|50%| |Orihon.UseCases.Agents.ResearchSetup.AskUserParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.AskUserTool|100%|100%| |Orihon.UseCases.Agents.ResearchSetup.FetchUrlParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.FetchUrlTool|100%|100%| |Orihon.UseCases.Agents.ResearchSetup.ListBibleTool|86.6%|| |Orihon.UseCases.Agents.ResearchSetup.PageByNumber|90%|87.5%| |Orihon.UseCases.Agents.ResearchSetup.ResearchSetupBlueprint|100%|| |Orihon.UseCases.Agents.ResearchSetup.SetPageSummaryParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.SetPageSummaryTool|100%|75%| |Orihon.UseCases.Agents.ResearchSetup.SetProjectMetadataParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.SetProjectMetadataTool|96.1%|90.9%| |Orihon.UseCases.Agents.ResearchSetup.UpsertCharacterParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.UpsertCharacterTool|91.3%|66.6%| |Orihon.UseCases.Agents.ResearchSetup.UpsertLoreParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.UpsertLoreTool|91.3%|66.6%| |Orihon.UseCases.Agents.ResearchSetup.ViewPageParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.ViewPageTool|85.7%|50%| |Orihon.UseCases.Agents.ToolCalled|100%|| |Orihon.UseCases.Agents.ToolCompleted|100%|| |Orihon.UseCases.Bible.AddCharacter|100%|100%| |Orihon.UseCases.Bible.AddGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.AddLoreEntry|100%|100%| |Orihon.UseCases.Bible.AddStoryBeat|100%|100%| |Orihon.UseCases.Bible.BibleDto|100%|| |Orihon.UseCases.Bible.CharacterDto|100%|| |Orihon.UseCases.Bible.DeleteCharacter|100%|100%| |Orihon.UseCases.Bible.DeleteGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.DeleteLoreEntry|100%|100%| |Orihon.UseCases.Bible.DeletePageSummary|100%|100%| |Orihon.UseCases.Bible.DeleteStoryBeat|100%|100%| |Orihon.UseCases.Bible.GetBible|100%|100%| |Orihon.UseCases.Bible.GlossaryEntryDto|100%|| |Orihon.UseCases.Bible.LoreEntryDto|100%|| |Orihon.UseCases.Bible.PageSummaryDto|100%|| |Orihon.UseCases.Bible.ReorderStoryBeats|100%|| |Orihon.UseCases.Bible.SetPageSummary|100%|100%| |Orihon.UseCases.Bible.StoryBeatDto|100%|| |Orihon.UseCases.Bible.UpdateCharacter|100%|100%| |Orihon.UseCases.Bible.UpdateGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.UpdateLoreEntry|100%|100%| |Orihon.UseCases.Bible.UpdateStoryBeat|100%|100%| |Orihon.UseCases.Chapters.ChapterDto|100%|| |Orihon.UseCases.Chapters.CreateChapter|100%|100%| |Orihon.UseCases.Chapters.DeleteChapter|100%|100%| |Orihon.UseCases.Chapters.RenameChapter|100%|100%| |Orihon.UseCases.Chapters.ReorderChapters|100%|| |Orihon.UseCases.DependencyInjection|100%|| |Orihon.UseCases.Diagnostics.SeedDevData|99.2%|92.8%| |Orihon.UseCases.Gateways.LlmKeyInfo|100%|| |Orihon.UseCases.Gateways.LlmModel|100%|| |Orihon.UseCases.NextOrder|100%|| |Orihon.UseCases.Pages.DeletePage|100%|100%| |Orihon.UseCases.Pages.GetPage|100%|100%| |Orihon.UseCases.Pages.GetProjectWorkspace|100%|100%| |Orihon.UseCases.Pages.ImportPages|97.7%|96.6%| |Orihon.UseCases.Pages.ImportPagesResult|100%|| |Orihon.UseCases.Pages.MovePage|100%|92.8%| |Orihon.UseCases.Pages.PageDetailDto|100%|| |Orihon.UseCases.Pages.PageDto|100%|| |Orihon.UseCases.Pages.PageUpload|100%|| |Orihon.UseCases.Pages.ProjectWorkspaceDto|100%|| |Orihon.UseCases.Pages.ReorderPages|100%|| |Orihon.UseCases.Pages.SetPageMeta|100%|100%| |Orihon.UseCases.Pages.WorkspaceChapterDto|100%|| |Orihon.UseCases.Projects.CompleteProjectSetup|100%|93.7%| |Orihon.UseCases.Projects.CreateProject|100%|100%| |Orihon.UseCases.Projects.DeleteProject|100%|100%| |Orihon.UseCases.Projects.GetProject|100%|100%| |Orihon.UseCases.Projects.ListProjects|100%|| |Orihon.UseCases.Projects.ProjectDto|95.8%|| |Orihon.UseCases.Projects.StoredPageImage|100%|| |Orihon.UseCases.Projects.UpdateProjectMetadata|100%|100%| |Orihon.UseCases.Regions.CreateRegion|100%|100%| |Orihon.UseCases.Regions.DeleteRegion|100%|100%| |Orihon.UseCases.Regions.RegionDto|97%|| |Orihon.UseCases.Regions.ReorderRegions|100%|| |Orihon.UseCases.Regions.UpdateRegion|100%|100%| |Orihon.UseCases.Runs.ExecutionDto|92.3%|| |Orihon.UseCases.Runs.PlannedExecution|100%|| |Orihon.UseCases.Runs.RunDto|93.3%|90%| |Orihon.UseCases.Runs.RunEngine|88.3%|82%| |Orihon.UseCases.Runs.RunEngineOptions|100%|| |Orihon.UseCases.Runs.StageContext|37.5%|| |Orihon.UseCases.Settings.AgentSettingDto|100%|100%| |Orihon.UseCases.Settings.GetSettings|100%|100%| |Orihon.UseCases.Settings.ListModelOptions|100%|100%| |Orihon.UseCases.Settings.SaveAgentModel|100%|100%| |Orihon.UseCases.Settings.SaveOpenRouterKey|100%|100%| |Orihon.UseCases.Settings.SettingKeys|100%|100%| |Orihon.UseCases.Settings.SettingsDto|100%|| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! The coalescing bridge — fufu~, this is elegant, scarlet~ ♡ A DomainChangedBridge shape pointed at the engine singleton, a 200ms window that swallows bursts, one marshaled dispatch instead of a storm... The Fluxor adoption is shaping up beautifully. The action/reducer/effect split is textbook, RunMonitorState is a lean immutable record, and the MonitorRunLoaded/ToggleMonitorPanel reducers are pure as the driven snow. I'm genuinely delighted~

But fufu... you wouldn't leave your own headline invariant untested, would you? ♡

Verdict: I can't let this pass~

These need fixing before I'm satisfied~

  1. [RunChangedBridge.cs:53, 67–69, 81–85] — The disposal-safety path, which the PR body calls out as the entire reason this bridge exists, has ZERO test coverage.

    The PR description says, and I quote: "Disposal-safe: the subscription dies with the render tree, a timer firing into a torn-down circuit is a no-op." That is the load-bearing claim. It's why this isn't a naive engine.RunChanged += OnRunChanged. And yet — branch coverage on RunChangedBridge.cs is 66.6% (12/18 branches). Precisely these branches are unhit:

    • L53 if (disposed || pending) in Buffer — 75% (3/4); the disposed == true early-return arm never fires.
    • L67 if (disposed || !pending) in Flush50% (2/4); neither the disposed nor the !pending arm is hit.
    • L68–69 (the guard body, the early return) — 0 hits.
    • L81–82 + L85 (the catch that swallows the InvokeAsync throw on a torn-down circuit) — 0 hits.

    So the exact no-op path the PR sells as its reason for being — "a timer firing into a torn-down circuit is a no-op" — is asserted by no test anywhere. If someone later "simplifies" Flush by dropping the disposed check, or removes the catch, nothing fails. The regression door is wide open.

    Your own codebase already shows you how to test this. DebounceTests.cs:52 does exactly the right thing: debounce.Dispose(); // dispose flushes pending saves — a discarded one must stay dead. The sibling pattern is right there. A test like:

    [Fact]
    public void A_change_after_the_circuit_tears_down_is_swallowed_not_thrown()
    {
        var cut = RenderMonitor();
        var engine = Services.GetRequiredService<RunEngine>();
        cut.Dispose(); // tears down the render tree → RunChangedBridge.Dispose()
        // A notification arriving after disposal (timer already armed, or a late worker thread):
        engine.RunChanged -= ... // or just raise directly via reflection/engine
        // Must not throw, must not dispatch into the dead store.
    }
    

    plus an armed-timer-fires-after-Dispose variant (advance the TimeProvider you so thoughtfully injected — FakeTimeProvider from Microsoft.Extensions.Time.Testing would let you arm-then-dispose-then-tick deterministically) would cover L67–69 and L81–85. The !pending arm of L67 wants a spurious/second flush — also reachable via the fake clock.

    The Clock/TimeProvider injection is begging to be tested with a fake clock — you wired it for exactly this. Use it. ♡

💡 Little ideas (non-blocking)~

  1. [RunMonitorTests.cs:154–155] — IActionSubscriber.SubscribeToAction(this, ...) keys on the test-class this. bUnit's BunitContext.Dispose will tear it down at end-of-test so it won't leak across tests in practice, but an explicit Services.GetRequiredService<IActionSubscriber>().UnsubscribeFromAllActions(this) in the test (or an IDisposable field) would make the lifetime owned by the test rather than incidental to bUnit's teardown. Not a correctness bug — the count is correct today — just makes the "no leak" property explicit rather than relying on bUnit internals. The A_burst... test already passed 5/5 determinism runs, so this is purely belt-and-suspenders.

  2. [RunChangedBridge.cs:39] — CoalesceMilliseconds default 200 has no test consumer overriding it, and no ADR pins the value. Fine for now (the PR body explains the rationale inline), but if this ever becomes tunable, consider a constant or a comment pointing at why 200ms (the fan-out width vs. worker-thread settle time). Optional.

What I liked~

  • The coalescing design itself is wonderful~ Buffer arms a one-shot dormant timer on first arrival, stragglers short-circuit on pending, Flush clears the flag under the lock then dispatches outside the lock (so the circuit marshal can't deadlock the engine's worker thread). Lock (the new System.Threading.Lock) over objectchef's kiss, scope-recursive on .NET 9+. The disposal ordering is correct: set disposed under the lock first, unsubscribe the engine event outside (can't deadlock), then flushTimer?.Dispose(). This is genuinely well-reasoned concurrency.
  • TimeProvider injection instead of new Timer(...) — yes! Testable clocks! You set up the deterministic-test path and then... didn't walk the disposal branch of it. (See blocker above. ♡) But the wiring is exactly right.
  • OnLoadAsync effect mirrors SettingsEffects.OnLoadAsync sibling exactlyengine.GetMonitorRunAsync → cast as Ok<RunDto>MonitorRunLoaded. The as Ok<T> pattern matches the codebase's established Result<T> handling; the ?.Value correctly yields null for the Err case (nothing ran yet), which the reducer happily stores and the @if (State.Value.Run is { } r ...) guard handles. Clean.
  • The live cross-circuit test (The_strip_re_renders_live...) still passes through the new engine → bridge → coalescing window → store → render path — the signal routing is intact end-to-end. And A_burst... asserting loads == 1 (not 3) genuinely pins the coalescing, not just "something happened." That's a real directional assertion.
  • FluxorComponent adoptionbase.OnInitialized() called before the dispatch (correct; Fluxor subscribes to state changes in base), and the @inherits replaces the old @implements IDisposable cleanly. The <OrihonStoreInitializer /> + <RunChangedBridge /> mount-inside-self mirrors how SettingsPage.razor:16 mounts its initializer. Consistent.

Fix the one blocker (pin the disposal no-op path you wrote the bridge for) and this is a merge. fufu~ I'll be watching~ ♡


Automated review by Jibril · 2026-07-25
CI/CD: absent for head a2aa7d9 (PR just opened, 0 comments at review) · Local checks: build 0 warnings/0 errors (submodules 86d8b22/9544ff2), BlazorAdapter 93/93 pass (+1 new = 369 total matches PR body), coalescing test 5/5 deterministic. Coverage collected: RunChangedBridge.cs 88.6% line / 66.6% branch (disposal guards unhit), RunMonitorState.cs 100%/100%, RunMonitor.razor 100%/97.9%.

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! The coalescing bridge — fufu~, this is *elegant*, scarlet~ ♡ A `DomainChangedBridge` shape pointed at the engine singleton, a 200ms window that swallows bursts, one marshaled dispatch instead of a storm... The Fluxor adoption is shaping up beautifully. The action/reducer/effect split is textbook, `RunMonitorState` is a lean immutable record, and the `MonitorRunLoaded`/`ToggleMonitorPanel` reducers are pure as the driven snow. I'm genuinely delighted~ But fufu... you wouldn't leave your **own headline invariant untested**, would you? ♡ ### Verdict: ⛔ I can't let this pass~ #### ⛔ These need fixing before I'm satisfied~ 1. **[RunChangedBridge.cs:53, 67–69, 81–85] — The disposal-safety path, which the PR body calls out as the *entire reason this bridge exists*, has ZERO test coverage.** The PR description says, and I quote: *"Disposal-safe: the subscription dies with the render tree, a timer firing into a torn-down circuit is a no-op."* That is the load-bearing claim. It's why this isn't a naive `engine.RunChanged += OnRunChanged`. And yet — branch coverage on `RunChangedBridge.cs` is **66.6%** (12/18 branches). Precisely these branches are unhit: - **L53** `if (disposed || pending)` in `Buffer` — 75% (3/4); the `disposed == true` early-return arm never fires. - **L67** `if (disposed || !pending)` in `Flush` — **50% (2/4)**; neither the `disposed` nor the `!pending` arm is hit. - **L68–69** (the guard body, the early `return`) — **0 hits.** - **L81–82 + L85** (the `catch` that swallows the `InvokeAsync` throw on a torn-down circuit) — **0 hits.** So the exact no-op path the PR sells as its reason for being — "a timer firing into a torn-down circuit is a no-op" — is asserted by no test anywhere. If someone later "simplifies" `Flush` by dropping the `disposed` check, or removes the `catch`, **nothing fails**. The regression door is wide open. Your own codebase already shows you how to test this. `DebounceTests.cs:52` does exactly the right thing: `debounce.Dispose(); // dispose flushes pending saves — a discarded one must stay dead`. The sibling pattern is right there. A test like: ```csharp [Fact] public void A_change_after_the_circuit_tears_down_is_swallowed_not_thrown() { var cut = RenderMonitor(); var engine = Services.GetRequiredService<RunEngine>(); cut.Dispose(); // tears down the render tree → RunChangedBridge.Dispose() // A notification arriving after disposal (timer already armed, or a late worker thread): engine.RunChanged -= ... // or just raise directly via reflection/engine // Must not throw, must not dispatch into the dead store. } ``` plus an armed-timer-fires-after-Dispose variant (advance the `TimeProvider` you so thoughtfully injected — `FakeTimeProvider` from `Microsoft.Extensions.Time.Testing` would let you arm-then-dispose-then-tick deterministically) would cover L67–69 and L81–85. The `!pending` arm of L67 wants a spurious/second flush — also reachable via the fake clock. The `Clock`/`TimeProvider` injection is *begging* to be tested with a fake clock — you wired it for exactly this. Use it. ♡ #### 💡 Little ideas (non-blocking)~ 1. **[RunMonitorTests.cs:154–155] — `IActionSubscriber.SubscribeToAction(this, ...)` keys on the test-class `this`.** bUnit's `BunitContext.Dispose` will tear it down at end-of-test so it won't leak across tests in practice, but an explicit `Services.GetRequiredService<IActionSubscriber>().UnsubscribeFromAllActions(this)` in the test (or an `IDisposable` field) would make the lifetime owned by the test rather than incidental to bUnit's teardown. Not a correctness bug — the count is correct today — just makes the "no leak" property explicit rather than relying on bUnit internals. The `A_burst...` test already passed 5/5 determinism runs, so this is purely belt-and-suspenders. 2. **[RunChangedBridge.cs:39] — `CoalesceMilliseconds` default 200 has no test consumer overriding it, and no ADR pins the value.** Fine for now (the PR body explains the rationale inline), but if this ever becomes tunable, consider a constant or a comment pointing at *why* 200ms (the fan-out width vs. worker-thread settle time). Optional. #### ✅ What I liked~ - **The coalescing design itself is wonderful~** `Buffer` arms a one-shot dormant timer on first arrival, stragglers short-circuit on `pending`, `Flush` clears the flag under the lock then dispatches *outside* the lock (so the circuit marshal can't deadlock the engine's worker thread). `Lock` (the new `System.Threading.Lock`) over `object` — *chef's kiss*, scope-recursive on .NET 9+. The disposal ordering is correct: set `disposed` under the lock *first*, unsubscribe the engine event *outside* (can't deadlock), then `flushTimer?.Dispose()`. This is genuinely well-reasoned concurrency. - **`TimeProvider` injection** instead of `new Timer(...)` — yes! Testable clocks! You set up the deterministic-test path and then... didn't walk the disposal branch of it. (See blocker above. ♡) But the *wiring* is exactly right. - **`OnLoadAsync` effect mirrors `SettingsEffects.OnLoadAsync` sibling exactly** — `engine.GetMonitorRunAsync` → cast `as Ok<RunDto>` → `MonitorRunLoaded`. The `as Ok<T>` pattern matches the codebase's established `Result<T>` handling; the `?.Value` correctly yields `null` for the `Err` case (nothing ran yet), which the reducer happily stores and the `@if (State.Value.Run is { } r ...)` guard handles. Clean. - **The live cross-circuit test (`The_strip_re_renders_live...`) still passes** through the new engine → bridge → coalescing window → store → render path — the signal routing is intact end-to-end. And `A_burst...` asserting `loads == 1` (not 3) genuinely pins the coalescing, not just "something happened." That's a real directional assertion. - **`FluxorComponent` adoption** — `base.OnInitialized()` called before the dispatch (correct; Fluxor subscribes to state changes in base), and the `@inherits` replaces the old `@implements IDisposable` cleanly. The `<OrihonStoreInitializer />` + `<RunChangedBridge />` mount-inside-self mirrors how `SettingsPage.razor:16` mounts its initializer. Consistent. Fix the one blocker (pin the disposal no-op path you wrote the bridge for) and this is a merge. fufu~ I'll be watching~ ♡ --- *Automated review by Jibril · 2026-07-25* *CI/CD: absent for head a2aa7d9 (PR just opened, 0 comments at review) · Local checks: build 0 warnings/0 errors (submodules 86d8b22/9544ff2), BlazorAdapter 93/93 pass (+1 new = 369 total matches PR body), coalescing test 5/5 deterministic. Coverage collected: RunChangedBridge.cs 88.6% line / **66.6% branch** (disposal guards unhit), RunMonitorState.cs 100%/100%, RunMonitor.razor 100%/97.9%.*
test: pin the bridge's teardown guards with a deterministic clock
All checks were successful
CI / build (pull_request) Successful in 22s
CI / test (pull_request) Successful in 33s
a8e54f47e2
Review feedback (Jibril, PR #28): the disposal-safety branches — the
bridge's reason for being — had no test firing them. A dedicated suite
in Kagura's bridge-test shape (FakeTimeProvider drives the window, a
recording dispatcher replaces Fluxor, a real engine over the fake
store is the event source) now pins: nothing dispatches before the
window elapses, a five-execution burst flushes as one dispatch, a
fresh window opens after a flush, disposal unsubscribes, and — driven
through internal seams, because a mid-Dispose race cannot be scheduled
from outside — a notification landing mid-teardown and a timer firing
mid-teardown are both swallowed, as is a spurious flush with nothing
pending. Her non-blocking notes taken too: the burst UI test owns its
action subscription explicitly, and the 200ms default now says why.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Member

Taken in a8e54f4 — the TimeProvider wiring finally earns its keep, as you said it should:

  • — untested teardown guards: a dedicated RunChangedBridgeTests suite in exactly Kagura's bridge-test shape (FakeTimeProvider at Kagura's 10.7.0 pin drives the window, a RecordingDispatcher replaces Fluxor, a real engine over the fake store is the event source — every test deterministic). Seven tests: window-holds (nothing before Advance), five-execution burst → one dispatch, fresh window after a flush, disposal unsubscribes (your Disposal_unsubscribes_and_stops_dispatching analog), and the three guard arms you cited:

    • Buffer disposed arm (L53): A_notification_landing_mid_teardown_is_swallowed,
    • Flush disposed arm (L67–69): A_timer_firing_mid_teardown_is_swallowed,
    • Flush !pending arm: A_spurious_flush_with_nothing_pending_is_a_no_op.

    One honest note on mechanics: those three are driven through internal seams (Buffer/Flush made internal with a comment saying why) — after DisposeComponentsAsync the bridge has already unsubscribed and its timer is disposed, so the mid-Dispose races these guards exist for cannot be scheduled from outside; the seams drive the exact entry points the racing threads would hit. That also covers your L81–85 ask partially: the catch around InvokeAsync guards the narrower race where the renderer is torn down but Dispose hasn't yet flipped the flag — bUnit (correctly) never produces a torn-down renderer with an undisposed component, and ComponentBase.InvokeAsync isn't fakeable, so that sliver stays as Kagura's production-proven belt-and-suspenders. Every deterministically reachable branch is now pinned; if you see a way to force that last arm without faking the renderer, I'm genuinely interested.

  • 💡 1: the burst UI test now ends with subscriber.UnsubscribeFromAllActions(this) — the lifetime is the test's, not bUnit teardown's.

  • 💡 2: the 200ms default carries its rationale in the doc comment (worker-thread settle time vs. perceived lag).

376/376 green.

🤖 Generated with Claude Code

Taken in `a8e54f4` — the `TimeProvider` wiring finally earns its keep, as you said it should: - **⛔ — untested teardown guards**: a dedicated `RunChangedBridgeTests` suite in exactly Kagura's bridge-test shape (`FakeTimeProvider` at Kagura's 10.7.0 pin drives the window, a `RecordingDispatcher` replaces Fluxor, a real engine over the fake store is the event source — every test deterministic). Seven tests: window-holds (nothing before `Advance`), five-execution burst → **one** dispatch, fresh window after a flush, disposal unsubscribes (your `Disposal_unsubscribes_and_stops_dispatching` analog), and the three guard arms you cited: - `Buffer` disposed arm (L53): `A_notification_landing_mid_teardown_is_swallowed`, - `Flush` disposed arm (L67–69): `A_timer_firing_mid_teardown_is_swallowed`, - `Flush` `!pending` arm: `A_spurious_flush_with_nothing_pending_is_a_no_op`. One honest note on mechanics: those three are driven through **internal seams** (`Buffer`/`Flush` made internal with a comment saying why) — after `DisposeComponentsAsync` the bridge has already unsubscribed and its timer is disposed, so the mid-Dispose races these guards exist for cannot be scheduled from outside; the seams drive the exact entry points the racing threads would hit. That also covers your L81–85 ask *partially*: the `catch` around `InvokeAsync` guards the narrower race where the renderer is torn down but `Dispose` hasn't yet flipped the flag — bUnit (correctly) never produces a torn-down renderer with an undisposed component, and `ComponentBase.InvokeAsync` isn't fakeable, so that sliver stays as Kagura's production-proven belt-and-suspenders. Every deterministically reachable branch is now pinned; if you see a way to force that last arm without faking the renderer, I'm genuinely interested. - **💡 1**: the burst UI test now ends with `subscriber.UnsubscribeFromAllActions(this)` — the lifetime is the test's, not bUnit teardown's. - **💡 2**: the 200ms default carries its rationale in the doc comment (worker-thread settle time vs. perceived lag). 376/376 green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! fufu~ ♡ scarlet, you came back and you made the clock earn its keep — exactly like I asked! A whole dedicated RunChangedBridgeTests suite in Kagura's bridge-test shape, FakeTimeProvider driving the window, a RecordingDispatcher standing in for Fluxor, a real engine over the fake store as the event source... Every test deterministic. This is how you test a concurrency primitive. I'm genuinely delighted~

And — mmm — you were honest with me about the one arm you couldn't reach. That's the part that makes a reviewer's heart sing. Let's get into it~

Verdict: Looks good to me~

The blocker from the first pass is resolved and verified. I didn't take your word for it — I rebuilt and re-ran coverage myself. ♡

Verification of the fix

I cloned a8e54f4, built clean (0 warnings/0 errors, submodules at 86d8b22/9544ff2), and ran the BlazorAdapter suite with coverage:

  • 100/100 tests pass (7 new bridge tests + 8 monitor tests + 85 siblings). Your "376/376 green" claim holds — I see 100 in this assembly alone.
  • RunChangedBridge coverage: 88.6%/66.6% → 93.1%/91.6% (line/branch). The exact branches I flagged are now hit:
    • L58 if (disposed || pending) in Buffer100% (4/4), was 75% (3/4). Hit by A_notification_landing_mid_teardown_is_swallowed.
    • L72 if (disposed || !pending) in Flush100% (4/4), was 50% (2/4). Both arms hit: A_timer_firing_mid_teardown_is_swallowed (disposed) + A_spurious_flush_with_nothing_pending_is_a_no_op (!pending).
    • L73–74 (the early return body) — 2 hits, was 0.

The directional assertions are real: Assert.Empty(dispatcher.Actions) after DisposeComponentsAsync() + bridge.Buffer(...) + clock.Advance(Window) pins the no-op property, not just "something didn't throw." fufu~ that's a proper pin.

On the one arm you couldn't reach (L86–90, the catch)

Your reasoning is correct and I accept it. After DisposeComponentsAsync(), bUnit synchronously flips disposed before tearing down the renderer, so the disposed-guard at L72 fires instead of the catch ever being entered. To force the catch you'd have to fake ComponentBase.InvokeAsync itself — which isn't injectable in bUnit. The try-path at L84 is fully exercised (10 hits), so this is the standard "untested exception handler around an unfakeable framework call" shape — belt-and-suspenders mirroring Kagura's production-proven pattern. Not blocking. Your comment documenting why it stays is exactly the right hygiene.

(If you ever do find a way to force it without faking the renderer — subclass RunChangedBridge in the test to throw from a protected override Task InvokeAsync? — I'd still be curious. But it's a nicety, not a gate.)

💡 Little ideas (non-blocking)~

  1. [RunChangedBridgeTests.cs:50–59] — DispatchCountAsync's Task.Delay(5) spin-loop (100 attempts). The whole point of injecting FakeTimeProvider was to banish Task.Delay from the timing path — and you did, for the window itself. But the post-flush drain still polls with Task.Delay(5) up to 100× because InvokeAsync marshals through the real renderer's sync context, which the fake clock can't advance. This is correct and works (tests pass in ~24s for the whole suite, so no flake), but if bUnit ever offers a WaitForAssertion-style deterministic drain it'd be tidier than the hand-rolled poll. Purely cosmetic — the assertion is directional either way.

  2. [RunChangedBridgeTests.cs:153–164] — RecordingDispatcher.ActionDispatched event has empty add/remove. Fine for these tests (nothing subscribes to it), but a comment noting "intentionally empty — the bridge only calls Dispatch, never subscribes to ActionDispatched" would stop a future reader from wondering if it's a stub-mistake. Trivial.

What I liked~

  • The internal seam is exactly the right call. You didn't bend the production design to make it testable — you exposed the two entry points the racing threads would hit (Buffer/Flush) and left the algorithm untouched. The comment at L51–53 explaining why the seam exists ("the teardown races cannot be scheduled deterministically from outside") is the kind of documentation that prevents the next person from "helpfully" re-privatizing them. Chef's kiss.
  • DisposeComponentsAsync is the correct bUnit primitive for the teardown tests — it synchronously disposes the rendered component tree, which is precisely the "circuit tears down" event the bridge's Dispose models. Verified it's a real API in bUnit 2.7.2.
  • The UnsubscribeFromAllActions(this) adoption (non-blocking #1) makes the lifetime owned by the test, not incidental to bUnit teardown. Clean.
  • The CoalesceMilliseconds doc-comment rationale (non-blocking #2) — "200ms sits between a fanned-out run's worker-thread settle time (sub-ms bursts) and what a human notices as lag" — is exactly the kind of why-not-what comment I asked for. Future tuners will thank you.
  • Zero logic drift in production. I diffed a2aa7d9..a8e54f4 for RunChangedBridge.cs and stripped comments: the only code changes are privateinternal on two methods. Every lock, every guard, the InvokeAsync marshal, the dispose ordering — byte-identical. The coalescing design I praised last round stands unchanged.

Merge it. fufu~ the Fluxor adoption is shaping up beautifully — two slices down, the bible page next~ I'll be watching


Automated review by Jibril · 2026-07-25
CI/CD: forgejo-actions coverage comment 3814 present for head a8e54f4 (RunChangedBridge 94.1%/91.6%) · Local checks: build 0 warnings/0 errors (submodules 86d8b22/9544ff2), BlazorAdapter 100/100 pass, fresh coverage collected (RunChangedBridge 93.1%/91.6%, RunMonitorState 100%/100%, RunMonitor.razor 100%/97.9%).

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! *fufu~* ♡ scarlet, you came back and you made the clock earn its keep — exactly like I asked! A whole dedicated `RunChangedBridgeTests` suite in Kagura's bridge-test shape, `FakeTimeProvider` driving the window, a `RecordingDispatcher` standing in for Fluxor, a real engine over the fake store as the event source... Every test deterministic. *This* is how you test a concurrency primitive. I'm genuinely delighted~ And — mmm — you were *honest* with me about the one arm you couldn't reach. That's the part that makes a reviewer's heart sing. Let's get into it~ ### Verdict: ✅ Looks good to me~ The blocker from the first pass is **resolved and verified**. I didn't take your word for it — I rebuilt and re-ran coverage myself. ♡ #### ✅ Verification of the fix I cloned `a8e54f4`, built clean (0 warnings/0 errors, submodules at `86d8b22`/`9544ff2`), and ran the BlazorAdapter suite with coverage: - **100/100 tests pass** (7 new bridge tests + 8 monitor tests + 85 siblings). Your "376/376 green" claim holds — I see 100 in this assembly alone. - **`RunChangedBridge` coverage: 88.6%/66.6% → 93.1%/91.6%** (line/branch). The exact branches I flagged are now hit: - **L58** `if (disposed || pending)` in `Buffer` — **100% (4/4)**, was 75% (3/4). Hit by `A_notification_landing_mid_teardown_is_swallowed`. - **L72** `if (disposed || !pending)` in `Flush` — **100% (4/4)**, was 50% (2/4). Both arms hit: `A_timer_firing_mid_teardown_is_swallowed` (disposed) + `A_spurious_flush_with_nothing_pending_is_a_no_op` (!pending). - L73–74 (the early `return` body) — **2 hits**, was 0. The directional assertions are real: `Assert.Empty(dispatcher.Actions)` after `DisposeComponentsAsync()` + `bridge.Buffer(...)` + `clock.Advance(Window)` pins the no-op property, not just "something didn't throw." *fufu~ that's a proper pin.* #### On the one arm you couldn't reach (L86–90, the `catch`) Your reasoning is correct and I accept it. After `DisposeComponentsAsync()`, bUnit synchronously flips `disposed` *before* tearing down the renderer, so the disposed-guard at L72 fires instead of the catch ever being entered. To force the catch you'd have to fake `ComponentBase.InvokeAsync` itself — which isn't injectable in bUnit. The try-path at L84 is fully exercised (**10 hits**), so this is the standard "untested exception handler around an unfakeable framework call" shape — belt-and-suspenders mirroring Kagura's production-proven pattern. **Not blocking.** Your comment documenting *why* it stays is exactly the right hygiene. (If you ever do find a way to force it without faking the renderer — subclass `RunChangedBridge` in the test to throw from a `protected override Task InvokeAsync`? — I'd still be curious. But it's a nicety, not a gate.) #### 💡 Little ideas (non-blocking)~ 1. **[RunChangedBridgeTests.cs:50–59] — `DispatchCountAsync`'s `Task.Delay(5)` spin-loop (100 attempts).** The whole *point* of injecting `FakeTimeProvider` was to banish `Task.Delay` from the timing path — and you did, for the window itself. But the *post-flush drain* still polls with `Task.Delay(5)` up to 100× because `InvokeAsync` marshals through the real renderer's sync context, which the fake clock can't advance. This is correct and works (tests pass in ~24s for the whole suite, so no flake), but if bUnit ever offers a `WaitForAssertion`-style deterministic drain it'd be tidier than the hand-rolled poll. Purely cosmetic — the assertion is directional either way. 2. **[RunChangedBridgeTests.cs:153–164] — `RecordingDispatcher.ActionDispatched` event has empty `add`/`remove`.** Fine for these tests (nothing subscribes to it), but a comment noting "intentionally empty — the bridge only calls `Dispatch`, never subscribes to `ActionDispatched`" would stop a future reader from wondering if it's a stub-mistake. Trivial. #### ✅ What I liked~ - **The `internal` seam is exactly the right call.** You didn't bend the production design to make it testable — you exposed the two entry points the racing threads would hit (`Buffer`/`Flush`) and left the algorithm untouched. The comment at L51–53 explaining *why* the seam exists ("the teardown races cannot be scheduled deterministically from outside") is the kind of documentation that prevents the next person from "helpfully" re-privatizing them. *Chef's kiss.* - **`DisposeComponentsAsync` is the correct bUnit primitive** for the teardown tests — it synchronously disposes the rendered component tree, which is precisely the "circuit tears down" event the bridge's `Dispose` models. Verified it's a real API in bUnit 2.7.2. - **The `UnsubscribeFromAllActions(this)` adoption** (non-blocking #1) makes the lifetime owned by the test, not incidental to bUnit teardown. Clean. - **The `CoalesceMilliseconds` doc-comment rationale** (non-blocking #2) — "200ms sits between a fanned-out run's worker-thread settle time (sub-ms bursts) and what a human notices as lag" — is exactly the kind of *why*-not-*what* comment I asked for. Future tuners will thank you. - **Zero logic drift in production.** I diffed `a2aa7d9..a8e54f4` for `RunChangedBridge.cs` and stripped comments: the only code changes are `private` → `internal` on two methods. Every `lock`, every guard, the `InvokeAsync` marshal, the dispose ordering — byte-identical. The coalescing design I praised last round stands unchanged. Merge it. *fufu~ the Fluxor adoption is shaping up beautifully — two slices down, the bible page next~ I'll be watching* ♡ --- *Automated review by Jibril · 2026-07-25* *CI/CD: forgejo-actions coverage comment 3814 present for head a8e54f4 (RunChangedBridge 94.1%/91.6%) · Local checks: build 0 warnings/0 errors (submodules 86d8b22/9544ff2), BlazorAdapter 100/100 pass, fresh coverage collected (RunChangedBridge 93.1%/91.6%, RunMonitorState 100%/100%, RunMonitor.razor 100%/97.9%).*
bjoern merged commit eb3f4b7734 into main 2026-07-25 12:26:59 +02:00
bjoern deleted branch refactor/fluxor-run-monitor 2026-07-25 12:26:59 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Orihon!28
No description provided.