diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs index 3f85fee7d..41063139f 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs @@ -521,12 +521,14 @@ public async Task> GetStuckProcessingNonCaptureAsync( throw new ArgumentOutOfRangeException(nameof(limit), limit, "Limit must be at least 1."); } + var staleBeforeUtc = staleBefore.ToUniversalTime(); + if (_context.Database.IsSqlite()) { // SQLite's EF provider cannot translate WHERE/ORDER BY on a DateTimeOffset column, so the // staleness comparison + order + LIMIT live in raw SQL. The TEXT comparison is chronological - // because every UpdatedAt writer (Entity ctor/Touch and the raw claim UPDATEs) and staleBefore - // are all DateTimeOffset.UtcNow, so they share a fixed-width "+00:00" offset and lexical order + // because every UpdatedAt writer (Entity ctor/Touch and the raw claim UPDATEs) and the + // normalized cutoff are all fixed at a "+00:00" offset, so lexical order // equals chronological order -- the same shape the shipped // OutboundWebhookDeliveryRepository.GetStuckProcessingAsync relies on. // FromSqlInterpolated + Include wraps this in a subquery that does not guarantee the raw @@ -536,7 +538,7 @@ public async Task> GetStuckProcessingNonCaptureAsync( // UpdatedAt) and never reads the User/Board navigations, so loading them is wasted work. var rows = await _context.LlmRequests .FromSqlInterpolated( - $"SELECT * FROM LlmRequests WHERE Status = {(int)RequestStatus.Processing} AND RequestType NOT LIKE {CaptureRequestTypeLike} AND UpdatedAt <= {staleBefore} ORDER BY UpdatedAt ASC LIMIT {limit}") + $"SELECT * FROM LlmRequests WHERE Status = {(int)RequestStatus.Processing} AND RequestType NOT LIKE {CaptureRequestTypeLike} AND UpdatedAt <= {staleBeforeUtc} ORDER BY UpdatedAt ASC LIMIT {limit}") .ToListAsync(cancellationToken); return rows.OrderBy(lr => lr.UpdatedAt).ToList(); } @@ -546,7 +548,7 @@ public async Task> GetStuckProcessingNonCaptureAsync( return await _context.LlmRequests .Where(lr => lr.Status == RequestStatus.Processing && !EF.Functions.Like(lr.RequestType, CaptureRequestTypeLike) - && lr.UpdatedAt <= staleBefore) + && lr.UpdatedAt <= staleBeforeUtc) .OrderBy(lr => lr.UpdatedAt) .Take(limit) .ToListAsync(cancellationToken); diff --git a/backend/tests/Taskdeck.Api.Tests/LlmQueueRepositoryIntegrationTests.cs b/backend/tests/Taskdeck.Api.Tests/LlmQueueRepositoryIntegrationTests.cs index 33e3cfec8..8bd419a65 100644 --- a/backend/tests/Taskdeck.Api.Tests/LlmQueueRepositoryIntegrationTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/LlmQueueRepositoryIntegrationTests.cs @@ -630,6 +630,33 @@ await WithSqliteRepoAsync(async (db, repo) => }); } + [Fact] + public async Task GetStuckProcessingNonCaptureAsync_WithNonUtcThreshold_ComparesByInstant() + { + await WithSqliteRepoAsync(async (db, repo) => + { + var user = new User("stuck-offset", "stuck-offset@example.com", "hash"); + db.Users.Add(user); + + var stale = new LlmRequest(user.Id, "instruction", "{}"); + stale.MarkAsProcessing(); + var fresh = new LlmRequest(user.Id, "instruction", "{}"); + fresh.MarkAsProcessing(); + + var cutoffUtc = new DateTimeOffset(2026, 1, 1, 22, 0, 0, TimeSpan.Zero); + db.Entry(stale).Property(nameof(Entity.UpdatedAt)).CurrentValue = cutoffUtc.AddHours(-1); + db.Entry(fresh).Property(nameof(Entity.UpdatedAt)).CurrentValue = cutoffUtc.AddHours(1); + db.LlmRequests.AddRange(stale, fresh); + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + + var nonUtcCutoff = cutoffUtc.ToOffset(TimeSpan.FromHours(2)); + var stuck = await repo.GetStuckProcessingNonCaptureAsync(nonUtcCutoff, 50); + + stuck.Select(request => request.Id).Should().Equal(stale.Id); + }); + } + [Fact] public async Task GetStuckProcessingNonCaptureAsync_ReturnsOldestFirstAndRespectsLimit() {