Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace Taskdeck.Application.Interfaces;

public enum ObservationSaveOutcome { Saved, SourceChanged, StorageBusy }
public enum ObservationSaveOutcome { Saved, SourceChanged, StorageBusy, ConcurrentWrite }

public interface IWorkspaceInsightRepository
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Suggest zero to three useful clarification questions about the supplied card.
kind is next-step, outcome, or dependency. Use each kind at most once.
question is a question, not a factual claim or instruction to change a board (max 240 characters).
reason explains why asking is useful (max 600 characters). quote is an exact, nonempty excerpt
from the supplied Text (max 400 characters). Cite only this source; do not invent people, dates,
from the supplied excerpt (max 400 characters). Cite only this source; do not invent people, dates,
dependencies, missing decisions or urgency. Do not repeat an already answered question, paraphrase
a clear next step as a problem, or ask for detail that would not change the work. Return [] when
the source already gives a clear outcome and next step. No Markdown, tools or board operations.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public async Task<Result<List<QuietInsightDto>>> GenerateAsync(Guid userId, Gene
if (health.IsMock || !health.IsAvailable)
return Result.Failure<List<QuietInsightDto>>(ErrorCodes.InvalidOperation, "Model analysis needs an available configured provider. Structural analysis remains available.");

var request = new ChatCompletionRequest([new("user", JsonSerializer.Serialize(source))], MaxTokens: 1600, Temperature: 0.2,
var request = new ChatCompletionRequest([new("user", source.Text)], MaxTokens: 1600, Temperature: 0.2,
Attribution: new(userId, Guid.NewGuid().ToString("N"), LlmRequestSourceSurface.Chat, dto.BoardId),
SystemPrompt: WorkspaceObservationContract.Prompt);
// UTF-8 byte count is a conservative input-token ceiling, plus bounded output and framing.
Expand Down Expand Up @@ -89,6 +89,8 @@ public async Task<Result<List<QuietInsightDto>>> GenerateAsync(Guid userId, Gene
return await repository.SaveObservationAsync(userId, dto.BoardId, dto.CardId, source.Fingerprint, ct) switch
{
ObservationSaveOutcome.Saved => Result.Success(result),
ObservationSaveOutcome.ConcurrentWrite => Result.Failure<List<QuietInsightDto>>(ErrorCodes.Conflict,
"Another request changed your question results. This request saved no observations. Reload Quiet insights to read the current results. Model usage was already accounted for; analyzing again uses budget again."),
ObservationSaveOutcome.StorageBusy => Result.Failure<List<QuietInsightDto>>(ErrorCodes.UnexpectedError,
"Storage was busy. No observations were saved. Model usage was already accounted for; analyzing again uses budget again. Wait, then preview the evidence before deciding whether to retry."),
_ => Changed(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ public async Task<ObservationSaveOutcome> SaveObservationAsync(Guid userId, Guid
committed = true;
return ObservationSaveOutcome.Saved;
}
catch (DbUpdateConcurrencyException) { return ObservationSaveOutcome.SourceChanged; }
catch (DbUpdateConcurrencyException) { return ObservationSaveOutcome.ConcurrentWrite; }
catch (DbUpdateException ex) when (ex.InnerException is Microsoft.Data.Sqlite.SqliteException { SqliteExtendedErrorCode: 2067 }) { return ObservationSaveOutcome.ConcurrentWrite; }
catch (DbUpdateException ex) when (ex.InnerException is Microsoft.Data.Sqlite.SqliteException { SqliteErrorCode: 19 }) { return ObservationSaveOutcome.SourceChanged; }
catch (DbUpdateException ex) when (ex.InnerException is Microsoft.Data.Sqlite.SqliteException { SqliteErrorCode: 5 or 6 }) { return ObservationSaveOutcome.StorageBusy; }
catch (Microsoft.Data.Sqlite.SqliteException ex) when (ex.SqliteErrorCode is 5 or 6) { return ObservationSaveOutcome.StorageBusy; }
Expand Down
38 changes: 37 additions & 1 deletion backend/tests/Taskdeck.Api.Tests/WorkspaceObservationApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,41 @@ public async Task BusyWriteLockIsNotSourceChangeAndRejectedQuestionsCannotLeakIn
(await db.Set<QuietInsight>().CountAsync(x => x.BoardId == board)).Should().Be(0);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CompetingQuestionWritesReportConcurrencyWithoutChangingTheSourceOrLeakingTheLoser(bool updateExisting)
{
using var app = WithProvider(new Provider()); var (_, board, card, source) = await Setup(app);
using var scope = app.Services.CreateScope(); var first = scope.ServiceProvider.GetRequiredService<TaskdeckDbContext>();
var user = (await first.Boards.FindAsync(board))!.OwnerId!.Value;
await using var second = new TaskdeckDbContext(new DbContextOptionsBuilder<TaskdeckDbContext>()
.UseSqlite(first.Database.GetConnectionString()!).Options);
var firstRepository = new WorkspaceInsightRepository(first); var secondRepository = new WorkspaceInsightRepository(second);
var winner = new QuietInsight(user, board, "model-question-next-step", card.ToString(), card, null);
firstRepository.Add(winner);
QuietInsight loser;
if (updateExisting)
{
await first.SaveChangesAsync();
loser = await second.Set<QuietInsight>().SingleAsync(x => x.Id == winner.Id);
}
else
{
loser = new QuietInsight(user, board, winner.Rule, winner.TargetKey, card, null);
secondRepository.Add(loser);
}
// Both requests hold their question state before either attempts its observation commit.
winner.Refresh("First result", "Winner", "Original evidence", DateTimeOffset.UtcNow);
loser.Refresh("Second result", "Loser", "Different generated evidence", DateTimeOffset.UtcNow);
(await firstRepository.SaveObservationAsync(user, board, card, source.Fingerprint, default)).Should().Be(ObservationSaveOutcome.Saved);
(await secondRepository.SaveObservationAsync(user, board, card, source.Fingerprint, default)).ToString().Should().Be("ConcurrentWrite");
await second.SaveChangesAsync();
var retained = await second.Set<QuietInsight>().AsNoTracking().SingleAsync(x => x.BoardId == board);
retained.Title.Should().Be("First result"); retained.Evidence.Should().Be("Original evidence");
(await new WorkspaceObservationReader(second).SourceAsync(user, board, card, default))!.Fingerprint.Should().Be(source.Fingerprint);
}

private sealed class Provider : ILlmProvider
{
public int Calls;
Expand Down Expand Up @@ -163,7 +198,8 @@ public async Task QuotedQuestions_ArePrivateDeduplicatedAndDoNotChangeBoard()
var again = await Generate(client, board, source); again.EnsureSuccessStatusCode();
(await again.Content.ReadFromJsonAsync<List<QuietInsightDto>>())!.Single().Id.Should().Be(insight.Id);
provider.Request!.Messages.Should().ContainSingle();
provider.Request.Messages[0].Content.Should().Contain(source.Fingerprint);
provider.Request.Messages[0].Content.Should().Be(source.Text)
.And.NotContain(source.Fingerprint).And.NotContain(cardId.ToString());
(await client.GetFromJsonAsync<List<QuietInsightDto>>($"/api/workspace-insights?boardId={board}"))!.Single().State.Should().Be("available");
(await client.PatchAsJsonAsync($"/api/workspace-insights/{insight.Id}", new InsightActionDto("dismiss"))).EnsureSuccessStatusCode();
var dismissed = await Generate(client, board, source); dismissed.EnsureSuccessStatusCode();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ namespace Taskdeck.Application.Tests.Services;

public class WorkspaceObservationTests
{
[Fact]
public async Task ConcurrentWriteReportsCurrentResultsAndAlreadyAccountedUsageWithoutRetry()
{
var (service, _, provider, quota, _, repository, source) = Setup();
provider.Setup(x => x.CompleteAsync(It.IsAny<ChatCompletionRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new LlmCompletionResult("""[{"kind":"next-step","question":"What next?","reason":"Action unclear","quote":"Investigate"}]""", 77, false, Provider: "Fixture", Model: "fixture"));
repository.Setup(x => x.SaveObservationAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(ObservationSaveOutcome.ConcurrentWrite);
var result = await service.GenerateAsync(Guid.NewGuid(), new(Guid.NewGuid(), source.CardId, source.Fingerprint), default);
result.IsSuccess.Should().BeFalse(); result.ErrorCode.Should().Be("Conflict");
result.ErrorMessage.Should().Contain("Another request").And.Contain("This request saved no observations")
.And.Contain("Reload Quiet insights").And.Contain("uses budget again").And.NotContain("source changed");
provider.Verify(x => x.CompleteAsync(It.IsAny<ChatCompletionRequest>(), It.IsAny<CancellationToken>()), Times.Once);
quota.Verify(x => x.CommitReservationAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), LlmSurface.Chat,
"Fixture", "fixture", 77, 0, CancellationToken.None), Times.Once);
}

[Fact]
public async Task BusyStorageReportsUnsavedOutcomeAndAlreadyAccountedUsageWithoutRetry()
{
Expand Down
6 changes: 4 additions & 2 deletions docs/IMPLEMENTATION_MASTERPLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

Last Updated: 2026-09-10

Finish exact-head hosted delivery of #2892, the implemented reminder-hours slice under #2808: optional named-zone weekly windows, explicit save/recovery, existing shared budgets and server-side eligibility. Local full backend/frontend, combined privacy/recovery and cross-browser proof pass. The final active-account write condition prevents erased private hours from being restored by an in-flight request. Old opted-in users retain unrestricted behavior; reminders remain default off.
The #2893/#2894 recovery follow-up is implemented in [PR #2895](https://github.com/Chris0Jeky/Taskdeck/pull/2895): preserve reminder drafts, report concurrent question writes accurately and align model payloads with the evidence preview. Keep the existing privacy/revision/usage boundaries, prove the reproduced cases and publish one reviewed follow-up. Local full suites, focused final payload checks, browser recovery and bounded independent review pass.

Overhaul closeout (#2808): #2866 and #2886 are merged with green required CI. All earlier continuation PRs are included; #2892 is the remaining delivery. Reconcile the tracker after that final merge. Scope reconciliation keeps generic processor hosting (#2258), historical representation migration (#2260) and undefined source-storage restore outside the requested prototype delivery. Retain live-provider/device/usefulness acceptance as unverified rather than inferring it from synthetic tests. Earlier execution notes below record the completed slice sequence.
Merged #2892 as 93eab3443 after required run 34453924959 passed at reviewed head 42965edde. The reminder-hours slice under #2808 delivers: optional named-zone weekly windows, explicit save/recovery, existing shared budgets and server-side eligibility. Local full backend/frontend, combined privacy/recovery and cross-browser proof pass. The final active-account write condition prevents erased private hours from being restored by an in-flight request. Old opted-in users retain unrestricted behavior; reminders remain default off.

Overhaul closeout (#2808): #2866 and #2886 are merged with green required CI. All earlier continuation PRs are included; #2892 is also merged. Reconcile the tracker after the final recovery follow-up merges. Scope reconciliation keeps generic processor hosting (#2258), historical representation migration (#2260) and undefined source-storage restore outside the requested prototype delivery. Retain live-provider/device/usefulness acceptance as unverified rather than inferring it from synthetic tests. Earlier execution notes below record the completed slice sequence.

Grounded-question commit recovery (#2808): close the final source-read/save interval and settle accounting before staging questions. Keep short transaction ownership in the insight repository, preserve source/access and insight concurrency checks, and prove no rejected staged state can leak into subsequent saves. This completes the two previously tracked observation consistency gaps; general semantic processing and recall remain separate.

Expand Down
6 changes: 4 additions & 2 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

Last Updated: 2026-09-10

Optional reminder hours (#2808, pending delivery): explicit weekday and time windows in an IANA zone, including overnight and daylight-saving behavior. Server checks precede question lookup and budget admission; preference edits preserve the shared UTC allowance, old-client toggles retain the window, and exports include it without a new migration. The slice passed 9,270 backend and 6,421 frontend tests, with 34/three existing skips respectively. After parent integration, 56 API tests, 56 component tests, typecheck/build and five Chromium journeys pass, including a reproduced and fixed account-erasure race. Firefox and mobile Grove/Grove Night evidence are recorded in the validation ledger. Exact-head hosted qualification remains pending. [Policy](product/WORKSPACE_ATTENTION.md).
Final overhaul recovery follow-up (#2893/#2894, [PR #2895](https://github.com/Chris0Jeky/Taskdeck/pull/2895)): retain an unsaved hours draft across enable-only saves and confirmed validation rejections, distinguish competing question writes from source changes, and send the model exactly the previewed excerpt. Final frontend passes 6,436 tests with three existing skips; full backend passes 9,315 with 34 existing skips, followed by 17 API and 19 application tests for the final payload correction. Build/typecheck, scoped lint, browser recovery and bounded independent review pass. Required hosted runs 34458797270 and 34462673996 passed at reviewed heads f39cc6cf3 and d25edf2a5, including both operating-system suites and browser smoke. The PR records subsequent base qualification and the final merge receipt. The source and draft regressions were reproduced before repair; the validation ledger records their scope.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not claim a merge receipt before it exists

This marks PR #2895 as having a final merge receipt, but the changed validation ledger still says hosted exact-head qualification remains the merge gate (WORKSPACE_OVERHAUL_VALIDATION.md lines 28–29), and unlike the completed PRs below, no merge SHA or date is recorded. Since STATUS.md is the shipped-reality source of truth, keep this entry pending until the merge occurs or replace the claim with the actual receipt afterward.

AGENTS.md reference: AGENTS.md:L15-L18

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Classified MEDIUM/P2 wording ambiguity, non-blocking. This paragraph records two completed hosted runs and directs readers to this PR for subsequent base qualification and the final receipt; it does not supply or verify a merge SHA. For clarity: #2895 is OPEN and unmerged, current head ba6dcf5, required run 34469579196 is still in progress. No final merge receipt exists yet. The actual merge SHA and exact-head success will be posted here only after the gate passes and the merge succeeds. Declining another documentation-only head push/CI restart for this phrasing; the delivery tracker remains explicitly pending.


Overhaul integrations #2866 and #2886 are merged at `384d8dfaa` and `f00cf1d31` respectively. Required CI passed at the reviewed #2886 head `fce97dc67`, including both operating-system API/unit suites and the final browser smoke job. The continuation delivers explicit transcription, route-exit recovery and grounded commit/account-erasure protection. Final local 6,428 frontend tests and ten browser journeys pass; the full backend's single unchanged worker-wait failure passes in the 30-case isolated/fix verification. Reminder work-hours/time-zone selection is implemented in #2892 and awaits its final hosted gate. Earlier pending-delivery notes below are historical slice evidence superseded by these merge receipts. [Full evidence](product/WORKSPACE_OVERHAUL_VALIDATION.md).
Optional reminder hours (#2808, merged #2892): explicit weekday and time windows in an IANA zone, including overnight and daylight-saving behavior. Server checks precede question lookup and budget admission; preference edits preserve the shared UTC allowance, old-client toggles retain the window, and exports include it without a new migration. The slice passed 9,270 backend and 6,421 frontend tests, with 34/three existing skips respectively. After parent integration, 56 API tests, 56 component tests, typecheck/build and five Chromium journeys pass, including a reproduced and fixed account-erasure race. Firefox and mobile Grove/Grove Night evidence are recorded in the validation ledger. Required CI run 34453924959 passed at reviewed head 42965edde; #2892 merged as 93eab3443. [Policy](product/WORKSPACE_ATTENTION.md).

Overhaul integrations #2866 and #2886 are merged at `384d8dfaa` and `f00cf1d31` respectively. Required CI passed at the reviewed #2886 head `fce97dc67`, including both operating-system API/unit suites and the final browser smoke job. The continuation delivers explicit transcription, route-exit recovery and grounded commit/account-erasure protection. Final local 6,428 frontend tests and ten browser journeys pass; the full backend's single unchanged worker-wait failure passes in the 30-case isolated/fix verification. Reminder work-hours/time-zone selection is merged in #2892. Earlier pending-delivery notes below are historical slice evidence superseded by these merge receipts. [Full evidence](product/WORKSPACE_OVERHAUL_VALIDATION.md).

Grounded-question commit recovery (#2808, pending main delivery): recheck source/access inside the same serializable transaction that saves observations, and detach rejected staged questions. Settle model usage before staging; accounting failure now has a definite no-save result and cannot hide a successful question save. Seventeen application and sixteen API/reminder tests pass, including changes after the final service read. Full backend verification passes 9,240 tests with 34 existing skips; bounded independent review is clean. Hosted qualification remains separate.

Expand Down
5 changes: 5 additions & 0 deletions docs/product/GROUNDED_OBSERVATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ batch. Existing structural **Analyze now** remains available with no model confi
A busy SQLite writer reports a storage failure rather than claiming the evidence changed. No
candidate is saved; the message explains that model usage was already accounted for and that a
new analysis uses budget again. The final transaction still detaches rejected candidates.
A competing question insert or revision update reports a concurrent write instead of a changed
source. This request saves no candidates; reload Quiet insights to read the current results before
deciding whether to spend budget on another analysis. The client preserves that specific recovery
message. Fingerprints and card identifiers remain server-side; the model user message contains
exactly the previewed excerpt.
- The producer shares the user's Chat request/token budget and kill switch. Its conservative input
estimate includes UTF-8 source/prompt bytes, bounded output and framing. Dispatch-aware settlement
retains billed usage after cancellation or invalid output. For accepted output, settlement occurs
Expand Down
5 changes: 4 additions & 1 deletion docs/product/WORKSPACE_ATTENTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ Optional **Reminder hours** restricts new reminders to selected weekdays and loc
IANA time zone, such as `Europe/London`. The default is unrestricted for existing opted-in accounts;
reminders themselves remain off by default. **Use this device's time zone** fills the field, and
**Save reminder hours** explicitly persists the choice across devices. Toggling reminders off/on
preserves the window. Clearing the restriction requires an explicit hours save.
preserves the saved window and any unsaved hours draft in the same account. Clearing the restriction
requires an explicit hours save. Confirmed validation rejections retain the draft for correction.
Account changes and uncertain saves clear the local form; reload
the server preference before continuing.

The start is included and the end excluded. An overnight window belongs to each selected starting
day: Monday 22:00–02:00 includes early Tuesday. Equal start/end times and invalid zones/days are
Expand Down
Loading
Loading