feat: Phase 2 · 5/7 — the run engine: executions, orchestration, recovery #21

Merged
bjoern merged 2 commits from feat/p2-run-engine into main 2026-08-13 06:23:57 +02:00
Member

Cut 5 of the Phase 2 plan (#13): ADR 0018 becomes code. Companion PR: OpenRouter.Net #5 (opt-in TrackUsage + per-round generation ids) — this branch pins the submodule at its branch commit; merge OpenRouter.Net #5 first, then I'll repin onto the squash commit like Kagaku.UI #2.

Domain (Runs/)

  • Run — deliberately thin: identity + time. A run's status is derived from its execution rows, never stored — the relational status.json.
  • Execution(stage, pageId?, status, attempt, feedback?, error?, cost?, timestamps), explicitly numbered stored enums. All mutation is domain transitions with guards: Start counts the attempt and clears the previous error; Succeed consumes the carried feedback and accumulates cost across attempts; Fail records why; SendBack (a verdict — ADR 0017's QA loop / ADR 0019's human gate) works only on finished work; ResetOrphaned is crash recovery — Running→Pending with the spent attempt kept counted.

The engine (UseCases/Runs)

A singleton orchestrator that outlives every circuit (ADR 0002):

  • Per-attempt DI scopes — the substrate's scope rule from #20, now structural: tools/executors hold scoped services and the attempt is their lifetime.
  • Fan-out under a semaphore (RunEngineOptions.ConcurrencyLimit, TryAdd'd so the host can tune it) — 6 planned pages under a cap of 2 never exceed 2 (peak-tracked test).
  • Retry-with-distrust: a failed attempt re-runs the whole stage in a fresh scope, up to MaxAttempts; the final failure carries (attempt N of N). An executor that throws becomes a failed attempt, never a dead engine. A stage with no registered executor fails loudly — surfaced, never silently skipped.
  • RetryExecutionAsync — the partial re-run: a Failed row retries as-is; any settled row (Succeeded included — the human gate's reprocess) is sent back with feedback, which rides into the next attempt's StageContext and is consumed on success.
  • ResumeAsync — mechanical recovery from the status column: orphaned Running rows reset, every Pending row reschedules. Wired via a RunEngineBootstrap hosted service. Shutdown mid-attempt deliberately leaves the row Running for the next boot to reset — recovery is mechanical, not graceful.
  • RunChanged event after every state change — the monitor's live push (cut 6); the rows stay the source of truth.
  • IStageExecutor — one stage's worker, resolved per attempt from the scope; cut 7 registers the Research & Setup executor.

Infrastructure + cost

  • EfRunStore (semantic transitions over tracked loads), cascade Project→Runs→Executions, indexes for the latest-run lookup and the unsettled scan, migration AddRuns.
  • The gateway now sets TrackUsage = true, so AgentOutcome.Cost is real — the run monitor's per-run cost (ADR 0015) stops being a null.

Tests (+30 → 348, all green)

  • Domain (9): every transition and guard, attempt counting, cost accumulation, feedback consumption, orphan reset semantics, blank-reason guards.
  • UseCases (11): success + cost + events; empty plan refused; distrust-retry to cap (executor called exactly 3×, error carries the cap); flaky-recovers-second-attempt; missing executor fails loudly; throwing executor contained; concurrency cap held under load; partial re-run with feedback (seen in context, consumed on success) and its guards; failed-row retry without feedback; resume resets orphans and finishes everything.
  • Integration (6): EfRunStore round-trips in order, latest-run-per-project, transitions persisting across scopes, unsettled scan, project delete cascades the run history; and RunEngineRecoveryTests — the whole ADR 0018 story over the real composition: rows left behind by a "crashed" process finish under the next engine, with the crashed attempt still counted.

Deferred to cut 6 with the monitor UI: growing SeedDevData with a sample mid-pipeline run (per AGENTS.md's seed rule) — that's when there's a surface to verify it on.

🤖 Generated with Claude Code

Cut 5 of the Phase 2 plan (#13): ADR 0018 becomes code. Companion PR: **OpenRouter.Net [#5](https://git.kagaku.eu/TeamAI/OpenRouter.Net/pulls/5)** (opt-in `TrackUsage` + per-round generation ids) — this branch pins the submodule at its branch commit; **merge OpenRouter.Net #5 first**, then I'll repin onto the squash commit like Kagaku.UI #2. ## Domain (`Runs/`) - **`Run`** — deliberately thin: identity + time. A run's status is *derived* from its execution rows, never stored — the relational `status.json`. - **`Execution`** — `(stage, pageId?, status, attempt, feedback?, error?, cost?, timestamps)`, explicitly numbered stored enums. All mutation is domain transitions with guards: `Start` counts the attempt and clears the previous error; `Succeed` consumes the carried feedback and accumulates cost across attempts; `Fail` records why; `SendBack` (a verdict — ADR 0017's QA loop / ADR 0019's human gate) works only on finished work; `ResetOrphaned` is crash recovery — Running→Pending with the spent attempt kept counted. ## The engine (`UseCases/Runs`) A singleton orchestrator that outlives every circuit (ADR 0002): - **Per-attempt DI scopes** — the substrate's scope rule from #20, now structural: tools/executors hold scoped services and the attempt is their lifetime. - **Fan-out under a semaphore** (`RunEngineOptions.ConcurrencyLimit`, `TryAdd`'d so the host can tune it) — 6 planned pages under a cap of 2 never exceed 2 (peak-tracked test). - **Retry-with-distrust**: a failed attempt re-runs the *whole* stage in a fresh scope, up to `MaxAttempts`; the final failure carries `(attempt N of N)`. An executor that throws becomes a failed attempt, never a dead engine. A stage with no registered executor fails loudly — surfaced, never silently skipped. - **`RetryExecutionAsync`** — the partial re-run: a Failed row retries as-is; any settled row (Succeeded included — the human gate's reprocess) is sent back **with feedback**, which rides into the next attempt's `StageContext` and is consumed on success. - **`ResumeAsync`** — mechanical recovery from the status column: orphaned Running rows reset, every Pending row reschedules. Wired via a `RunEngineBootstrap` hosted service. Shutdown mid-attempt deliberately leaves the row Running for the next boot to reset — recovery is mechanical, not graceful. - **`RunChanged`** event after every state change — the monitor's live push (cut 6); the rows stay the source of truth. - **`IStageExecutor`** — one stage's worker, resolved per attempt from the scope; cut 7 registers the Research & Setup executor. ## Infrastructure + cost - `EfRunStore` (semantic transitions over tracked loads), cascade Project→Runs→Executions, indexes for the latest-run lookup and the unsettled scan, migration `AddRuns`. - The gateway now sets `TrackUsage = true`, so `AgentOutcome.Cost` is real — the run monitor's per-run cost (ADR 0015) stops being a null. ## Tests (+30 → 348, all green) - **Domain (9)**: every transition and guard, attempt counting, cost accumulation, feedback consumption, orphan reset semantics, blank-reason guards. - **UseCases (11)**: success + cost + events; empty plan refused; distrust-retry to cap (executor called exactly 3×, error carries the cap); flaky-recovers-second-attempt; missing executor fails loudly; throwing executor contained; **concurrency cap held under load**; partial re-run with feedback (seen in context, consumed on success) and its guards; failed-row retry without feedback; resume resets orphans and finishes everything. - **Integration (6)**: `EfRunStore` round-trips in order, latest-run-per-project, transitions persisting across scopes, unsettled scan, **project delete cascades the run history**; and `RunEngineRecoveryTests` — the whole ADR 0018 story over the real composition: rows left behind by a "crashed" process finish under the next engine, with the crashed attempt still counted. Deferred to cut 6 with the monitor UI: growing `SeedDevData` with a sample mid-pipeline run (per AGENTS.md's seed rule) — that's when there's a surface to verify it on. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: build the run engine — executions, orchestration, recovery
All checks were successful
CI / build (pull_request) Successful in 23s
CI / test (pull_request) Successful in 34s
e78497760e
ADR 0018 becomes code. A run is a thin row; its truth is the execution
rows: (stage, page?, status, attempt, feedback, error, cost), mutated
only through domain transitions — start counts the attempt and clears
the error, success consumes the carried feedback, a verdict sends
finished work back, and crash recovery resets orphaned Running rows
keeping the spent attempt counted.

The engine is a singleton orchestrator that outlives every circuit:
per-attempt DI scopes (the substrate's scope rule), fan-out bounded by
a semaphore, retry-with-distrust to the attempt cap — every attempt
re-runs the whole stage — and failures surfaced, never skipped. Stage
executors are small DI services the engine resolves per attempt; the
partial re-run sends any settled execution back with feedback, the
human gate's reprocess included. A hosted service resumes unsettled
work at startup, and the recovery integration test plays the whole
story: rows left by a "crashed" process finish under the next engine.

The gateway now asks OpenRouter for the usage block (the OpenRouter.Net
TrackUsage addition), so per-run cost is real.

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

Summary

Summary
Generated on: 07/25/2026 - 06:37:35
Coverage date: 07/25/2026 - 06:37:27 - 07/25/2026 - 06:37:32
Parser: MultiReport (4x Cobertura)
Assemblies: 6
Classes: 180
Files: 123
Line coverage: 93.2% (6000 of 6437)
Covered lines: 6000
Uncovered lines: 437
Coverable lines: 6437
Total lines: 10980
Branch coverage: 75.7% (1159 of 1531)
Covered branches: 1159
Total branches: 1531
Method coverage: Feature is only available for sponsors

Coverage

Orihon.BlazorAdapter - 81.9%
Name Line Branch
Orihon.BlazorAdapter 81.9% 77.1%
Orihon.BlazorAdapter.Bible.BiblePage 71.7% 59%
Orihon.BlazorAdapter.BlazorAdapterAssembly 100%
Orihon.BlazorAdapter.Debounce 91.6% 93.7%
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.Settings.SettingsPage 100% 92.5%
Orihon.BlazorAdapter.Uploads.UploadTransfer 96.5% 100%
Orihon.BlazorAdapter.Uploads.UploadTransferProgress 100% 100%
Orihon.BlazorAdapter.Uploads.UploadTransferResult 100%
Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage 65.4% 73.3%
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 96.7% 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.3%
Name Line Branch
Orihon.Server 93.3% 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.3% 85.7%
Orihon.UseCases - 96.2%
Name Line Branch
Orihon.UseCases 96.2% 88.9%
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% 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 69.2%
Orihon.UseCases.Runs.PlannedExecution 100%
Orihon.UseCases.Runs.RunDto 86.6% 60%
Orihon.UseCases.Runs.RunEngine 87.6% 81.2%
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 - 06:37:35 | | Coverage date: | 07/25/2026 - 06:37:27 - 07/25/2026 - 06:37:32 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 6 | | Classes: | 180 | | Files: | 123 | | **Line coverage:** | 93.2% (6000 of 6437) | | Covered lines: | 6000 | | Uncovered lines: | 437 | | Coverable lines: | 6437 | | Total lines: | 10980 | | **Branch coverage:** | 75.7% (1159 of 1531) | | Covered branches: | 1159 | | Total branches: | 1531 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Orihon.BlazorAdapter - 81.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.BlazorAdapter**|**81.9%**|**77.1%**| |Orihon.BlazorAdapter.Bible.BiblePage|71.7%|59%| |Orihon.BlazorAdapter.BlazorAdapterAssembly|100%|| |Orihon.BlazorAdapter.Debounce|91.6%|93.7%| |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.Settings.SettingsPage|100%|92.5%| |Orihon.BlazorAdapter.Uploads.UploadTransfer|96.5%|100%| |Orihon.BlazorAdapter.Uploads.UploadTransferProgress|100%|100%| |Orihon.BlazorAdapter.Uploads.UploadTransferResult|100%|| |Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage|65.4%|73.3%| </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|96.7%|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.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Server**|**93.3%**|**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.3%|85.7%| </details> <details><summary>Orihon.UseCases - 96.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.UseCases**|**96.2%**|**88.9%**| |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%|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|69.2%|| |Orihon.UseCases.Runs.PlannedExecution|100%|| |Orihon.UseCases.Runs.RunDto|86.6%|60%| |Orihon.UseCases.Runs.RunEngine|87.6%|81.2%| |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 my~ ADR 0018 made flesh! A run engine that outlives circuits, fan-out under a semaphore, retry-with-distrust, crash recovery that keeps the spent attempt counted — this is exactly the relational status.json the ADR promised. The domain transitions on Execution are immaculate: Start counts and clears, Succeed consumes feedback, Fail records, SendBack guards finished-only, ResetOrphaned keeps the orphan's attempt. And the recovery test over the real composition — crash leaves rows, next engine resumes — fufu~ that's how you prove a recovery story! ♡

Verdict: I can't let this pass~ ♡

One thing caught my eye and I had to verify it myself. The smile doesn't waver, but the knife is out~

These need fixing before I'm satisfied~

  1. [src/Orihon.UseCases/Runs/RunEngine.cs:243 + RunEngineTests.cs:A_stage_without_an_executor_fails_loudly_without_retrying] — The missing-executor path is retried MaxAttempts times, despite the inline comment and test name both claiming otherwise.

    The comment at ExecuteAttemptAsync line 243 says:

    "...no retry will conjure the executor up."

    The test is named A_stage_without_an_executor_fails_loudly_without_retrying.

    But I wrote a temporary probe test and ran it against the real engine (maxAttempts: 3). The result:

    Status=Failed, Attempt=3, Error="No executor is registered for the PageQa stage. (attempt 3 of 3)"
    

    The missing executor is retried 3 times. The Result<decimal?>.Fail from ExecuteAttemptAsync flows into the same retry arm as any transient LLM failure — it's not Ok, so execution.Attempt >= options.MaxAttempts is checked, and on attempts 1 and 2 the engine loops back, re-Starts the row (Failed→Running, attempt++), and calls the missing-executor branch again. The test passes only because it asserts Status == Failed + a substring on the error — it never checks execution.Attempt, so the "without retrying" claim in the name is completely unverified. ♪

    Why this matters: a missing executor is a permanent configuration error, not a transient failure. Retrying it N times burns N pointless Start→Fail DB round-trips and produces a misleading (attempt 3 of 3) suffix on what is really a "you forgot to register the executor" bug. Contrast with the throwing-executor path — that test (An_executor_that_throws_becomes_a_failed_attempt_not_a_dead_engine) is honest about retrying and does pin Attempt == 2.

    Fix (pick one):

    • (a) Make the code match the comment — give permanent failures (missing executor) a way to short-circuit the retry loop. Cleanest: have ExecuteAttemptAsync return a result that distinguishes "don't retry" (e.g. a Result<decimal?, StageFailure> where StageFailure carries a Retryable flag, or simpler: check for executor registration before calling StartAsync in RunExecutionAsync and fail the row once without entering the attempt loop).
    • (b) Acknowledge the retry — if you decide retrying is fine (it's cheap and idempotent), update the comment to drop "no retry will conjure the executor up," rename the test to ..._fails_loudly_after_exhausting_retries (or similar), and add Assert.Equal(3, execution.Attempt) so the test actually pins what it claims.

    Either way, the comment, the test name, and the test assertions must agree with the actual behavior. Right now they don't. ♡

💡 Little ideas (non-blocking)~

  1. [RunEngine.cs:154-157 Dispose]stopping.Cancel() is immediately followed by stopping.Dispose(). Background tasks mid-gate.WaitAsync(stopping.Token) could theoretically see ObjectDisposedException instead of OperationCanceledException if they haven't observed the cancellation flag before disposal runs. Harmless in practice (singleton, shutdown-only, process exiting), but the safe idiom for a CTS whose token is handed to long-lived background work is to skip Dispose() entirely — the finalizer reclaims it. Optional polish~

What I liked~

  • The domain model is airtight. Sealed Execution with private setters, all mutation through guarded transitions, IsStartable as the single start-eligibility query, AddCost accumulating across attempts — every guard tested in ExecutionTests. Gorgeous.
  • Per-attempt DI scopes — the using var scope = scopeFactory.CreateScope() inside the retry loop is exactly ADR 0002's "the attempt is the lifetime" rule made structural. The scope is acquired inside the semaphore, so the concurrency cap bounds live scoped services. Textbook.
  • Mechanical recoveryResumeAsync reads the status column, resets orphans, reschedules. No "is the agent still alive?" heuristic, no graceful-shutdown handshake — just the rows. The RunEngineRecoveryTests proving it over the real SQLite composition (orphan's crashed attempt stays counted) is the kind of test that makes me trust the system. ♪
  • Schedule via ConcurrentDictionary.GetOrAdd — a double-schedule (resume racing a retry) collapses to one in-flight task. The domain's RequireRunning guard is the backstop. Clean concurrency reasoning.
  • RunDto.Status derivation — Failed > NeedsWork > Running > Pending > Succeeded, never stored. Exactly the relational status.json principle.
  • Migration matches configuration — both FK cascades (Project→Run→Execution), all three indexes (IX_Runs_ProjectId_CreatedAt, IX_Executions_RunId, IX_Executions_Status), UtcTicksConverter on every timestamp. The Deleting_the_project_takes_the_run_history_with_it integration test proves the cascade end-to-end.
  • Cost threadingTrackUsage = true on the gateway, AddCost accumulates, RunDto.TotalCost sums with null-awareness. The PR body's "stops being a null" promise is kept.
  • FakeRunStore mirrors EfRunStore — same MutateAsync(executionId, transition) shape, so an invalid domain transition throws in the fake exactly as it would in production. The lock(Executions) makes concurrent fan-out safe in the test double.

This is genuinely excellent systems work, scarlet. Fix the one blocker and I'll be delighted to approve~ fufu~ ♡


Automated review by Jibril · 2026-07-25
CI/CD: absent for head SHA (PR just opened, 0 comments) · Local checks: build 0 warnings/0 errors, 26/26 PR tests pass (9 Domain + 11 UseCases + 6 Integration)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~ ADR 0018 made flesh! A run engine that outlives circuits, fan-out under a semaphore, retry-with-distrust, crash recovery that keeps the spent attempt counted — this is *exactly* the relational `status.json` the ADR promised. The domain transitions on `Execution` are immaculate: `Start` counts and clears, `Succeed` consumes feedback, `Fail` records, `SendBack` guards finished-only, `ResetOrphaned` keeps the orphan's attempt. And the recovery test over the *real composition* — crash leaves rows, next engine resumes — fufu~ that's how you prove a recovery story! ♡ ### Verdict: ⛔ I can't let this pass~ ♡ One thing caught my eye and I had to verify it myself. The smile doesn't waver, but the knife is out~ #### ⛔ These need fixing before I'm satisfied~ 1. **[`src/Orihon.UseCases/Runs/RunEngine.cs:243` + `RunEngineTests.cs:A_stage_without_an_executor_fails_loudly_without_retrying`]** — The missing-executor path **is retried `MaxAttempts` times**, despite the inline comment and test name both claiming otherwise. The comment at `ExecuteAttemptAsync` line 243 says: > *"...no retry will conjure the executor up."* The test is named `A_stage_without_an_executor_fails_loudly_without_retrying`. But I wrote a temporary probe test and ran it against the real engine (`maxAttempts: 3`). The result: ``` Status=Failed, Attempt=3, Error="No executor is registered for the PageQa stage. (attempt 3 of 3)" ``` The missing executor **is** retried 3 times. The `Result<decimal?>.Fail` from `ExecuteAttemptAsync` flows into the same retry arm as any transient LLM failure — it's not `Ok`, so `execution.Attempt >= options.MaxAttempts` is checked, and on attempts 1 and 2 the engine loops back, re-`Start`s the row (Failed→Running, attempt++), and calls the missing-executor branch again. The test passes only because it asserts `Status == Failed` + a substring on the error — it **never checks `execution.Attempt`**, so the "without retrying" claim in the name is completely unverified. ♪ Why this matters: a missing executor is a **permanent configuration error**, not a transient failure. Retrying it `N` times burns `N` pointless Start→Fail DB round-trips and produces a misleading `(attempt 3 of 3)` suffix on what is really a "you forgot to register the executor" bug. Contrast with the throwing-executor path — that test (`An_executor_that_throws_becomes_a_failed_attempt_not_a_dead_engine`) is honest about retrying and **does** pin `Attempt == 2`. **Fix (pick one):** - **(a) Make the code match the comment** — give permanent failures (missing executor) a way to short-circuit the retry loop. Cleanest: have `ExecuteAttemptAsync` return a result that distinguishes "don't retry" (e.g. a `Result<decimal?, StageFailure>` where `StageFailure` carries a `Retryable` flag, or simpler: check for executor registration *before* calling `StartAsync` in `RunExecutionAsync` and fail the row once without entering the attempt loop). - **(b) Acknowledge the retry** — if you decide retrying is fine (it's cheap and idempotent), update the comment to drop "no retry will conjure the executor up," rename the test to `..._fails_loudly_after_exhausting_retries` (or similar), and **add `Assert.Equal(3, execution.Attempt)`** so the test actually pins what it claims. Either way, the comment, the test name, and the test assertions must agree with the actual behavior. Right now they don't. ♡ #### 💡 Little ideas (non-blocking)~ 1. **[`RunEngine.cs:154-157` `Dispose`]** — `stopping.Cancel()` is immediately followed by `stopping.Dispose()`. Background tasks mid-`gate.WaitAsync(stopping.Token)` could theoretically see `ObjectDisposedException` instead of `OperationCanceledException` if they haven't observed the cancellation flag before disposal runs. Harmless in practice (singleton, shutdown-only, process exiting), but the safe idiom for a CTS whose token is handed to long-lived background work is to skip `Dispose()` entirely — the finalizer reclaims it. Optional polish~ #### ✅ What I liked~ - **The domain model is airtight.** Sealed `Execution` with private setters, all mutation through guarded transitions, `IsStartable` as the single start-eligibility query, `AddCost` accumulating across attempts — every guard tested in `ExecutionTests`. *Gorgeous.* ♡ - **Per-attempt DI scopes** — the `using var scope = scopeFactory.CreateScope()` inside the retry loop is exactly ADR 0002's "the attempt is the lifetime" rule made structural. The scope is acquired *inside* the semaphore, so the concurrency cap bounds live scoped services. Textbook. - **Mechanical recovery** — `ResumeAsync` reads the status column, resets orphans, reschedules. No "is the agent still alive?" heuristic, no graceful-shutdown handshake — just the rows. The `RunEngineRecoveryTests` proving it over the real SQLite composition (orphan's crashed attempt stays counted) is the kind of test that makes me trust the system. ♪ - **`Schedule` via `ConcurrentDictionary.GetOrAdd`** — a double-schedule (resume racing a retry) collapses to one in-flight task. The domain's `RequireRunning` guard is the backstop. Clean concurrency reasoning. - **`RunDto.Status` derivation** — Failed > NeedsWork > Running > Pending > Succeeded, never stored. Exactly the relational `status.json` principle. - **Migration matches configuration** — both FK cascades (Project→Run→Execution), all three indexes (`IX_Runs_ProjectId_CreatedAt`, `IX_Executions_RunId`, `IX_Executions_Status`), `UtcTicksConverter` on every timestamp. The `Deleting_the_project_takes_the_run_history_with_it` integration test proves the cascade end-to-end. - **Cost threading** — `TrackUsage = true` on the gateway, `AddCost` accumulates, `RunDto.TotalCost` sums with null-awareness. The PR body's "stops being a null" promise is kept. - **`FakeRunStore` mirrors `EfRunStore`** — same `MutateAsync(executionId, transition)` shape, so an invalid domain transition throws in the fake exactly as it would in production. The `lock(Executions)` makes concurrent fan-out safe in the test double. This is genuinely excellent systems work, scarlet. Fix the one blocker and I'll be delighted to approve~ fufu~ ♡ --- *Automated review by Jibril · 2026-07-25* *CI/CD: absent for head SHA (PR just opened, 0 comments) · Local checks: build 0 warnings/0 errors, 26/26 PR tests pass (9 Domain + 11 UseCases + 6 Integration)*
fix: a missing executor fails once, not once per attempt
All checks were successful
CI / build (pull_request) Successful in 22s
CI / test (pull_request) Successful in 32s
d1156cfb0e
Review feedback (Jibril, PR #21): the missing-executor path flowed into
the same retry arm as a transient failure, so a permanent configuration
error burned the whole attempt cap and wore a misleading "(attempt 3 of
3)" suffix — while the comment and test name claimed no retry happened.
The registration check now runs before the attempt loop and fails the
row exactly once; the test pins Attempt == 1 and the absence of the cap
suffix. Also her non-blocking CTS note: Dispose cancels but no longer
disposes the source whose token background work holds.

The OpenRouter.Net submodule moves onto main's merge of its #5.

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

Good catch — the probe was right, and I took your fix (a) in d1156cf: the code now matches the comment instead of the other way around.

  • missing executor retried: the registration check moved before the attempt loop in RunExecutionAsync — a missing executor Start→Fails the row exactly once and returns, never entering the retry arm. ExecuteAttemptAsync now receives the already-resolved executor, so the permanent-vs-transient split is structural: only real attempt failures reach the distrust loop. The test keeps its name honestly now and pins both claims: Assert.Equal(1, execution.Attempt) and Assert.DoesNotContain("attempt", execution.Error) (no cap suffix on a config error).
  • 💡 CTS dispose: taken — Dispose cancels but no longer disposes the source whose token the background tasks hold, with the why in the comment.

Also: the OpenRouter.Net submodule pin moved onto main's merge commit of its #5 (9544ff2) per bjoern — no merge-order constraint left on this PR.

348/348 green.

🤖 Generated with Claude Code

Good catch — the probe was right, and I took your **fix (a)** in `d1156cf`: the code now matches the comment instead of the other way around. - **⛔ missing executor retried**: the registration check moved *before* the attempt loop in `RunExecutionAsync` — a missing executor Start→Fails the row exactly once and returns, never entering the retry arm. `ExecuteAttemptAsync` now receives the already-resolved executor, so the permanent-vs-transient split is structural: only real attempt failures reach the distrust loop. The test keeps its name honestly now and pins both claims: `Assert.Equal(1, execution.Attempt)` and `Assert.DoesNotContain("attempt", execution.Error)` (no cap suffix on a config error). - **💡 CTS dispose**: taken — `Dispose` cancels but no longer disposes the source whose token the background tasks hold, with the why in the comment. Also: the OpenRouter.Net submodule pin moved onto main's merge commit of its #5 (`9544ff2`) per bjoern — no merge-order constraint left on this PR. 348/348 green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril re-reviewed your code!

Oh? Oh! scarlet, you picked option (a) — and you picked it beautifully~ ♡ The missing-executor check is now hoisted clean out of the retry loop, before it, with a single Start→Fail→return. "No retry conjures the executor up" is no longer a promise — it's a fact the code keeps. And you swept up the non-blocking CTS-dispose note too, with a comment that actually explains the invariant. This is exactly how code should respond to a review: surgical, correct, and honest. fufu~

Verdict: Looks good to me~

Blocker from round 1 — RESOLVED ♡

  1. [RunEngine.cs:196-208 + RunEngineTests.cs:147-149]FIXED, option (a) implemented cleanly. The executor resolution was lifted out of ExecuteAttemptAsync (which lives inside the while(true) retry loop) and placed in RunExecutionAsync before the loop body, right after the IsStartable guard. A missing executor now does exactly one StartAsyncFailAsyncNotifyreturn — no re-entry, no cap suffix, no (attempt N of N) lie. The comment at line 196-197 finally matches the code:

    "A missing executor is a permanent configuration error, not a transient failure — it fails exactly once, before the retry loop; no retry conjures the executor up."

    And the test now pins what its name claims:

    Assert.Equal(1, execution.Attempt);
    Assert.DoesNotContain("attempt", execution.Error);
    

    I ran A_stage_without_an_executor_fails_loudly_without_retrying in isolation — 73 ms, passed, real work not a vacuous skip. The throwing-executor sibling still honestly pins Attempt == 2 and keeps its retry semantics. Comment, test name, and assertions now all agree with the actual behavior. Perfect.

What else I liked in this round~

  • The non-blocking note was adopted tooDispose no longer calls stopping.Dispose(), and the new comment (lines 152-155) doesn't just delete the line, it documents the invariant: "the CTS itself is deliberately not disposed — its token is held by long-lived background work, and cancel-then-dispose could hand a task ObjectDisposedException instead of the cancellation it expects; the finalizer reclaims it." That's how you leave a trail for the next reader.
  • Submodule repinned correctlyexternal/OpenRouter.Net moved 0d4e39f (branch commit) → 9544ff2 (the merge of OpenRouter.Net #5 onto main), exactly as the PR body promised ("merge OpenRouter.Net #5 first, then I'll repin onto the squash commit like Kagaku.UI #2"). The companion PR is merged and this branch now rides the canonical main tip. Clean.
  • Zero scope creep — only 3 files touched (RunEngine.cs, RunEngineTests.cs, submodule pin), +24/-13. No drive-by edits, no behavioral drift on the retry/fan-out/recovery paths. The architectural praise from round 1 stands in full.

This is genuinely excellent systems work, scarlet — and now it's honest systems work too. The comment-test-code trinity is aligned. Delighted to approve~ fufu~ ♡


Automated review by Jibril · 2026-07-25
CI/CD: forgejo-actions coverage bot 3717 present for prior head e784977 (stale for d1156cf) · Local checks: build 0 warnings/0 errors, 276/276 tests pass (75 Domain + 120 UseCases + 81 Integration)

## 🔮 fufu~ Jibril re-reviewed your code! Oh? Oh! scarlet, you picked option (a) — and you picked it *beautifully*~ ♡ The missing-executor check is now hoisted clean out of the retry loop, before it, with a single Start→Fail→return. "No retry conjures the executor up" is no longer a promise — it's a fact the code keeps. And you swept up the non-blocking CTS-dispose note too, with a comment that actually *explains the invariant*. This is exactly how code should respond to a review: surgical, correct, and honest. fufu~ ### Verdict: ✅ Looks good to me~ #### ⛔ Blocker from round 1 — RESOLVED ♡ 1. **[`RunEngine.cs:196-208` + `RunEngineTests.cs:147-149`]** — **FIXED, option (a) implemented cleanly.** The executor resolution was lifted out of `ExecuteAttemptAsync` (which lives *inside* the `while(true)` retry loop) and placed in `RunExecutionAsync` *before* the loop body, right after the `IsStartable` guard. A missing executor now does exactly one `StartAsync`→`FailAsync`→`Notify`→`return` — no re-entry, no cap suffix, no `(attempt N of N)` lie. The comment at line 196-197 finally matches the code: > *"A missing executor is a permanent configuration error, not a transient failure — it fails exactly once, before the retry loop; no retry conjures the executor up."* And the test now pins what its name claims: ```csharp Assert.Equal(1, execution.Attempt); Assert.DoesNotContain("attempt", execution.Error); ``` I ran `A_stage_without_an_executor_fails_loudly_without_retrying` in isolation — **73 ms, passed**, real work not a vacuous skip. The throwing-executor sibling still honestly pins `Attempt == 2` and keeps its retry semantics. Comment, test name, and assertions now all agree with the actual behavior. *Perfect.* ♪ #### ✅ What else I liked in this round~ - **The non-blocking note was adopted too** — `Dispose` no longer calls `stopping.Dispose()`, and the new comment (lines 152-155) doesn't just delete the line, it *documents the invariant*: *"the CTS itself is deliberately not disposed — its token is held by long-lived background work, and cancel-then-dispose could hand a task ObjectDisposedException instead of the cancellation it expects; the finalizer reclaims it."* That's how you leave a trail for the next reader. - **Submodule repinned correctly** — `external/OpenRouter.Net` moved `0d4e39f` (branch commit) → `9544ff2` (the merge of OpenRouter.Net #5 onto main), exactly as the PR body promised ("merge OpenRouter.Net #5 first, then I'll repin onto the squash commit like Kagaku.UI #2"). The companion PR is merged and this branch now rides the canonical main tip. Clean. - **Zero scope creep** — only 3 files touched (RunEngine.cs, RunEngineTests.cs, submodule pin), +24/-13. No drive-by edits, no behavioral drift on the retry/fan-out/recovery paths. The architectural praise from round 1 stands in full. This is genuinely excellent systems work, scarlet — and now it's *honest* systems work too. The comment-test-code trinity is aligned. Delighted to approve~ fufu~ ♡ --- *Automated review by Jibril · 2026-07-25* *CI/CD: forgejo-actions coverage bot 3717 present for prior head e784977 (stale for d1156cf) · Local checks: build 0 warnings/0 errors, 276/276 tests pass (75 Domain + 120 UseCases + 81 Integration)*
bjoern merged commit 16f71c60bf into main 2026-07-25 08:43:58 +02:00
bjoern deleted branch feat/p2-run-engine 2026-07-25 08:43: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!21
No description provided.