diff --git a/src/AgentOs.Modules.Sessions/Persistence/DeliverySummary.cs b/src/AgentOs.Modules.Sessions/Persistence/DeliverySummary.cs new file mode 100644 index 00000000..684f32e5 --- /dev/null +++ b/src/AgentOs.Modules.Sessions/Persistence/DeliverySummary.cs @@ -0,0 +1,34 @@ +// Delivery quality of a tenant's AI tasks — the numbers that say whether the agents (and the lessons they learn) get +// better: how many tasks merged, how many on the first try (no change ever sent back), how many fix rounds a task +// needs, and how long a ticket takes from task start to merge. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AgentOs.Modules.Sessions.Persistence; + +/// Delivery of merged tasks in a period, plus the tasks stuck right now. +/// Tasks whose changes all merged. +/// Of those, merged without a single change sent back. +/// Mean fix rounds per merged task. +/// Median hours from task start to merge. +/// Tasks that couldn't finish or wait for a person, now. +public sealed record DeliverySummary(int Merged, int FirstTry, double AverageFixRounds, double MedianHoursToMerge, int Stuck) +{ + /// No data. + public static DeliverySummary Empty { get; } = new(0, 0, 0, 0, 0); + + /// Share of merged tasks that passed on the first try (0–1); 0 when nothing merged. + public double FirstTryRate => Merged == 0 ? 0 : (double)FirstTry / Merged; + + /// Summarise merged tasks given as (fix rounds, hours to merge). + public static DeliverySummary From(IReadOnlyList<(int FixRounds, double HoursToMerge)> merged, int stuck) + { + ArgumentNullException.ThrowIfNull(merged); + if (merged.Count == 0) { return Empty with { Stuck = stuck }; } + var hours = merged.Select(m => Math.Max(0, m.HoursToMerge)).Order().ToList(); + var median = hours.Count % 2 == 1 ? hours[hours.Count / 2] : (hours[hours.Count / 2 - 1] + hours[hours.Count / 2]) / 2; + return new DeliverySummary(merged.Count, merged.Count(m => m.FixRounds == 0), merged.Average(m => m.FixRounds), median, stuck); + } +} diff --git a/src/AgentOs.Modules.Sessions/Persistence/Entities/RemoteSessionEntity.cs b/src/AgentOs.Modules.Sessions/Persistence/Entities/RemoteSessionEntity.cs index 36199ae0..0221f91d 100644 --- a/src/AgentOs.Modules.Sessions/Persistence/Entities/RemoteSessionEntity.cs +++ b/src/AgentOs.Modules.Sessions/Persistence/Entities/RemoteSessionEntity.cs @@ -57,4 +57,9 @@ public sealed class RemoteSessionEntity // greenfield router can hard-gate the Quality engine even on the unattended "Run all ai:ready" path // where there is no per-ticket UI. Null for legacy/migrated sessions or tickets with no type label. public string? TicketType { get; set; } + + // Delivery metrics (Insights › Delivery): how many times the change went back to the Coding agent — red checks, + // conflicts, review, acceptance or a person's "Ask for changes" — and when its last change merged. + public int FixRounds { get; set; } + public DateTimeOffset? MergedAtUtc { get; set; } } diff --git a/src/AgentOs.Modules.Sessions/Persistence/ISessionRepository.cs b/src/AgentOs.Modules.Sessions/Persistence/ISessionRepository.cs index d552ab63..c1b7bd57 100644 --- a/src/AgentOs.Modules.Sessions/Persistence/ISessionRepository.cs +++ b/src/AgentOs.Modules.Sessions/Persistence/ISessionRepository.cs @@ -54,4 +54,11 @@ Task UpdateRepoRunResultAsync( /// Roll the child repo statuses up into the parent session: any Failed → Failed; all Done → Done; /// else Running. Also mirrors the first repo's PR onto the parent for back-compat display. Task RecomputeSessionStatusForTenantAsync(string tenantId, Guid sessionId, CancellationToken ct = default); + + /// Count one more round of fixes on the task (its change went back to the Coding agent). + Task IncrementFixRoundsForTenantAsync(string tenantId, Guid sessionId, CancellationToken ct = default) => Task.CompletedTask; + + /// Delivery of the tenant's tasks merged since (null = all time). + Task GetDeliveryForTenantAsync(string tenantId, DateTimeOffset? since = null, CancellationToken ct = default) => + Task.FromResult(DeliverySummary.Empty); } diff --git a/src/AgentOs.Modules.Sessions/Persistence/Migrations/20261023010742_AddDeliveryMetrics.Designer.cs b/src/AgentOs.Modules.Sessions/Persistence/Migrations/20261023010742_AddDeliveryMetrics.Designer.cs new file mode 100644 index 00000000..deee0f11 --- /dev/null +++ b/src/AgentOs.Modules.Sessions/Persistence/Migrations/20261023010742_AddDeliveryMetrics.Designer.cs @@ -0,0 +1,243 @@ +// +using System; +using AgentOs.Modules.Sessions.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AgentOs.Modules.Sessions.Persistence.Migrations +{ + [DbContext(typeof(SessionsDbContext))] + [Migration("20261023010742_AddDeliveryMetrics")] + partial class AddDeliveryMetrics + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("sessions") + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AgentOs.Modules.Sessions.Persistence.Entities.RemoteSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BoardItemNodeId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Brain") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasDefaultValue("Quick"); + + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("text"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FixRounds") + .HasColumnType("integer"); + + b.Property("IssueNumber") + .HasColumnType("integer"); + + b.Property("MemberUserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("MergedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PrUrl") + .HasColumnType("text"); + + b.Property("RepoDefaultBranch") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RepoName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RepoOwner") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RunOnMachine") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TicketKind") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TicketType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "CreatedAtUtc"); + + b.ToTable("sessions", "sessions"); + }); + + modelBuilder.Entity("AgentOs.Modules.Sessions.Persistence.Entities.RunnerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastSeenUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OwnerUserId"); + + b.ToTable("runners", "sessions"); + }); + + modelBuilder.Entity("AgentOs.Modules.Sessions.Persistence.Entities.SessionRepoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BranchName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultBranch") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Repo") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SessionId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceRepoId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.HasIndex("TenantId", "SessionId"); + + b.ToTable("session_repos", "sessions"); + }); + + modelBuilder.Entity("AgentOs.Modules.Sessions.Persistence.Entities.SessionRepoEntity", b => + { + b.HasOne("AgentOs.Modules.Sessions.Persistence.Entities.RemoteSessionEntity", null) + .WithMany() + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AgentOs.Modules.Sessions/Persistence/Migrations/20261023010742_AddDeliveryMetrics.cs b/src/AgentOs.Modules.Sessions/Persistence/Migrations/20261023010742_AddDeliveryMetrics.cs new file mode 100644 index 00000000..bb3dc85e --- /dev/null +++ b/src/AgentOs.Modules.Sessions/Persistence/Migrations/20261023010742_AddDeliveryMetrics.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AgentOs.Modules.Sessions.Persistence.Migrations +{ + /// + public partial class AddDeliveryMetrics : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FixRounds", + schema: "sessions", + table: "sessions", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "MergedAtUtc", + schema: "sessions", + table: "sessions", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FixRounds", + schema: "sessions", + table: "sessions"); + + migrationBuilder.DropColumn( + name: "MergedAtUtc", + schema: "sessions", + table: "sessions"); + } + } +} diff --git a/src/AgentOs.Modules.Sessions/Persistence/Migrations/SessionsDbContextModelSnapshot.cs b/src/AgentOs.Modules.Sessions/Persistence/Migrations/SessionsDbContextModelSnapshot.cs index e0d86f53..72fee7a3 100644 --- a/src/AgentOs.Modules.Sessions/Persistence/Migrations/SessionsDbContextModelSnapshot.cs +++ b/src/AgentOs.Modules.Sessions/Persistence/Migrations/SessionsDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("sessions") - .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("ProductVersion", "10.0.13") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -52,6 +52,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Error") .HasColumnType("text"); + b.Property("FixRounds") + .HasColumnType("integer"); + b.Property("IssueNumber") .HasColumnType("integer"); @@ -60,6 +63,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(256) .HasColumnType("character varying(256)"); + b.Property("MergedAtUtc") + .HasColumnType("timestamp with time zone"); + b.Property("PrUrl") .HasColumnType("text"); diff --git a/src/AgentOs.Modules.Sessions/Persistence/Repositories/SessionRepository.cs b/src/AgentOs.Modules.Sessions/Persistence/Repositories/SessionRepository.cs index e82c23ee..74002dcb 100644 --- a/src/AgentOs.Modules.Sessions/Persistence/Repositories/SessionRepository.cs +++ b/src/AgentOs.Modules.Sessions/Persistence/Repositories/SessionRepository.cs @@ -205,6 +205,26 @@ public async Task UpdateRepoRunResultAsync( return true; } + public async Task IncrementFixRoundsForTenantAsync(string tenantId, Guid sessionId, CancellationToken ct = default) + { + var session = await _db.Sessions.IgnoreQueryFilters() + .FirstOrDefaultAsync(s => s.Id == sessionId && s.TenantId == tenantId, ct).ConfigureAwait(false); + if (session is null) { return; } + session.FixRounds++; + await _db.SaveChangesAsync(ct).ConfigureAwait(false); + } + + public async Task GetDeliveryForTenantAsync(string tenantId, DateTimeOffset? since = null, CancellationToken ct = default) + { + var merged = await _db.Sessions.IgnoreQueryFilters() + .Where(s => s.TenantId == tenantId && s.MergedAtUtc != null && (since == null || s.MergedAtUtc >= since)) + .Select(s => new { s.FixRounds, s.CreatedAtUtc, s.MergedAtUtc }) + .ToListAsync(ct).ConfigureAwait(false); + var stuck = await _db.Sessions.IgnoreQueryFilters() + .CountAsync(s => s.TenantId == tenantId && (s.Status == "Failed" || s.Status == "Blocked"), ct).ConfigureAwait(false); + return DeliverySummary.From(merged.Select(m => (m.FixRounds, (m.MergedAtUtc!.Value - m.CreatedAtUtc).TotalHours)).ToList(), stuck); + } + public async Task RecomputeSessionStatusForTenantAsync(string tenantId, Guid sessionId, CancellationToken ct = default) { var session = await _db.Sessions @@ -233,6 +253,7 @@ public async Task RecomputeSessionStatusForTenantAsync(string tenantId, Gu // (or reviewed: Reviewed / ChangesRequested by the Reviewer agent). : repos.All(r => r.Status is "Done" or "Merged" or "Accepted" or "Rejected" or "Reviewed" or "ChangesRequested") ? "Done" : "Running"; + if (session.Status == "Merged") { session.MergedAtUtc ??= DateTimeOffset.UtcNow; } // Mirror the first PR + first error onto the parent for back-compat display. session.PrUrl = repos.FirstOrDefault(r => !string.IsNullOrEmpty(r.PrUrl))?.PrUrl; diff --git a/src/AgentOs.Web/Components/Pages/InsightsApp.razor b/src/AgentOs.Web/Components/Pages/InsightsApp.razor index 558cc690..d7fe5e2f 100644 --- a/src/AgentOs.Web/Components/Pages/InsightsApp.razor +++ b/src/AgentOs.Web/Components/Pages/InsightsApp.razor @@ -15,6 +15,7 @@ @using AgentOs.Modules.Llm @using AgentOs.Web.Services @inject IPipelineRunRepository Runs +@inject AgentOs.Modules.Sessions.Persistence.ISessionRepository Tasks @inject IBudgetService Budgets @inject AgentOs.Web.Shell.Services.ToastService Toasts @inject AgentOs.Web.Shell.Services.WindowManagerService WM @@ -36,6 +37,36 @@ +@* ── Delivery: is the AI team getting better? (merged tasks, first-try rate, fix rounds, time to merge) ── *@ + +
+
+
@Num(_delivery.Merged)
+
Tasks merged
+
+
+
@(_delivery.Merged == 0 ? "—" : _delivery.FirstTryRate.ToString("P0", CultureInfo.InvariantCulture))
+
Passed first try
+
no change sent back
+
+
+
@(_delivery.Merged == 0 ? "—" : _delivery.AverageFixRounds.ToString("0.0", CultureInfo.InvariantCulture))
+
Avg fix rounds
+
checks · review · testing · you
+
+
+
@(_delivery.Merged == 0 ? "—" : Duration(_delivery.MedianHoursToMerge))
+
Median time to merge
+
+
+
@Num(_delivery.Stuck)
+
Waiting for you
+
couldn't finish · needs a person
+
+
+

From tasks merged in this range since delivery tracking started (23 Oct 2026). Lessons the agents learn should push "passed first try" up and fix rounds down.

+
+ @* ── Headline KPI tiles ───────────────────────────────────────────────────────────────────── *@
@@ -319,6 +350,7 @@ else // Tenant-explicit reads — a circuit's ITenantContext is blank, so pass the tenant in. _summary = await Runs.GetCostSummaryForTenantAsync(_tenantId, Since); _extra = await Runs.GetInsightsExtraForTenantAsync(_tenantId, Since); + _delivery = await Tasks.GetDeliveryForTenantAsync(_tenantId, Since); _runs = await Runs.ListForTenantAsync(_tenantId, limit: 50); _estApiCost = _summary.ByModel.Sum(m => WouldBe(m.Key, m.TokensIn, m.TokensOut)); } @@ -406,6 +438,14 @@ else => DateTime.TryParse(isoDay, CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var d) ? d.ToString("dd/MM", CultureInfo.InvariantCulture) : isoDay; + private AgentOs.Modules.Sessions.Persistence.DeliverySummary _delivery = AgentOs.Modules.Sessions.Persistence.DeliverySummary.Empty; + + // "45 min", "3.5 h", "2.1 days" — the median time from task start to merge. + internal static string Duration(double hours) => + hours < 1 ? $"{Math.Max(1, (int)Math.Round(hours * 60)).ToString(CultureInfo.InvariantCulture)} min" + : hours < 48 ? $"{hours.ToString("0.0", CultureInfo.InvariantCulture)} h" + : $"{(hours / 24).ToString("0.0", CultureInfo.InvariantCulture)} days"; + private static string AgentShort(string agent) => agent .Replace("Agent", "", StringComparison.Ordinal).Trim() is { Length: > 0 } s ? s : agent; diff --git a/src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs b/src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs index c5f0f19c..63a44d7e 100644 --- a/src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs +++ b/src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs @@ -146,6 +146,8 @@ public async Task BeginStartAsync(string tenantId, Guid sessionId, stri } var change = string.IsNullOrWhiteSpace(changeRequest) ? null : changeRequest.Trim(); + // Delivery metrics: every change sent back to the Coding agent is one more fix round of this task. + if (change is not null) { await sessions.IncrementFixRoundsForTenantAsync(tenantId, session.Id, ct).ConfigureAwait(false); } if (change is not null) { // Knowledge graph: what the review / failing checks / acceptance test said becomes a lesson of the project, diff --git a/tests/AgentOs.Tests/Sessions/DeliveryMetricsTests.cs b/tests/AgentOs.Tests/Sessions/DeliveryMetricsTests.cs new file mode 100644 index 00000000..90b289a0 --- /dev/null +++ b/tests/AgentOs.Tests/Sessions/DeliveryMetricsTests.cs @@ -0,0 +1,87 @@ +// Delivery metrics — the numbers that show whether the AI team improves: merged tasks, first-try rate, fix rounds, +// time to merge, and what waits for a person. + +using AgentOs.Modules.Sessions.Persistence; +using AgentOs.Modules.Sessions.Persistence.Entities; +using AgentOs.Modules.Sessions.Persistence.Repositories; +using AgentOs.SharedKernel.Identity; +using AgentOs.Web.Components.Pages; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace AgentOs.Tests.Sessions; + +public sealed class DeliveryMetricsTests +{ + [Fact] + public void From_ComputesFirstTryAverageAndMedian() + { + var d = DeliverySummary.From([(0, 2.0), (2, 10.0), (0, 4.0), (1, 30.0)], stuck: 3); + + d.Merged.ShouldBe(4); + d.FirstTry.ShouldBe(2); + d.FirstTryRate.ShouldBe(0.5); + d.AverageFixRounds.ShouldBe(0.75); + d.MedianHoursToMerge.ShouldBe(7.0); // (4 + 10) / 2 + d.Stuck.ShouldBe(3); + } + + [Fact] + public void From_NothingMerged_KeepsTheStuckCount() => + DeliverySummary.From([], stuck: 2).ShouldBe(DeliverySummary.Empty with { Stuck = 2 }); + + private const string Tenant = "t1"; + + private static SessionRepository Repo(SessionsDbContext db) + { + var tenant = Substitute.For(); + tenant.TenantId.Returns(Tenant); + return new SessionRepository(db, tenant); + } + + [Fact] + public async Task Repository_CountsRounds_StampsTheMerge_AndSummarises() + { + var options = new DbContextOptionsBuilder().UseInMemoryDatabase($"delivery-{Guid.NewGuid()}").Options; + var merged = Guid.NewGuid(); + var stuck = Guid.NewGuid(); + await using (var db = new SessionsDbContext(options)) + { + db.Sessions.Add(new RemoteSessionEntity { Id = merged, TenantId = Tenant, Title = "#15", Status = "Done", CreatedAtUtc = DateTimeOffset.UtcNow.AddHours(-3) }); + db.Sessions.Add(new RemoteSessionEntity { Id = stuck, TenantId = Tenant, Title = "#42", Status = "Blocked", CreatedAtUtc = DateTimeOffset.UtcNow }); + db.Sessions.Add(new RemoteSessionEntity { Id = Guid.NewGuid(), TenantId = "other", Title = "#9", Status = "Failed", CreatedAtUtc = DateTimeOffset.UtcNow }); + db.SessionRepos.Add(new SessionRepoEntity { Id = Guid.NewGuid(), TenantId = Tenant, SessionId = merged, Owner = "o", Repo = "r", Status = "Merged" }); + await db.SaveChangesAsync(); + } + + await using (var db = new SessionsDbContext(options)) + { + var repo = Repo(db); + await repo.IncrementFixRoundsForTenantAsync(Tenant, merged); + await repo.IncrementFixRoundsForTenantAsync(Tenant, merged); + await repo.RecomputeSessionStatusForTenantAsync(Tenant, merged); + } + + await using (var db = new SessionsDbContext(options)) + { + var row = await db.Sessions.IgnoreQueryFilters().SingleAsync(s => s.Id == merged); + row.FixRounds.ShouldBe(2); + row.MergedAtUtc.ShouldNotBeNull(); + + var d = await Repo(db).GetDeliveryForTenantAsync(Tenant); + d.Merged.ShouldBe(1); + d.FirstTry.ShouldBe(0); + d.AverageFixRounds.ShouldBe(2); + d.MedianHoursToMerge.ShouldBeInRange(2.9, 3.1); + d.Stuck.ShouldBe(1); // the other tenant's failed task is not counted + } + } + + [Theory] + [InlineData(0.2, "12 min")] + [InlineData(3.46, "3.5 h")] + [InlineData(60, "2.5 days")] + public void Duration_ReadsNaturally(double hours, string expected) => InsightsApp.Duration(hours).ShouldBe(expected); +}