Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ebf9c74
Map the PWA cache boundary and fix a false-green in the seam map
Chris0Jeky Sep 3, 2026
11d76ff
State the cache boundary as it actually is, not as unconditional
Chris0Jeky Sep 3, 2026
2d3d07d
Remove misleading review apply rate
Chris0Jeky Sep 3, 2026
6b5ff7a
Merge current main into review apply-rate removal
Chris0Jeky Sep 3, 2026
84a905c
Fix the second false-green, and correct the contract this row points at
Chris0Jeky Sep 3, 2026
f9e7f26
Record #2388 and #2299, and retract a sentence they made false
Chris0Jeky Sep 3, 2026
e30f067
Treat superseded Smart CI plans as non-red
Chris0Jeky Sep 3, 2026
80f3fbf
Align Smart CI receipt schema
Chris0Jeky Sep 3, 2026
b9ab692
Merge current main into review apply-rate removal
Chris0Jeky Sep 3, 2026
19baf99
Strike the two residuals #2388 delivered, so the entries stop contrad…
Chris0Jeky Sep 3, 2026
6d86464
Merge current main into Smart CI cancellation repair
Chris0Jeky Sep 3, 2026
9766a7d
Merge pull request #2409 from Chris0Jeky/coord/agent-index-reconcile
Chris0Jeky Sep 3, 2026
468d76d
Merge pull request #2414 from Chris0Jeky/coord/status-sync-2299-2388
Chris0Jeky Sep 3, 2026
9ededa2
Reconcile capture text before dispositions
Chris0Jeky Sep 3, 2026
3f41ae8
Merge current main into capture reconciliation
Chris0Jeky Sep 3, 2026
acee74c
Merge origin/main into issue-2327/smart-ci-cancelled-plan
Chris0Jeky Sep 3, 2026
7062a89
Reconcile already-cancelled captures
Chris0Jeky Sep 3, 2026
4f0f052
Merge pull request #2415 from Chris0Jeky/issue-2327/smart-ci-cancelle…
Chris0Jeky Sep 3, 2026
64a0aac
Refresh capture reconciliation on current main
Chris0Jeky Sep 3, 2026
3b60b2b
Bound board reads and cancel stale loads
Chris0Jeky Sep 3, 2026
c51f56a
Scope board load bounds to fan-out reads
Chris0Jeky Sep 3, 2026
eaa996f
Merge pull request #2417 from Chris0Jeky/codex-2347/reconcile-capture…
Chris0Jeky Sep 3, 2026
becefcd
Merge remote-tracking branch 'origin/main' into codex-1721/board-load…
Chris0Jeky Sep 3, 2026
07534d9
Merge pull request #2419 from Chris0Jeky/codex-1721/board-load-bounds
Chris0Jeky Sep 3, 2026
b577db1
Merge remote-tracking branch 'origin/codex-2205-remove-apply-rate' in…
Chris0Jeky Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 19 additions & 12 deletions autodoc/AGENT_INDEX.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,19 @@ private async Task ReconcileAsync(
{
var legacyState = ResolveLegacyState(request, payload);

// An archived capture is terminal: it rejects edits and projections alike, and the queue row
// has nothing left to teach it beyond the stamp.
// Archived is terminal for disposition, not evidence that source text agrees. The aggregate
// cannot accept a superseding asset once archived, so leave a mismatch outstanding and let
// the run's normal skip path retain queue-row fallback instead of stamping stale text away.
if (capture.Disposition == CaptureUserDisposition.Archived &&
!string.Equals(capture.CurrentText, payload.Text, StringComparison.Ordinal))
{
throw new DomainException(
ErrorCodes.ValidationError,
"Cannot reconcile source text on an archived capture");
}

// An archived capture whose text already agrees rejects the remaining projections; only its
// queue reconciliation stamp may move forward.
if (capture.Disposition != CaptureUserDisposition.Archived)
{
if (!string.IsNullOrWhiteSpace(payload.Text) &&
Expand Down
151 changes: 102 additions & 49 deletions backend/src/Taskdeck.Application/Services/CaptureService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -890,12 +890,7 @@ public async Task<Result<CaptureItemDto>> UpdateSuggestionAsync(
{
// The durable side must never be the reason an operation the queue row accepted fails.
// The aggregate is now behind its queue row. The divergence guard on the read path
// detects that and the reconcile pass repairs it on the next start -- PROVIDED no
// disposition change intervenes first. Keep, Archive and Reactivate all Touch the
// aggregate, which stamps it newer than the queue row that moved past it and defeats
// both the read guard and the divergence join, masking the divergence indefinitely.
// Tracked as #2347; until that lands, a divergence followed by a disposition write stays
// hidden.
// detects that and the reconcile pass repairs it on the next start.
_logger?.LogWarning(
ex,
"Context Fabric: could not record a superseding source for capture {CaptureId}; " +
Expand Down Expand Up @@ -923,26 +918,74 @@ private async Task<Result> CancelInternalAsync(
if (item.UserId != userId)
return Result.Failure(ErrorCodes.Forbidden, "You do not have permission to modify this capture item");

if (item.Status == RequestStatus.Cancelled)
return Result.Success();

var transactionOpen = false;
try
{
// Cancellation changes both the queue row and the durable aggregate. Use the same
// optimistic queue guard as Keep/Archive so a stale request cannot project an older
// payload into a capture that a concurrent edit has already corrected.
await _unitOfWork.BeginTransactionAsync(cancellationToken);
transactionOpen = true;

var expectedStatus = item.Status;
var expectedUpdatedAt = item.UpdatedAt;
item.Cancel();
var updated = await _unitOfWork.LlmQueue.TrySetCaptureDispositionAsync(
item.Id,
expectedStatus,
expectedUpdatedAt,
item.Status,
item.Payload,
cancellationToken);
if (!updated)
{
await _unitOfWork.RollbackTransactionAsync(cancellationToken);
transactionOpen = false;
return Result.Failure(
ErrorCodes.Conflict,
"Capture item changed while it was being cancelled");
}

var persistedPayload = ParsePayload(item);

// The user's disposition is a durable column now, not JSON on the queue row: putting a
// capture away records Archived on the aggregate's disposition axis in the same unit of
// work. Processing and action outcomes are deliberately left standing -- archiving is a
// decision about the Inbox, not an erasure of what was produced (ADR-0065 Decision 1).
await ApplyDurableDispositionAsync(userId, item.Id, CaptureDisposition.Archived, cancellationToken);
var durable = await ApplyDurableDispositionAsync(
userId,
item.Id,
CaptureDisposition.Archived,
persistedPayload.Text,
item.UpdatedAt,
cancellationToken);
if (durable is not null)
{
await _unitOfWork.SaveChangesAsync(cancellationToken);
}

await _unitOfWork.SaveChangesAsync(cancellationToken);
await _unitOfWork.CommitTransactionAsync(cancellationToken);
transactionOpen = false;
return Result.Success();
}
catch (DomainException ex)
{
if (transactionOpen)
{
await _unitOfWork.RollbackTransactionAsync(cancellationToken);
}

return Result.Failure(ex.ErrorCode, ex.Message);
}
catch
{
if (transactionOpen)
{
await _unitOfWork.RollbackTransactionAsync(cancellationToken);
}

throw;
}
}

/// <summary>
Expand All @@ -954,6 +997,8 @@ private async Task<Result> CancelInternalAsync(
Guid userId,
Guid captureId,
CaptureDisposition disposition,
string queueText,
DateTimeOffset queueUpdatedAt,
CancellationToken cancellationToken)
{
// Not gated on DualWriteCaptures, for the same reason as SupersedeDurableTextAsync: the flag
Expand All @@ -971,6 +1016,16 @@ private async Task<Result> CancelInternalAsync(

try
{
// CF-01c (#2347): this is the exact queue text the disposition CAS wrote. Repair any
// out-of-band divergence before Keep/Archive/Reactivate touches UpdatedAt; applying the
// disposition first would make stale durable text look newer than its queue row and
// hide it from both the read guard and reconcile backlog forever. Sources stay
// immutable: reconciliation appends a superseding asset.
if (!string.Equals(capture.CurrentText, queueText, StringComparison.Ordinal))
{
capture.SupersedeInlineTextSource(queueText);
}

switch (CaptureUserDispositionMapping.FromLegacy(disposition))
{
case CaptureUserDisposition.Archived:
Expand All @@ -983,6 +1038,8 @@ private async Task<Result> CancelInternalAsync(
capture.Reactivate();
break;
}

capture.RecordLegacyReconciliation(queueUpdatedAt);
}
catch (DomainException ex)
{
Expand Down Expand Up @@ -1019,43 +1076,29 @@ private async Task<Result<CaptureItemDto>> SetDispositionAsync(

var payload = ParsePayload(item);
var status = ResolveCaptureStatus(item, payload);

if (payload.Disposition?.Kind == disposition &&
var isIdempotentDisposition = payload.Disposition?.Kind == disposition &&
(status is CaptureStatus.New or CaptureStatus.Failed ||
disposition == CaptureDisposition.Archived && status == CaptureStatus.Ignored))
{
// The idempotent return still repairs the aggregate. The queue row already carries this
// disposition, so a durable row that does not is the residue of an interrupted attempt --
// and this early exit is the path every retry takes, so it has to be the path that heals.
var repaired = await ApplyDurableDispositionAsync(userId, item.Id, disposition, cancellationToken);
if (repaired is not null)
{
await _unitOfWork.SaveChangesAsync(cancellationToken);
}

return Result.Success(MapToDetailDto(
item,
payload,
effectiveBoardId: null,
await ReadableMaterialAsync(repaired, cancellationToken)));
}
disposition == CaptureDisposition.Archived && status == CaptureStatus.Ignored);

if (status is not CaptureStatus.New and not CaptureStatus.Failed)
if (!isIdempotentDisposition && status is not CaptureStatus.New and not CaptureStatus.Failed)
{
return Result.Failure<CaptureItemDto>(
ErrorCodes.Conflict,
$"Capture item cannot be {disposition.ToString().ToLowerInvariant()} from {status}");
}

var existingProposal = await _unitOfWork.AutomationProposals.GetBySourceReferenceAsync(
ProposalSourceType.Queue,
item.Id.ToString(),
cancellationToken);
if (existingProposal?.Status is ProposalStatus.PendingReview or ProposalStatus.Approved or ProposalStatus.Applied)
if (!isIdempotentDisposition)
{
return Result.Failure<CaptureItemDto>(
ErrorCodes.Conflict,
"Capture item already has a proposal in review or applied work");
var existingProposal = await _unitOfWork.AutomationProposals.GetBySourceReferenceAsync(
ProposalSourceType.Queue,
item.Id.ToString(),
cancellationToken);
if (existingProposal?.Status is ProposalStatus.PendingReview or ProposalStatus.Approved or ProposalStatus.Applied)
{
return Result.Failure<CaptureItemDto>(
ErrorCodes.Conflict,
"Capture item already has a proposal in review or applied work");
}
}

var transactionOpen = false;
Expand All @@ -1069,14 +1112,16 @@ private async Task<Result<CaptureItemDto>> SetDispositionAsync(

var expectedStatus = item.Status;
var expectedUpdatedAt = item.UpdatedAt;
var updatedPayload = payload with
{
Disposition = new CaptureDispositionV1(
disposition,
DateTimeOffset.UtcNow,
userId,
item.BoardId)
};
var updatedPayload = isIdempotentDisposition
? payload
: payload with
{
Disposition = new CaptureDispositionV1(
disposition,
DateTimeOffset.UtcNow,
userId,
item.BoardId)
};
var targetStatus = disposition == CaptureDisposition.Archived
? RequestStatus.Cancelled
: item.Status;
Expand All @@ -1101,9 +1146,17 @@ private async Task<Result<CaptureItemDto>> SetDispositionAsync(
"Capture item changed while its disposition was being recorded");
}

var persistedPayload = ParsePayload(item);

// Only after the conditional queue-row update actually won: a lost race must not leave
// the durable disposition axis ahead of the row it describes.
var durable = await ApplyDurableDispositionAsync(userId, item.Id, disposition, cancellationToken);
var durable = await ApplyDurableDispositionAsync(
userId,
item.Id,
disposition,
persistedPayload.Text,
item.UpdatedAt,
cancellationToken);
if (durable is not null)
{
await _unitOfWork.SaveChangesAsync(cancellationToken);
Expand All @@ -1114,7 +1167,7 @@ private async Task<Result<CaptureItemDto>> SetDispositionAsync(

return Result.Success(MapToDetailDto(
item,
updatedPayload,
persistedPayload,
effectiveBoardId: null,
await ReadableMaterialAsync(durable, cancellationToken)));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,7 @@ public sealed class ContextFabricSettings
/// durable write that fails is not retried inline. Re-enabling therefore does not simply resume:
/// the next <see cref="CaptureBackfillService"/> pass has to bring the window's captures in and
/// reconcile anything that drifted, and until it has, the read path serves whichever of the two
/// wrote last. Nothing a user sees goes backwards at any point in that sequence - provided no
/// disposition change (keep, archive, reactivate) intervenes before the next reconcile pass.
/// Such a write stamps the aggregate newer than the queue row that moved past it, which defeats
/// both the divergence guard and the reconcile pass and masks the divergence until
/// <c>#2347</c> lands.
/// wrote last. Nothing a user sees goes backwards at any point in that sequence.
/// </para>
/// </summary>
public bool DualWriteCaptures { get; set; } = true;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Taskdeck.Application.Interfaces;
using Taskdeck.Domain.Entities;
Expand Down Expand Up @@ -58,10 +59,12 @@ public async Task<IReadOnlyList<LlmRequest>> GetLegacyCaptureBacklogAsync(
// gives the Inbox listing. The NOT EXISTS clause is the divergence join: a row leaves the
// backlog only once a capture exists for it AND that capture is at least as fresh as the
// queue row. Oldest first, so the backlog drains in intake order.
// Rows this run has already failed on are excluded here rather than filtered afterwards,
// so a poisoned head cannot consume the whole batch on every iteration.
// FromSqlInterpolated parameterises every hole; the exclusion list is bounded by the
// number of distinct failures in one run, so it is fetched generously and trimmed below.
// Rows this run has already failed on are excluded by SQLite before LIMIT, so a poisoned
// head cannot consume the whole batch and no more than batchSize payloads are materialized.
// json_each carries every id in one collection parameter rather than one parameter per id,
// so a large poisoned set cannot hit SQLite's bound-variable ceiling.
var excludedJson = JsonSerializer.Serialize(
excluded.Select(id => id.ToString("D").ToUpperInvariant()));
FormattableString sql =
$"""
SELECT * FROM LlmRequests
Expand All @@ -70,19 +73,14 @@ AND NOT EXISTS (
SELECT 1 FROM Captures
WHERE Captures.Id = LlmRequests.Id
AND Captures.UpdatedAt >= LlmRequests.UpdatedAt)
AND Id NOT IN (SELECT value FROM json_each({excludedJson}))
ORDER BY CreatedAt, Id
LIMIT {batchSize + excluded.Count}
LIMIT {batchSize}
""";
var rows = await _context.LlmRequests
return await _context.LlmRequests
.FromSqlInterpolated(sql)
.AsNoTracking()
.ToListAsync(cancellationToken);
return rows
.Where(request => !excluded.Contains(request.Id))
.OrderBy(request => request.CreatedAt)
.ThenBy(request => request.Id.ToString(), StringComparer.Ordinal)
.Take(batchSize)
.ToList();
}

var query = Backlog;
Expand Down
Loading
Loading