diff --git a/QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs b/QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs index c8715fa32..80ab21db1 100644 --- a/QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs +++ b/QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs @@ -29,11 +29,17 @@ public class EmailMoveMonitorTests // (avoiding a compile-time WindowsBase dependency on System.Windows.Threading.Dispatcher) // and asserts it is unchanged after each test. The class is not parallelized because other // QuickFiler tests intentionally replace UiThread.Dispatcher with dedicated WPF dispatchers. + // + // The snapshot reads the private _dispatcher backing field rather than the public + // Dispatcher property (issue #584): the property getter now throws + // InvalidOperationException when the field is null, and PropertyInfo.GetValue would + // surface that as a TargetInvocationException from this class's setup and teardown. + // Reading the field observes the same state without invoking the guard. private object _capturedDispatcher; - private static readonly System.Reflection.PropertyInfo DispatcherProperty = - typeof(UiThread).GetProperty( - "Dispatcher", - System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static + private static readonly System.Reflection.FieldInfo DispatcherField = + typeof(UiThread).GetField( + "_dispatcher", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static ); /// @@ -46,7 +52,7 @@ public void Setup() { // Snapshot the static UiThread.Dispatcher (reflectively, to avoid WindowsBase) so // teardown can confirm no test mutated this set-once static state. - _capturedDispatcher = DispatcherProperty?.GetValue(null); + _capturedDispatcher = DispatcherField?.GetValue(null); _marshalInvocationCount = 0; } @@ -55,7 +61,7 @@ public void Cleanup() { // Assert the static dispatcher snapshot is unchanged so any accidental static mutation // is caught and tests remain order-independent. - object current = DispatcherProperty?.GetValue(null); + object current = DispatcherField?.GetValue(null); current.Should().BeSameAs(_capturedDispatcher); } diff --git a/UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs b/UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs index 985c4b0c1..556f8de5a 100644 --- a/UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs +++ b/UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs @@ -26,6 +26,7 @@ namespace UtilitiesCS.Test.Threading /// live ApplicationIdleTimer firing timing. /// [TestClass] + [DoNotParallelize] public class IdleAsyncQueue_Tests { #region Helpers diff --git a/UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs b/UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs index f26dab220..e0a6847ea 100644 --- a/UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs +++ b/UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs @@ -11,6 +11,7 @@ namespace UtilitiesCS.Test.Threading { [TestClass] + [DoNotParallelize] public class ProgressTrackerAsync_Tests { [TestMethod] diff --git a/UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs b/UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs index 9518c1e9a..d5c1c02de 100644 --- a/UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs +++ b/UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs @@ -11,7 +11,7 @@ namespace UtilitiesCS.Test { - [TestClass] + [TestClass, DoNotParallelize] public class ProgressTracker_Tests { [TestMethod] diff --git a/UtilitiesCS.Test/Threading/UiThread_Tests.cs b/UtilitiesCS.Test/Threading/UiThread_Tests.cs index a562737b2..34eb227e5 100644 --- a/UtilitiesCS.Test/Threading/UiThread_Tests.cs +++ b/UtilitiesCS.Test/Threading/UiThread_Tests.cs @@ -1,4 +1,5 @@ using System; +using System.Reflection; using System.Threading; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -101,4 +102,78 @@ public override void Post(SendOrPostCallback d, object state) } } } + + /// + /// Regression coverage for issue #584: the accessor contract of + /// UiThread.Dispatcher. + /// + /// Purpose: + /// Reflection is used to write the private static UiThread._dispatcher backing + /// field directly. The property has a private setter whose only production writer is + /// UiThread.Initialize(), which shows a real hidden WinForms window, so the + /// backing field is the one seam that lets a unit test place the accessor in each of + /// its two states. Driving the contract through that seam makes both tests + /// deterministic without any timing construct. + /// + /// Both tests capture the prior field value and put it back in a finally block, so the + /// process-global state is left exactly as it was found. + /// + [TestClass] + [DoNotParallelize] + public class UiThread_Dispatcher_Tests + { + private static FieldInfo DispatcherField() + { + return typeof(UiThread).GetField( + "_dispatcher", + BindingFlags.NonPublic | BindingFlags.Static + ); + } + + [TestMethod] + public void Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize() + { + // Arrange + var field = DispatcherField(); + field.Should().NotBeNull(); + var prior = field.GetValue(null); + field.SetValue(null, null); + try + { + // Act + Action act = () => + { + _ = UiThread.Dispatcher; + }; + + // Assert + act.Should() + .Throw() + .WithMessage("*UiThread.Initialize()*"); + } + finally + { + field.SetValue(null, prior); + } + } + + [TestMethod] + public void Dispatcher_WhenBackingFieldIsPopulated_ReturnsThatSameInstance() + { + // Arrange + var field = DispatcherField(); + var prior = field.GetValue(null); + var expected = System.Windows.Threading.Dispatcher.CurrentDispatcher; + field.SetValue(null, expected); + try + { + // Act / Assert + UiThread.Dispatcher.Should().BeSameAs(expected); + } + finally + { + field.SetValue(null, prior); + } + } + } } diff --git a/UtilitiesCS/Threading/UiThread.cs b/UtilitiesCS/Threading/UiThread.cs index 8663db033..20832f1dd 100644 --- a/UtilitiesCS/Threading/UiThread.cs +++ b/UtilitiesCS/Threading/UiThread.cs @@ -134,10 +134,19 @@ public static int UiThreadId public static Dispatcher Dispatcher { - get => _dispatcher; + get + { + if (_dispatcher is null) + { + throw new InvalidOperationException( + "The UI dispatcher has not been captured. Call UiThread.Init() so that UiThread.Initialize() runs before reading UiThread.Dispatcher." + ); + } + return _dispatcher; + } private set => _dispatcher = value; } - private static Dispatcher _dispatcher = null!; // set in Initialize() before any access + private static Dispatcher? _dispatcher; #endregion UI Thread Synchronization #region Other UI Methods and Properties diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/code-review.2026-09-04T04-05.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/code-review.2026-09-04T04-05.md new file mode 100644 index 000000000..8bf22df06 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/code-review.2026-09-04T04-05.md @@ -0,0 +1,287 @@ +# Code Review — uithread-dispatcher-null-race-progresstrackerasync (#584) + +- **Date:** 2026-09-04 +- **Timestamp:** 2026-09-04T04-05 +- **Base / merge-base:** `87cb4df338322844abfa580abea14df77e738e5c` +- **Branch:** `bug/uithread-dispatcher-null-race-progresstrackerasync-584` +- **Scope:** full branch diff — 6 files, 1 production, 5 test + +**BLOCKING FINDINGS COUNT (this artifact): 0** + +--- + +## 1. Overall Assessment + +The production change is the smallest correct fix for the reported defect. It converts an unguarded +static accessor from a silent-null return into an explicit, self-diagnosing failure, and it does so +at the single source rather than duplicating a guard across roughly forty call sites. The chosen +exception type and message shape match an idiom already established elsewhere in the same assembly +(`UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs:62-66`), so the change increases internal +consistency rather than introducing a new convention. + +Two aspects of the execution are worth naming specifically because they are stronger than typical: + +1. **The regression was found by the work, not by the reviewer.** The initial blast-radius census + matched only the literal qualified expression `UiThread.Dispatcher` and therefore could not match + a reflective read. That gap materialised as a real failure — 8 of 1312 in `QuickFiler.Test` — and + the response was to run the missing census (`p0-t14-reflective-dispatcher-census.md`), record the + limitation verbatim in `spec.md` "Risks & Mitigations", widen the write set, and add the sixth + file to AC4 with an amendment note explaining why omitting it was not tenable. That is the correct + handling of a discovered gap. +2. **The fail-before evidence is genuine at the assertion level, on both halves.** For the new tests, + `p1-t4-expect-fail.md` records `Failed: 1` with a FluentAssertions message + ("Expected a `` to be thrown, but no exception was thrown") on a + tree that built with `0 Error(s)`, while the sibling positive test passed in the same run — so the + red is attributable to the defect and not to the harness. For the `EmailMoveMonitorTests` repair, + `p4-t6-first-pass-failure.md` preserves the 8-of-1312 failure and `p4-t6-quickfiler-tests.md` + names all eight methods as passing afterwards. + +No defect was found in the delivered code. All findings below are non-blocking; the three Low items +are observations a maintainer may reasonably decline. + +## 2. Findings Table + +| ID | Severity | Blocking | File / location | Finding | +|---|---|---|---|---| +| CR-1 | Minor | No | `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` (whole file, 514 lines) | Exceeds the 500-line limit. Pre-existing at BASE at exactly 514; branch delta is zero. | +| CR-2 | Minor | No | `spec.md` "Rollout & Follow-up" item 1 | Deferred residual recorded only as feature-folder prose, which does not survive merge. | +| CR-3 | Low | No | `UtilitiesCS.Test/Threading/UiThread_Tests.cs:164` | Missing `field.Should().NotBeNull()` guard that the sibling test has at `:138`. | +| CR-4 | Low | No | `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs:55, 64` | `DispatcherField?.GetValue(null)` degrades to a vacuous assertion if `_dispatcher` is ever renamed. | +| CR-5 | Low | No | `UtilitiesCS.Test/Threading/UiThread_Tests.cs:166` | `Dispatcher.CurrentDispatcher` creates thread-affine WPF state on the MSTest worker thread that outlives the test. | +| CR-6 | Low | No | `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs:14` | Combined `[TestClass, DoNotParallelize]` form diverges stylistically from the three sibling files. Deliberate and documented. | +| CR-7 | Low | No | `UtilitiesCS/Threading/UiThread.cs:135-167` | Three static accessors in one class now have three different null policies. | + +## 3. Detailed Findings + +### CR-1 — `ProgressTracker_Tests.cs` is 514 lines (Minor, non-blocking) + +- **Rule:** `.claude/rules/general-code-change.md`, "File Size Limit" — 500 lines, applying to + production code, test code, and reusable scripts alike; test code is not excepted. +- **Evidence:** `evidence/baseline/p0-t13-parallel-bucket-census.md` (baseline 514), + `evidence/qa-gates/p2-t3-file-size.md` (head 514), + `evidence/qa-gates/p4-t8-loop-closure.md` (post-format 514). +- **Assessment.** The breach pre-dates the branch and the branch does not deepen it. The single-line + attribute addition was deliberately written as `[TestClass, DoNotParallelize]` on the existing + line precisely so the count would not move from 514 to 515 — the reasoning is recorded in + `p1-t5-donotparallelize.md` and re-checked after formatting in `p4-t8-loop-closure.md`, since + CSharpier could have re-wrapped the attribute list. It did not. +- **Recommendation:** extract a cohesive group of test methods into a `partial class` in a sibling + file, as separate follow-up work, with no weakening of any assertion. Not a condition of this merge. + +### CR-2 — Deferred residual is prose-only (Minor, non-blocking) + +`spec.md` "Rollout & Follow-up" records two candidate follow-ups. Item 2 (the `IUiDispatcher` seam +conversion of ~62 remaining direct reads across ~29 production files) is already durable on the +GitHub issue thread. Item 1 is not: + +> Add synchronization (or an injectable seam, per the already-partially-adopted `IUiDispatcher`) +> around `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`'s reflection-based mutation of +> `UiThread._dispatcher`, mirroring the fixture-level fix `#493` applied in `QuickFiler.Test`. + +The feature folder is removed when the branch merges, so this residual disappears with it. It is a +genuine test-isolation concern on a process-global static, of the same shape as an issue that was +already worth fixing once (#493) in a different assembly. + +**Recommendation:** promote item 1 to a GitHub issue before merge. + +### CR-3 — Asymmetric null guard on the reflection helper (Low, non-blocking) + +```csharp +// UiThread_Tests.cs:136-139 (first test — guarded) +var field = DispatcherField(); +field.Should().NotBeNull(); +var prior = field.GetValue(null); + +// UiThread_Tests.cs:163-165 (second test — unguarded) +var field = DispatcherField(); +var prior = field.GetValue(null); +``` + +`FieldInfo.GetField` returns `null` when the member does not exist. If `_dispatcher` is renamed in a +future refactor, the first test fails with a clear FluentAssertions message naming the expectation, +while the second fails with a bare `NullReferenceException` inside test setup — the exact class of +unattributed failure this issue exists to eliminate. `UiThread_Tests.cs` carries no `#nullable enable` +directive, so the compiler raises nothing here. + +**Recommendation:** add `field.Should().NotBeNull();` after line 164 to match the sibling. + +### CR-4 — Null-conditional reflection lookup can silently vacate the cleanup assertion (Low, non-blocking) + +```csharp +// EmailMoveMonitorTests.cs:55 (TestInitialize) and :64-65 (TestCleanup) +_capturedDispatcher = DispatcherField?.GetValue(null); +... +object current = DispatcherField?.GetValue(null); +current.Should().BeSameAs(_capturedDispatcher); +``` + +If `DispatcherField` is ever `null` — the field renamed, or its binding flags changed — both sides +become `null` and `current.Should().BeSameAs(_capturedDispatcher)` passes vacuously. The class then +silently loses the static-mutation detection its own comment says it exists to provide. + +This is not a defect introduced here: the pre-change code used `DispatcherProperty?.GetValue(null)` +with the identical `?.` shape, and the change only retargeted the member being looked up. AC4's +"unmodified assertions" clause also constrains how far this file could be improved in this change. +Noted so it is not lost. + +**Recommendation:** in a separate change, replace `?.` with a one-time `NotBeNull` assertion on the +cached `FieldInfo`. + +### CR-5 — `Dispatcher.CurrentDispatcher` leaves thread-affine state (Low, non-blocking) + +```csharp +// UiThread_Tests.cs:166 +var expected = System.Windows.Threading.Dispatcher.CurrentDispatcher; +``` + +`CurrentDispatcher` *creates* a `Dispatcher` for the calling thread if none exists and associates it +with that thread permanently. MSTest reuses worker threads, so the created dispatcher outlives the +test. The `UiThread._dispatcher` static itself is correctly restored in `finally`, so no cross-test +state leaks through the unit under test, and `[DoNotParallelize]` prevents interleaving. + +The residue is a WPF dispatcher object bound to a pooled thread, with no message pump started +(`PushFrame` is correctly absent, and its absence is affirmatively verified by AC5's token filter). +This is the standard way to obtain a real `Dispatcher` instance without a live host and no better +seam is available given `Initialize()` shows a WinForms window. Recording it as an observation only. + +### CR-6 — Attribute-form inconsistency across the four modified classes (Low, non-blocking) + +Three files use the two-line form: + +```csharp +[TestClass] +[DoNotParallelize] +``` + +`ProgressTracker_Tests.cs:14` uses `[TestClass, DoNotParallelize]`. The divergence is deliberate — it +is how the 514-line count was held constant (see CR-1) — and is documented in +`p1-t5-donotparallelize.md`. Semantically identical. If CR-1 is ever resolved by a file split, this +should be normalised to the two-line form at the same time. + +### CR-7 — Three null policies across three static accessors in one class (Low, non-blocking) + +`UtilitiesCS/Threading/UiThread.cs` now exposes: + +| Accessor | Backing field null policy | +|---|---| +| `UiSyncContext` (`:113-125`) | Lazy: calls `Init()`, then returns `_uiSyncContext!` | +| `Dispatcher` (`:135-148`) | Fail fast: throws `InvalidOperationException` | +| `AutoScaleFactor` (`:156-167`) | Lazy with fallback: calls `Init()`, then `?? new SizeF(1f, 1f)` | + +`spec.md` Root Cause Analysis cites the two lazy accessors as evidence that the omission on +`Dispatcher` was unintentional, then the Proposed Fix deliberately chooses a *different* policy — +throw — rather than matching them. That choice is well-founded and is argued in the spec: `Initialize()` +shows a real hidden WinForms window, so lazily initialising it from an arbitrary caller's thread is +not a safe default, whereas the throw matches the precedent `WpfDispatcherYield.cs` already +established for this exact hazard. Recording this as an observation so the divergence is a conscious, +visible property of the class rather than an accident. The `_uiSyncContext!` suppression at `:122` is +an untouched pre-existing instance of the same pattern this change removed from `_dispatcher`, and is +a candidate for the same treatment in future work. + +## 4. Design Principles Review + +| Principle | Assessment | +|---|---| +| Simplicity first | The guard is four lines with no indirection. The alternative — guarding at each of ~40 call sites — was correctly rejected. | +| Reusability | The single-source guard is the reusable form. | +| Extensibility | Public type unchanged; the getter's contract is now total and can be extended (e.g. to lazy init) without breaking callers further. | +| Separation of concerns | No I/O or UI added; the guard is pure. | +| Error handling — fail fast | Correct. Named exception, actionable message identifying both the entry point (`UiThread.Init()`) and the initialiser (`UiThread.Initialize()`). | +| Contracts enforced at access | The invariant previously stated only in a trailing comment is now enforced in code, and that comment is correctly deleted rather than left to rot. | +| Comment *why*, not *what* | Exemplary at `EmailMoveMonitorTests.cs:33-37`: the comment records the causal chain (throwing getter -> `PropertyInfo.GetValue` -> `TargetInvocationException` in setup/teardown) and the reason field access is equivalent, which is exactly the information a future reader needs to avoid reverting the change. | +| Public API compatibility | A behavioural break, correctly identified as such and called out in `spec.md` "Backward-compatibility expectations". Blast radius established across all three read routes (see policy-audit §8/B1). | + +## 5. Test Quality Review + +**Strengths.** + +- Both new tests are deterministic by construction: they drive the accessor through its backing + field rather than attempting to reproduce a timing race. The original failure was 1-of-3 + reproducible; the replacement is 2-of-2 by design. +- Capture-and-restore in `finally` on both tests, so the process-global static is left exactly as + found even if an assertion throws. +- Message assertion is behavioural, not brittle: `WithMessage("*UiThread.Initialize()*")` pins the + contract AC1 states (the message names the missing initialisation) without pinning the full + sentence, so message wording can be improved without a test edit. +- The positive test is not redundant. It passed both before and after the fix, which is what proves + the RED in the negative test is caused by the defect and not by the reflection arrangement. +- The `[DoNotParallelize]` decision is verified rather than asserted: `p0-t13` records the + false-before state (zero occurrences across the four files), `p1-t5` the true-after (exactly one + each), and `p3-t3`/`p4-t5` confirm empirically that serialising exposed no latent ordering + dependency (41 of 41 and 4787 of 4787). + +**Determinism trade-off assessed (prompt point 4).** `[DoNotParallelize]` on four classes trades +parallel throughput for freedom from concurrent interleaving on a process-global static. + +- Against `.claude/rules/general-unit-test.md` Core Principle 4 (Determinism) and the + "Determinism Infrastructure" section: this is the right instrument. The banned-API list targets + wall-clock waits and uncontrolled time; none is used here. Serialising access to a shared static + is not a timing tolerance and does not mask a race — it removes the concurrency under which the + race can occur, while the capture/restore in `finally` independently preserves order-independence. +- Against Core Principle 1 (Independence): `[DoNotParallelize]` alone would not deliver + independence, since it constrains concurrency rather than order. Independence is delivered by the + `finally` restore in each mutating test. The two mechanisms are complementary and both are present. +- Against Core Principle 3 (Fast Execution): four classes out of a 4787-test assembly. `p3-t3` + reports 41 tests in 2.24 s; the final full run is green. The cost is not material. +- **Residual, recorded as informational.** The census behind the `[DoNotParallelize]` decision + enumerated *reflective* writers of `_dispatcher` only. It did not enumerate the production writer + path — `UiThread.UiSyncContext` / `UiThread.AutoScaleFactor` getters call `Init()`, which runs + `Initialize()`, which assigns `Dispatcher`. This review verified the path is not reachable from a + parallel-bucket test today: no `UtilitiesCS.Test` file reads either property; `ThreadMonitor.cs:143` + is covered by a class already marked `[DoNotParallelize]`; and `FolderPredictor.cs:178` is driven + by `FolderPredictorTests.cs:479`, which pre-sets `_uiSyncContext` so the lazy branch is never + taken. The guarantee therefore holds, but partly by coincidence of an unrelated test's arrangement. + The durable fix is the deferred `IUiDispatcher` seam conversion. No action required here. + +**AC4 "unmodified assertions" clause (prompt point 5) — verified against the complete file.** +Read all 320 lines of `EmailMoveMonitorTests.cs`: + +- 8 `[TestMethod]` declarations, matching the base count of 8. +- The only assertion in the touched region, `current.Should().BeSameAs(_capturedDispatcher);` at + line 65, is byte-identical to its pre-change form; only the expression producing `current` changed + from `DispatcherProperty?.GetValue(null)` to `DispatcherField?.GetValue(null)`. +- No `.Should()` call anywhere else in the file sits inside the diff region. All eight test bodies, + every `folder.VerifyAdd` / `VerifyRemove` with its `Times` argument, every `Mock` setup, and the + `CountingPassThrough` / `CreateMail` / `CreateFolder` helpers are unchanged. +- No `using` directive added; the retarget uses the fully-qualified `System.Reflection.FieldInfo` / + `BindingFlags` spelling the pre-change code already used. + +The clause holds. This is corroborated by `p2-t4-emailmovemonitor-reflection-target.md`'s command 9 +(no added or removed diff line carries `.Should()`, exit 1), but the conclusion above is from direct +reading of the file, not from that artifact. + +## 6. Verification Performed by This Review + +Independently re-derived against the worktree rather than accepted from evidence artifacts: + +| Claim | How verified | Result | +|---|---|---| +| `null!` removed; field is `Dispatcher?` | Read `UiThread.cs:149` | Confirmed — `private static Dispatcher? _dispatcher;`, no `null!` in the file | +| Nullable gate is non-vacuous | Read `UiThread.cs:1` | Confirmed — `#nullable enable` present, so `CS8603` would fire without the guard | +| Message names `Initialize()` | Read `UiThread.cs:142` vs `UiThread_Tests.cs:152` | Confirmed — literal contains `UiThread.Initialize()`; assertion pattern matches | +| Exactly one reflective property read existed and is gone | Repo-wide search for `GetProperty(` with a `"Dispatcher"` operand in `*.cs` | Zero hits | +| All remaining `"Dispatcher"` literals are doc cross-references | Repo-wide search for `"Dispatcher"` in `*.cs` | 4 hits, all `` | +| Reflective field consumers | Repo-wide search for `"_dispatcher"` in `*.cs` | 6 hits, all field reads, matching the census plus the new helper | +| No `using static` alias route (a route the census did not enumerate) | Repo-wide search for `using static .*UiThread` | Zero hits — gap closed | +| `[DoNotParallelize]` present on all four classes | Attribute search across `UtilitiesCS.Test/Threading` | Confirmed on `UiThread_Dispatcher_Tests`, `IdleAsyncQueue_Tests`, `ProgressTrackerAsync_Tests`, `ProgressTracker_Tests` | +| AC4 assertion-preservation | Full read of `EmailMoveMonitorTests.cs` | 8 `[TestMethod]`, sole touched assertion unchanged | +| No timing constructs in new test code | Full read of `UiThread_Tests.cs` | None of the seven banned tokens present | +| Evidence-location compliance | Directory enumeration of `artifacts/**` | No `baselines/`, `qa/`, `evidence/`, or `coverage/` subtree | +| Plan completion | Checkbox count in `plan.2026-09-02T09-02.md` | 50 of 50 complete, zero unchecked | + +**Not verified (no-shell constraint).** csharpier, msbuild, and vstest were not re-executed. Every +head-state fact those gates assert was re-derived by file read and matched, so the residual risk is +limited to the possibility that the recorded exit codes and summary blocks are themselves inaccurate +— for which there is no supporting signal in the artifacts. + +## 7. Verdict + +**APPROVE.** + +**BLOCKING FINDINGS: 0** (0 FAIL, 0 blocking-PARTIAL) + +Non-blocking: 2 Minor (CR-1, CR-2), 5 Low (CR-3 through CR-7). + +The two Minor items are follow-up work rather than defects in this change. Recommended sequencing: +promote CR-2 to a GitHub issue before merge (it is otherwise lost with the feature folder); handle +CR-1 and CR-3 through CR-7 as separate maintenance work. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t10-utilitiescs-tests-coverage.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t10-utilitiescs-tests-coverage.md new file mode 100644 index 000000000..269a73abd --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t10-utilitiescs-tests-coverage.md @@ -0,0 +1,73 @@ +# P0-T10 — UtilitiesCS.Test baseline run with Cobertura coverage + +Timestamp: 2026-09-03T08-27 + +Command: +```text +env -C MSYS_NO_PATHCONV=1 dotnet-coverage collect --output coverage/p0-t10.cobertura.xml --output-format cobertura --settings coverage.config -- "\vstest.console.exe" UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /Logger:trx /ResultsDirectory:TestResults/p0-t10 /TestCaseFilter:TestCategory!=LiveOutlook +``` + +EXIT_CODE: 0 + +## Output Summary + +### Test counts + +Console summary block, verbatim: + +```text +Test Run Successful. +Total tests: 4785 + Passed: 4785 + Total time: 13.5747 Seconds +``` + +- **Total tests: 4785** (console summary block) +- **Passed: 4785** (console summary block) +- **Failed: 0** (read from the `failed` attribute of the single `` element in the TRX + written under `TestResults/p0-t10/`) +- **Skipped: 0** (derived as `total` minus `executed`) + +TRX `` values used for the derivation: + +- `total` = **4785** +- `executed` = **4785** +- `failed` = **0** +- derived `Skipped` = 4785 - 4785 = **0** + +The `notExecuted` attribute was NOT used, per constraint 5 of "Shell constraints measured in this +worktree"; the TRX logger hard-codes it to `0` regardless of the run's outcome. + +TRX SELECTED: most recently modified .trx in TestResults/p0-t10/ +Last-modified timestamp of the selected file: `2026-09-03 08:27:05.113345500 -0400`. +That directory held two `.trx` files at the moment this artifact was written (an earlier one dated +2026-09-02 from a prior preparation-cycle run, and the one this task produced), so the TRX selection +rule stated after constraint 5 applies. The selected file's own name is deliberately not recorded, +and the run's `Results File:` console line is deliberately not quoted, because +`vstest.console.exe` composes the default TRX filename from the host account name and the machine +name and prints it inside a full absolute host path. + +BASELINE_FAILURE_SET: empty. `Failed` is 0, so the comparison target for P3-T3 and P4-T5 is the empty +set. + +### Baseline coverage figures + +Read from the root `` element of `coverage/p0-t10.cobertura.xml`: + +- `lines-covered` = **105901** +- `lines-valid` = **149719** +- `line-rate` = **0.7073317347831605** + +Supporting attributes on the same element, recorded for completeness: `branch-rate` += 0.4679954683849041, `branches-covered` = 13219, `branches-valid` = 28246, `complexity` = 31677. + +These three values are the **baseline coverage figures** referred to by P0-T12, P4-T5, and P4-T7. +They are the raw unstripped `dotnet-coverage` figures for the `UtilitiesCS.Test` process and are not +the repository first-party figure CLAUDE.md's 80% refers to. + +## Acceptance + +All four test counts and all three coverage attribute values are recorded as concrete numbers, not +placeholders. The `total` and `executed` values from which `Skipped` was derived are recorded. +`TestResults/p0-t10/` is identified as the results directory `Failed` and `Skipped` were read from, +without recording a TRX filename and without quoting a `Results File:` line. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t11-quickfiler-tests.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t11-quickfiler-tests.md new file mode 100644 index 000000000..2a7cc6fbe --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t11-quickfiler-tests.md @@ -0,0 +1,57 @@ +# P0-T11 — QuickFiler.Test baseline run + +Timestamp: 2026-09-03T08-28 + +Command: +```text +env -C MSYS_NO_PATHCONV=1 PATH=":$PATH" vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /InIsolation /Logger:trx /ResultsDirectory:TestResults/p0-t11 /TestCaseFilter:TestCategory!=LiveOutlook +``` + +EXIT_CODE: 0 + +## Output Summary + +Console summary block, verbatim: + +```text +Test Run Successful. +Total tests: 1312 + Passed: 1312 + Total time: 14.2187 Seconds +``` + +- **Total tests: 1312** (console summary block) +- **Passed: 1312** (console summary block) +- **Failed: 0** (read from the `failed` attribute of the single `` element in the TRX + written under `TestResults/p0-t11/`) +- **Skipped: 0** (derived as `total` minus `executed`) + +TRX `` values used for the derivation: + +- `total` = **1312** +- `executed` = **1312** +- `failed` = **0** +- derived `Skipped` = 1312 - 1312 = **0** + +The `notExecuted` attribute was NOT used, per constraint 5 of "Shell constraints measured in this +worktree". + +TRX SELECTED: most recently modified .trx in TestResults/p0-t11/ +Last-modified timestamp of the selected file: `2026-09-03 08:28:04.618542900 -0400`. +That directory held two `.trx` files (an earlier one dated 2026-09-02 from a prior preparation-cycle +run, and the one this task produced), so the TRX selection rule applies. The selected file's own name +is deliberately not recorded, and the run's `Results File:` console line is deliberately not quoted. + +BASELINE_FAILURE_SET: empty. `Failed` is 0. + +## Acceptance + +All four counts are recorded as concrete numbers, the `total` and `executed` values from which +`Skipped` was derived are recorded, and `TestResults/p0-t11/` is identified as the results directory +`Failed` and `Skipped` were read from, without a TRX filename and without a quoted `Results File:` +line. + +This assembly is baselined because the sibling audit in P3-T6 found that +`QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs` constructs the parameterless `WpfUiDispatcher`, +whose provider closes over `UiThread.Dispatcher`. `Total tests: 1312` is the figure P4-T6's +acceptance compares against. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t12-threshold-reconciliation.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t12-threshold-reconciliation.md new file mode 100644 index 000000000..92681ce5c --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t12-threshold-reconciliation.md @@ -0,0 +1,56 @@ +# P0-T12 — Coverage-threshold reconciliation + +Timestamp: 2026-09-03T08-29 + +Command: +```text +(documentary task; the reconciliation is derived from the policy files read in P0-T1 and from the +baseline coverage figures recorded in P0-T10) +``` + +EXIT_CODE: 0 + +## Output Summary + +### Conflicting sources + +| Rank | Source | Line coverage | Branch coverage | New-code coverage | +|---|---|---|---|---| +| 1 | `CLAUDE.md` | `>= 80%` repository-wide | not specified | `>= 90%` for any new module, class, or method | +| 3 | `.claude/rules/general-unit-test.md` | `>= 85%` | `>= 75%` | not specified | +| 4 | `.claude/rules/quality-tiers.md` | `>= 85%` (uniform T1-T4) | `>= 75%` (uniform T1-T4) | not specified | + +**`CLAUDE.md` is the rank-1 authority** under `policy-compliance-order`, which places `CLAUDE.md` +first and the `.claude/rules/` files after it. Its figures therefore supersede the rank-3 and rank-4 +figures for this plan. The figures this plan enforces are consequently `>= 80%` repository line +coverage and `>= 90%` new-code coverage. + +The `>= 85%` line and `>= 75%` branch figures in the two rule files are recorded here rather than +silently discarded. The divergence is pre-existing, is repository-wide, and is not resolved or +narrowed by this bug fix. + +### What this plan actually gates on + +The enforced repository-level gate is **no regression relative to the P0-T10 baseline**, applied in +P4-T7 clause (d) with a 0.005 tolerance. The absolute `>= 80%` figure is recorded as an observation +rather than gated, because the baseline is measured rather than assumed and a floor asserted against +an unmeasured baseline could be unsatisfiable for reasons this change did not cause. + +### Baseline line-rate quoted from P0-T10 + +`line-rate` = **0.7073317347831605** + +Supporting values from the same root `` element: `lines-covered` = 105901, `lines-valid` += 149719. + +This figure is the raw unstripped `dotnet-coverage` line rate for the `UtilitiesCS.Test` process. It +is **not** comparable to CLAUDE.md's `>= 80%` repository figure, which refers to the repository's +first-party testable denominator after third-party stripping and after the COM/VSTO/WinForms +exemption CLAUDE.md ratifies. Because the baseline figure (0.7073, i.e. 70.73%) already sits below +the 80% policy floor on this unstripped basis, a post-change figure below that floor is recorded by +P4-T7 as `PRE-EXISTING FLOOR SHORTFALL` and is not attributed to this change. + +## Acceptance + +The artifact names `CLAUDE.md` explicitly as the superseding rank-1 authority and quotes the concrete +baseline `line-rate` value `0.7073317347831605` recorded in P0-T10. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t13-parallel-bucket-census.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t13-parallel-bucket-census.md new file mode 100644 index 000000000..28aca6373 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t13-parallel-bucket-census.md @@ -0,0 +1,77 @@ +# P0-T13 — Parallel-bucket census and baseline file sizes + +Timestamp: 2026-09-03T08-30 + +Command: +```text +env -C git grep -n -F '"_dispatcher"' -- UtilitiesCS.Test +env -C git grep -c -F DoNotParallelize -- UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs +env -C git grep -n -F "[TestClass" -- UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs +env -C wc -l UtilitiesCS/Threading/UiThread.cs UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs +``` + +EXIT_CODE: +- command 1 — 0 +- command 2 — 1 (`git grep` exits 1 on zero matches) +- command 3 — 0 +- command 4 — 0 + +## Output Summary + +### BASELINE_DISPATCHER_WRITERS: + +```text +UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs:144: "_dispatcher", +UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs:138: "_dispatcher", +UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs:422: "_dispatcher", +``` + +Exactly three lines, in exactly the three files and at exactly the three line numbers the plan +states. + +### BASELINE_DONOTPARALLELIZE_COUNTS: + +```text +``` + +The command printed nothing and exited 1. None of the four files carries the `DoNotParallelize` +token at BASE. This is the false-before half of P1-T5's gate. + +### BASELINE_TESTCLASS_LINES: + +```text +UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs:28: [TestClass] +UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs:13: [TestClass] +UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs:14: [TestClass] +UtilitiesCS.Test/Threading/UiThread_Tests.cs:8: [TestClass] +``` + +Exactly four lines, at exactly the four line numbers the plan states. These are P1-T5's edit sites +(lines 28, 13, and 14) and P1-T2's existing-class attribute (line 8). + +### BASELINE_LINE_COUNTS: + +```text + 163 UtilitiesCS/Threading/UiThread.cs + 104 UtilitiesCS.Test/Threading/UiThread_Tests.cs + 347 UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs + 205 UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs + 514 UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs + 1333 total +``` + +The five per-file rows are the recorded baseline counts referred to by P2-T3 and P4-T8. The counting +idiom is `wc -l`, the physical newline count, and it is the idiom used identically in P0-T13, P2-T3, +and P4-T8. The trailing `total` row is expected and is ignored. + +PRE-EXISTING FILE-SIZE OVERAGE: UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs 514 + +That overage exists at BASE `87cb4df338322844abfa580abea14df77e738e5c` — the file is 514 lines +before this plan touches it, already above the 500-line limit in +`.claude/rules/general-code-change.md` — and is not introduced by this change. P1-T5 adds the +attribute to that one file by extending its existing attribute list on line 14 rather than by adding +a line, so the change does not deepen the overage. + +## Acceptance + +All four stated values match the tree exactly. No BLOCKED condition applies. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t14-reflective-dispatcher-census.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t14-reflective-dispatcher-census.md new file mode 100644 index 000000000..b6ef78795 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t14-reflective-dispatcher-census.md @@ -0,0 +1,128 @@ +# P0-T14 — Reflective `Dispatcher` property and `UiThread` type census + +Timestamp: 2026-09-03T21-31 + +Command: +```text +env -C git grep -n -F '"Dispatcher"' -- QuickFiler.Test SVGControl.Test Tags.Test TaskMaster.Test TaskTree.Test TaskVisualization.Test ToDoModel.Test UtilitiesCS.Test VBFunctions.Test +env -C git grep -n -F 'typeof(UiThread)' -- '*.cs' +env -C git grep -n -F '"Dispatcher"' -- '*.cs' +``` + +EXIT_CODE: +- command 1 — 0 +- command 2 — 0 +- command 3 — 0 + +Aggregate EXIT_CODE: 0 + +## Output Summary + +### REFLECTIVE_PROPERTY_NAME_HITS: + +Complete verbatim output of command 1: + +```text +QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs:14: /// against a real, running WPF hosted on a dedicated STA thread (the +QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs:35: "Dispatcher", +``` + +Classification, one line per hit: + +- `QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs:14` — DOC-COMMENT. The occurrence is the XML + `` cross-reference in that file's class summary. It invokes nothing. +- `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs:35` — REFLECTION. The occurrence is the + name operand of the `typeof(UiThread).GetProperty(` call that begins on line 34. + +Clause 1 result: two lines, in exactly the two files the task names. The FILE SET matches. The line +numbers observed (14 and 35) are identical to the round-15 readings the task records, so no +line-number deviation had to be recorded. No unlisted file appears and neither listed file is +missing. + +### REFLECTIVE_UITHREAD_TYPE_HITS: + +Complete verbatim output of command 2: + +```text +QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs:135: FieldInfo field = typeof(UiThread).GetField( +QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs:34: typeof(UiThread).GetProperty( +UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs:469: var uiThreadType = typeof(UiThread); +UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs:144: return typeof(UiThread).GetField( +UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs:138: var dispatcherField = typeof(UiThread).GetField( +UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs:421: var dispatcherField = typeof(UiThread).GetField( +UtilitiesCS.Test/Threading/UiThread_Tests.cs:127: return typeof(UiThread).GetField( +``` + +Classification, one line per hit. Each classification was read from the member-name operand on the +line immediately following the reflection call. + +- `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs:135` — FIELD:_dispatcher +- `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs:34` — PROPERTY:Dispatcher +- `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs:469` — FIELD:_uiSyncContext +- `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs:144` — FIELD:_dispatcher +- `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs:138` — FIELD:_dispatcher +- `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs:421` — FIELD:_dispatcher +- `UtilitiesCS.Test/Threading/UiThread_Tests.cs:127` — FIELD:_dispatcher + +Clause 2 result: seven lines, in exactly the seven files the task names, and exactly one of the +seven is classified `PROPERTY:`. That one file is +`QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs`, which is the file P2-T4 repairs. + +`UtilitiesCS.Test/Threading/UiThread_Tests.cs` appears in this set because P1-T2 added the +`DispatcherField()` helper to it. It is not present at BASE, and this census describes the tree as +it stands when this task runs. + +`UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs` is unrelated to this fix. It +reflects over `_uiSyncContext`, a different static member of `UiThread`, and is recorded here so a +reviewer can see it was examined rather than overlooked. + +### REPOSITORY_WIDE_PROPERTY_NAME_HITS: + +Complete verbatim output of command 3: + +```text +QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs:14: /// against a real, running WPF hosted on a dedicated STA thread (the +QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs:35: "Dispatcher", +UtilitiesCS/Threading/IUiDispatcher.cs:13: /// forwards each member 1:1 to the underlying WPF . +UtilitiesCS/Threading/ThreadMonitor.cs:25: /// , so it is unit-testable without a live +UtilitiesCS/Threading/WpfUiDispatcher.cs:14: /// against a real, running hosted on a dedicated STA +``` + +Classification, one line per hit: + +- `QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs:14` — DOC-COMMENT +- `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs:35` — REFLECTION +- `UtilitiesCS/Threading/IUiDispatcher.cs:13` — DOC-COMMENT +- `UtilitiesCS/Threading/ThreadMonitor.cs:25` — DOC-COMMENT +- `UtilitiesCS/Threading/WpfUiDispatcher.cs:14` — DOC-COMMENT + +Clause 3 result: five lines, in exactly the five files the task names — the two files clause 1 +names plus `UtilitiesCS/Threading/WpfUiDispatcher.cs`, `UtilitiesCS/Threading/ThreadMonitor.cs`, and +`UtilitiesCS/Threading/IUiDispatcher.cs`. All three added files are XML `` +cross-references to the WPF type and none invokes anything. + +## Conclusion + +No production file in this repository reads `UiThread.Dispatcher` reflectively. The blast radius of +P2-T1's guard through the reflective route is therefore confined to the single test file P2-T4 +repairs, `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs`. + +This conclusion rests on the recorded output of commands 2 and 3 together, and both are +repository-wide over `.cs` files: + +- Command 3's recorded output shows that the only `.cs` occurrences of the reflective name operand + `"Dispatcher"` outside the one repaired test file are four documentation cross-references + (`WpfUiDispatcherTests.cs:14`, `IUiDispatcher.cs:13`, `ThreadMonitor.cs:25`, + `WpfUiDispatcher.cs:14`), none of which invokes reflection. +- Command 2's recorded output shows that the reflection entry point `typeof(UiThread)` occurs in + seven files, all seven inside test assemblies, and in no production file at all. + +Together the two recorded outputs cover every `.cs` file the conclusion speaks about. + +## Acceptance + +Clause 1 satisfied: two lines, two files, the stated file set, classified DOC-COMMENT and REFLECTION. +Clause 2 satisfied: seven lines, seven files, the stated file set, exactly one classified `PROPERTY:`. +Clause 3 satisfied: five lines, five files, the stated file set, three added files all DOC-COMMENT. + +No BLOCKED condition applies. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t2-uithread-rederivation.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t2-uithread-rederivation.md new file mode 100644 index 000000000..40c1e761c --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t2-uithread-rederivation.md @@ -0,0 +1,52 @@ +# P0-T2 — Re-derivation of the defect site in UtilitiesCS/Threading/UiThread.cs + +Timestamp: 2026-09-03T08-20 + +Command: +```text +cat -n UtilitiesCS/Threading/UiThread.cs +wc -l UtilitiesCS/Threading/UiThread.cs +``` + +EXIT_CODE: 0 (both commands) + +## Output Summary + +- Total line count: **163** (`wc -l`, physical newline count; the `cat -n` listing likewise ends at + line 163). +- Nullable-enable directive: present on **line 1**, spelled `#nullable enable` (the file carries a + UTF-8 BOM immediately before it). +- `Dispatcher` property: **lines 135-139**. + + ```csharp + public static Dispatcher Dispatcher + { + get => _dispatcher; + private set => _dispatcher = value; + } + ``` + +- Backing field: **line 140**, verbatim: + + ```csharp + private static Dispatcher _dispatcher = null!; // set in Initialize() before any access + ``` + + The line contains the null-forgiving suppression `null!`. + +- Lazy-initialising sibling properties, recorded for contrast with the `Dispatcher` accessor: + - `UiSyncContext` — **lines 113-125**; its getter calls `Init()` when `_uiSyncContext is null` and + then returns `_uiSyncContext!`. Backing field `_uiSyncContext` at line 126. + - `AutoScaleFactor` — **lines 147-158**; its getter calls `Init()` when `_autoScaleFactor is null` + and returns `_autoScaleFactor ?? new System.Drawing.SizeF(1f, 1f)`. Backing field + `_autoScaleFactor` at line 159. + + `Dispatcher` is the only one of the three that neither lazily initialises nor guards: its getter is + the bare expression body `get => _dispatcher;`, so on a worker thread that reaches it before + `Initialize()` has run it returns `null` silently, which is the defect this plan fixes. + +- Assignment site: `Dispatcher = _syncContextForm.UiDispatcher;` at line 61, inside `Initialize()`. + +All five values asserted by P0-T2 match: total line count 163; backing-field line contains `null!`; +property at lines 135-139; field at line 140; nullable-enable directive on line 1. No BLOCKED +condition applies. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t3-progresstrackerasync-rederivation.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t3-progresstrackerasync-rederivation.md new file mode 100644 index 000000000..e24c8323f --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t3-progresstrackerasync-rederivation.md @@ -0,0 +1,51 @@ +# P0-T3 — Re-derivation of the AC3 hypothesis in UtilitiesCS/Threading/ProgressTrackerAsync.cs + +Timestamp: 2026-09-03T08-20 + +Command: +```text +sed -n '1,60p' UtilitiesCS/Threading/ProgressTrackerAsync.cs +sed -n '59,109p' UtilitiesCS/Threading/ProgressTrackerAsync.cs +wc -l UtilitiesCS/Threading/ProgressTrackerAsync.cs +``` + +EXIT_CODE: 0 (all three commands) + +## Output Summary + +The file is 109 lines and was read in full. + +- **Assignment of `UiThread.Dispatcher` to the instance field — line 33**, verbatim: + + ```csharp + UiDispatcher = UiThread.Dispatcher; + ``` + + It is the first statement of `public async Task InitializeAsync()`, which + opens at line 31. `UiDispatcher` is the internal property declared at lines 86-90 over the + protected backing field `_uiDispatcher` at line 85. + +- **First statement that dereferences that instance field — line 35**, verbatim: + + ```csharp + await UiDispatcher.InvokeAsync(() => + ``` + + No statement between line 33 and line 35 reads `UiDispatcher`; line 34 is blank. + +## Conclusion + +The property read `UiThread.Dispatcher` on line 33 is evaluated strictly before the dereference on +line 35. Once `UtilitiesCS/Threading/UiThread.cs` is fixed so that the `Dispatcher` getter throws +`InvalidOperationException` when its backing field is null, that exception is raised at line 33 — at +the property access, with a message naming `UiThread.Initialize()` — and control never reaches the +`InvokeAsync` dereference on line 35, which is where the current unhelpful +`NullReferenceException` originates. The consumer therefore receives a self-diagnosing exception at +the correct site without any edit to this file. + +`UtilitiesCS/Threading/ProgressTrackerAsync.cs` is consequently NOT added to this plan's write-target +list. AC3's "left unmodified" wording holds. No overturned conclusion to report. + +Incidental observation (recorded, not acted on): this file carries three further `null!` +suppressions of the same shape, at lines 69 (`_progressViewer`), 76 (`_jobName`), and 85 +(`_uiDispatcher`). They are outside this plan's scope and are not edited. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t4-test-rederivation.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t4-test-rederivation.md new file mode 100644 index 000000000..0b882fc4a --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t4-test-rederivation.md @@ -0,0 +1,73 @@ +# P0-T4 — Re-derivation of the test-side facts + +Timestamp: 2026-09-03T08-20 + +Command: +```text +cat -n UtilitiesCS.Test/Threading/UiThread_Tests.cs +wc -l UtilitiesCS.Test/Threading/UiThread_Tests.cs +sed -n '135,170p' UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs +cat -n UtilitiesCS.Test/Properties/AssemblyInfo.cs +``` + +EXIT_CODE: 0 (all four commands) + +## Output Summary + +### UtilitiesCS.Test/Threading/UiThread_Tests.cs + +- Total line count: **104** (`wc -l`). +- Namespace: **`UtilitiesCS.Test.Threading`**, declared on line 6. +- Existing `using` directives — exactly four, in this order: + 1. line 1 `using System;` + 2. line 2 `using System.Threading;` + 3. line 3 `using FluentAssertions;` + 4. line 4 `using Microsoft.VisualStudio.TestTools.UnitTesting;` + + **`using System.Reflection;` is absent.** This is the fact P1-T1 acts on: it inserts that directive + as the second entry, giving five directives in the order `System`, `System.Reflection`, + `System.Threading`, `FluentAssertions`, `Microsoft.VisualStudio.TestTools.UnitTesting`. +- The file declares one `[TestClass]`, `SynchronizationContextAwaiter_Tests`, whose attribute is on + line 8 and whose closing brace is on line 103; the namespace's closing brace is on line 104. That + is the insertion point P1-T2 appends after. +- The file carries no `[DoNotParallelize]` attribute. + +### Reflection idiom used to reach the private static backing field + +Taken from the helper region of `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` +(`DispatcherField` / `ForceDispatcherNull` / `RestoreDispatcher`), the idiom is: + +```csharp + private static FieldInfo DispatcherField() + { + return typeof(UiThread).GetField( + "_dispatcher", + BindingFlags.NonPublic | BindingFlags.Static + ); + } +``` + +It takes the field by the name `_dispatcher` with non-public static binding flags. `ForceDispatcherNull()` +captures the prior value with `field.GetValue(null)` before writing null with `field.SetValue(null, null)`, +so the caller can restore it. P1-T2's new class reuses this idiom verbatim. + +### UtilitiesCS.Test/Properties/AssemblyInfo.cs + +Line 18 carries the assembly-level parallelisation attribute: + +```csharp +[assembly: Parallelize( + Workers = 0, + Scope = Microsoft.VisualStudio.TestTools.UnitTesting.ExecutionScope.ClassLevel +)] +``` + +Classes in this assembly therefore run concurrently by default at class-level scope. That is the +justification for the `[DoNotParallelize]` attribute P1-T2 places on the new class and for P1-T5's +attribute-only edits to the three existing files that write the process-global +`UiThread._dispatcher`. + +All five values asserted by P0-T4 match: 104 total lines; namespace `UtilitiesCS.Test.Threading`; +`System.Reflection` using directive absent; the reflection idiom takes `_dispatcher` with +`BindingFlags.NonPublic | BindingFlags.Static`; the assembly-level parallelisation attribute is +present on line 18 of `UtilitiesCS.Test/Properties/AssemblyInfo.cs`. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t5-toolchain-resolution.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t5-toolchain-resolution.md new file mode 100644 index 000000000..9c52de340 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t5-toolchain-resolution.md @@ -0,0 +1,88 @@ +# P0-T5 — Toolchain entry-point resolution + +Timestamp: 2026-09-03T08-20 + +## Working-directory prefix used by every command block in this plan + +This executing agent's Bash tool resets its working directory to the session root between calls, and +the directory does not persist: a standalone `cd` into the item worktree followed by `pwd` in the +next call reported the session root again. Every command block in this plan is specified to run +"from the worktree root", so each recorded command line below carries a leading +`env -C ` prefix, which sets the process working directory for that one command +without using `cd` and without chaining. The prefix changes no switch and no operand; it is applied +for the same class of reason the plan's own `MSYS_NO_PATHCONV=1 ` prefix is applied, and the +acceptance clauses in P0-T9 and P4-T4 are already worded as `contains` rather than `begins with` for +exactly that situation. `` denotes this item's worktree root and is not spelled out +here, because it is an absolute host path. + +Command: +```text +env -C dotnet --version +env -C PATH="/c/Program Files (x86)/Microsoft Visual Studio/Installer:$PATH" vswhere.exe -latest -products '*' -find "Common7\IDE\Extensions\TestPlatform\vstest.console.exe" +env -C msbuild.exe -version +env -C nuget.exe restore TaskMaster.sln +env -C dotnet tool restore +env -C dotnet tool run csharpier --version +env -C dotnet-coverage --version +ls -la "\vstest.console.exe" +``` + +EXIT_CODE: +- `dotnet --version` — 0 +- `vswhere.exe ... -find ...` — 0 +- `msbuild.exe -version` — 0 +- `nuget.exe restore TaskMaster.sln` — 0 +- `dotnet tool restore` — 0 +- `dotnet tool run csharpier --version` — 0 +- `dotnet-coverage --version` — 0 +- `ls -la` existence check on the resolved `vstest.console.exe` — 0 + +## Output Summary + +SDK_BOOTSTRAP: NOT REQUIRED (first probe already reported a version beginning 8.0.2) + +The first and only `dotnet --version` probe, run with the worktree root as its working directory, +printed `8.0.205` and exited 0. `.dotnet-sdk/dotnet.exe` is already present in this worktree, so none +of the four POSIX bootstrap commands was run and there is no post-bootstrap reading to record. +`scripts/vscode/Install-RepoDotNetSdk.ps1` was not invoked, in accordance with constraint 1 of +"Shell constraints measured in this worktree". + +Recorded observation about the probe's working-directory sensitivity: an identical `dotnet --version` +issued without the `env -C` prefix — that is, from the session root rather than from this item's +worktree — exits 155 and reports `The repo-local .NET SDK is missing`, because `global.json`'s +`paths` entry `.dotnet-sdk` is resolved relative to whichever `global.json` the working directory +selects. That reading describes a different checkout and is not a fact about this worktree. It is +recorded here so the `env -C` prefix is understood as load-bearing rather than cosmetic. + +NUGET_RESTORE: exit code 0. Restore summary line printed verbatim: +`All packages listed in packages.config are already installed.` +MSBuild auto-detection selected version `18.9.1.35102`. No `packages/build/` directory was produced. + +Verbatim path line printed by `vswhere.exe`: +```text +C:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe +``` + +RESOLVED_VSTEST_DIR_NATIVE: `C:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform` + +RESOLVED_VSTEST_DIR: `/c/Program Files/Microsoft Visual Studio/18/Community/Common7/IDE/Extensions/TestPlatform` + +Both values are derived mechanically from the single printed line by stripping the trailing +`\vstest.console.exe`, and, for the POSIX spelling, by replacing the leading `C:` with `/c` and every +`\` with `/`. `vstest.console.exe` was confirmed to exist inside that directory (310088 bytes). + +Tool versions reported: +- MSBuild — `MSBuild version 18.9.1+a81b43525 for .NET Framework` / `18.9.1.35102` +- CSharpier — `1.2.6`, matching the version pinned by `dotnet-tools.json` +- dotnet-coverage — `18.10.0+f4cc39224845ffa74bf246c9da2399d50e5d6342` + +`dotnet tool restore` reported `Tool 'csharpier' (version '1.2.6') was restored.` followed by +`Restore was successful.` `dotnet-coverage` was already available, so no +`dotnet-coverage: UNAVAILABLE` record and no install attempt was required. + +## Acceptance + +All clauses satisfied: the last (and only) `dotnet --version` reading is `8.0.205`, beginning `8.0.2`, +exit 0; `nuget.exe restore TaskMaster.sln` exits 0; both resolved directory values are concrete and +non-empty and `vstest.console.exe` exists inside that directory; `msbuild.exe -version` exits 0; +`dotnet tool restore` exits 0; the reported CSharpier version is `1.2.6`. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t6-mcp-probe.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t6-mcp-probe.md new file mode 100644 index 000000000..a7eea9e16 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t6-mcp-probe.md @@ -0,0 +1,33 @@ +# P0-T6 — MCP validator availability probe + +Timestamp: 2026-09-03T08-20 + +Command: +```text +mcp__drm-copilot__validate_orchestration_artifacts + artifact_type: "plan" + artifact_path: "docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/plan.2026-09-02T09-02.md" +``` + +EXIT_CODE: non-zero (tool invocation error; no exit code is returned by the MCP transport) + +## Output Summary + +MCP VALIDATOR UNAVAILABLE + +Verbatim error text returned by the tool call: + +```text +Error: No such tool available: mcp__drm-copilot__validate_orchestration_artifacts +``` + +The `drm-copilot` MCP server is reachable from this executing agent's session, but it exposes only +the four PoshQC tools (`run_poshqc_format`, `run_poshqc_analyze`, `run_poshqc_test`, +`run_poshqc_analyze_autofix`). `validate_orchestration_artifacts` is not among the tools available to +this session, so the probe could not be completed. + +Per this task's own text, an unavailable validator is recorded and execution continues; this task +never halts the plan. The delegating orchestrator separately reported having run +`validate_orchestration_artifacts` against this same plan file after the revision-round-14 +re-anchoring delta and having received `{"ok":true}`. That report is recorded here as a statement +received from the caller, not as an observation made by this agent. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t7-csharpier-check.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t7-csharpier-check.md new file mode 100644 index 000000000..03688c307 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t7-csharpier-check.md @@ -0,0 +1,27 @@ +# P0-T7 — Format baseline (CSharpier check, whole repository) + +Timestamp: 2026-09-03T08-21 + +Command: +```text +env -C dotnet tool run csharpier check . +``` + +EXIT_CODE: 0 + +## Output Summary + +Console output, verbatim: + +```text +Checked 1576 files in 6587ms. +``` + +CSharpier reported no unformatted file. It prints one `Error ` line per unformatted path when it +finds any; no such line was printed, and the command exited 0. + +BASELINE_FORMAT_DRIFT_SET: NONE + +Because the baseline drift set is empty, P4-T2's subset clause reduces to `EXIT_CODE: 0` with an +empty reported set, and P5-T10's clause about paths in the baseline drift set is vacuously satisfied +by an empty set — as that task's own wording anticipates. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t8-analyzer-build.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t8-analyzer-build.md new file mode 100644 index 000000000..bfaaf88b3 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t8-analyzer-build.md @@ -0,0 +1,34 @@ +# P0-T8 — Analyzer baseline build + +Timestamp: 2026-09-03T08-22 + +Command: +```text +env -C MSYS_NO_PATHCONV=1 msbuild.exe TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +``` + +EXIT_CODE: 0 + +## Output Summary + +Trailing MSBuild summary, verbatim: + +```text +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:16.82 +``` + +- Errors: **0** +- Warnings: **0** — this is the **baseline analyzer warning count** referred to by P3-T1 and P4-T3. + +The build ran `/t:Rebuild` as CLAUDE.md mandates, so `CoreCompile` was not skipped by MSBuild +incrementality and analyzers actually executed. All twelve projects in `TaskMaster.sln` reported +`Done Building Project ... (Rebuild target(s))`. The switch set is character-for-character the one +CLAUDE.md mandates; the only additions to the recorded line are the `env -C ` +working-directory prefix and the `MSYS_NO_PATHCONV=1 ` assignment required by constraint 4 of +"Shell constraints measured in this worktree". + +Acceptance satisfied: `EXIT_CODE: 0` and `0 Error(s)`, with the baseline warning count recorded as 0. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t9-nullable-build.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t9-nullable-build.md new file mode 100644 index 000000000..b1bf24659 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t9-nullable-build.md @@ -0,0 +1,38 @@ +# P0-T9 — Nullable / type-check baseline build + +Timestamp: 2026-09-03T08-23 + +Command (quoted command line, verbatim): +```text +env -C MSYS_NO_PATHCONV=1 msbuild.exe TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true +``` + +EXIT_CODE: 0 + +## Output Summary + +Trailing MSBuild summary, verbatim: + +```text +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:17.92 +``` + +- Errors: **0** + +## Acceptance + +- `EXIT_CODE: 0` — satisfied. +- The artifact records `0 Error(s)` — satisfied. +- The quoted command line **contains** `msbuild.exe TaskMaster.sln` — satisfied. The clause is + worded as `contains` rather than `begins with` because the recorded line begins with the + `env -C ` working-directory prefix and the `MSYS_NO_PATHCONV=1 ` assignment + required by constraint 4 of "Shell constraints measured in this worktree". +- The quoted command line contains no `Nullable=enable` substring — satisfied. `/p:Nullable=enable` + is deliberately absent, matching `.github/workflows/_build-nullable.yml` and CLAUDE.md's explicit + instruction not to add it. +- The quoted command line uses `/t:Rebuild` rather than `/t:Build` — satisfied, so the gate is not + vacuous through MSBuild incrementality. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/phase0-instructions-read.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/phase0-instructions-read.md new file mode 100644 index 000000000..ec1f9f554 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/phase0-instructions-read.md @@ -0,0 +1,44 @@ +# P0-T1 — Phase 0 policy reads + +Timestamp: 2026-09-03T08-20 + +Policy Order: CLAUDE.md, .claude/rules/general-code-change.md, .claude/rules/general-unit-test.md, .claude/rules/quality-tiers.md, .claude/rules/csharp.md, .claude/rules/tonality.md + +Command: read each of the six files below in the order listed, in the item worktree + +EXIT_CODE: 0 + +## Files read (in order) + +1. `CLAUDE.md` — 447 lines. Standing repository instructions: policy compliance order, General Code + Change Policy, General Unit Test Policy, C# Code Change Policy, C# Unit Test Policy, tone policy, + and the four-step C# toolchain (csharpier format, msbuild analyzer build, msbuild nullable build, + vstest). +2. `.claude/rules/general-code-change.md` — 80 lines. Design principles, module rigor tiers, + mandatory toolchain loop, 500-line file-size limit, error handling and logging, naming, public API + compatibility, dependencies, I/O boundaries. +3. `.claude/rules/general-unit-test.md` — 105 lines. Core principles, coverage requirements and the + coverage exclusion policy, scenario completeness, Arrange-Act-Assert structure, external + dependency rules, test file location, determinism infrastructure and banned timing APIs in tests. +4. `.claude/rules/quality-tiers.md` — 51 lines. T1-T4 module rigor tiers, the source of truth in + `quality-tiers.yml`, and the uniform-versus-tier-dependent gate matrix. +5. `.claude/rules/csharp.md` — 96 lines. C#-specific toolchain commands, coding standards, testing + standards, deterministic test rules, DI seams, the analyzer stack, and prohibited behaviors + (including "adding sleeps, retries, or timing hacks to mask flaky behavior"). +6. `.claude/rules/tonality.md` — 80 lines. Required professional tone, prohibitions on humor, + hyperbole, and metaphor, evidence-first wording, and rules for difficult messages. + +## Output Summary + +All six policy files were read in the order required by `policy-compliance-order`. All six exist in +the item worktree at the paths listed above. No policy file was modified. + +Threshold conflict observed and recorded here for P0-T12: CLAUDE.md (rank 1) states repository line +coverage `>= 80%` and new module/class/method coverage `>= 90%`; +`.claude/rules/general-unit-test.md` and `.claude/rules/quality-tiers.md` (ranks 3 and 4) state +`>= 85%` line and `>= 75%` branch. The rank-1 figures govern. + +Test-determinism rules relevant to this plan's AC5: `.claude/rules/general-unit-test.md` bans +`Thread.Sleep`, `Task.Delay`, real wall-clock waits, and `setTimeout` in test code; +`.claude/rules/csharp.md` lists "adding sleeps, retries, or timing hacks to mask flaky behavior" +under Prohibited Behaviors. The regression test this plan adds uses no timing construct. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/issue-updates/issue-584.2026-09-02T09-02.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/issue-updates/issue-584.2026-09-02T09-02.md new file mode 100644 index 000000000..b4e8e34c8 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/issue-updates/issue-584.2026-09-02T09-02.md @@ -0,0 +1,41 @@ +# Issue update mirror — issue #584 + +Timestamp: 2026-09-03T22-24 + +PostedAs: comment + +Comment URL: https://github.com/drmoisan/TaskMaster/issues/584#issuecomment-5534846382 + +`gh` was available and authenticated, so posting was not blocked. The exact text posted is reproduced +verbatim below. + +--- + +## Fix implemented — `UiThread.Dispatcher` null-dispatcher guard + +`UiThread.Dispatcher`'s getter now throws a named `InvalidOperationException` when its backing field has not been captured, instead of returning `null` and leaving the consumer to fail later with an unattributed `NullReferenceException`. The `null!` null-forgiving suppression is removed and the backing field is redeclared `private static Dispatcher? _dispatcher;`, so the nullable analyser verifies the guard rather than being suppressed around it. + +### Changes + +| File | Change | +|---|---| +| `UtilitiesCS/Threading/UiThread.cs` | Guarded getter; backing field retyped to `Dispatcher?`; `null!` removed | +| `UtilitiesCS.Test/Threading/UiThread_Tests.cs` | New `UiThread_Dispatcher_Tests` class with two deterministic regression tests | +| `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` | `[DoNotParallelize]` added (writer of the process-global static) | +| `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` | `[DoNotParallelize]` added | +| `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` | `[DoNotParallelize]` added | +| `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` | Reflective setup/teardown snapshot retargeted from the public `Dispatcher` property to the private `_dispatcher` backing field | + +`UtilitiesCS/Threading/ProgressTrackerAsync.cs` is unmodified. Its `UiDispatcher = UiThread.Dispatcher;` statement now raises the named exception at the assignment, before the first dereference, so the fix in `UiThread.cs` alone converts the downstream failure into a self-diagnosing one. + +### Verification + +- Regression test: `Failed: 1` against the unfixed accessor, `Passed: 2` against the fixed accessor. No sleep, retry, or timing tolerance anywhere in the diff. +- `csharpier check .` — exit 0, 1576 files checked, no unformatted path. +- Analyzer build (`/t:Rebuild`, `EnableNETAnalyzers`, `EnforceCodeStyleInBuild`) — exit 0, 0 warnings, 0 errors. +- Nullable build (`/t:Rebuild`, `TreatWarningsAsErrors`) — exit 0, 0 errors. +- `UtilitiesCS.Test` — 4787 of 4787 passed (baseline 4785, plus the two new tests). +- `QuickFiler.Test` — 1312 of 1312 passed (baseline 1312). +- Coverage: baseline line-rate 0.70733, post-change 0.70736; `lines-valid` delta +42. Changed-line coverage 100% (8 of 8 coverable added lines). + +All seven acceptance criteria in the feature spec are checked off against evidence artifacts recorded under the feature folder. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/other/p3-t4-progresstrackerasync-unmodified.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/other/p3-t4-progresstrackerasync-unmodified.md new file mode 100644 index 000000000..c07be86cc --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/other/p3-t4-progresstrackerasync-unmodified.md @@ -0,0 +1,76 @@ +# P3-T4 — AC3 re-verified against the staged tree: ProgressTrackerAsync.cs is unmodified + +Timestamp: 2026-09-03T08-36 + +Command: +```text +env -C git add -A -- UtilitiesCS UtilitiesCS.Test +env -C git status --porcelain -- UtilitiesCS/Threading/ProgressTrackerAsync.cs +env -C git diff --name-status --cached 87cb4df338322844abfa580abea14df77e738e5c -- UtilitiesCS/Threading/ProgressTrackerAsync.cs +env -C git grep -n -F "UiDispatcher = UiThread.Dispatcher;" -- UtilitiesCS/Threading/ProgressTrackerAsync.cs +``` + +EXIT_CODE: +- `git add -A` — 0 +- `git status --porcelain` — 0 +- `git diff --name-status --cached` — 0 +- `git grep -n -F` — 0 + +## Output Summary + +Command 2 (`git status --porcelain` for that path) output, verbatim: + +```text +``` + +Nothing. Neither the index nor the working tree carries any change to +`UtilitiesCS/Threading/ProgressTrackerAsync.cs`. + +Command 3 (`git diff --name-status --cached` against BASE for that path) output, verbatim: + +```text +``` + +Nothing. The staged state is identical to BASE `87cb4df338322844abfa580abea14df77e738e5c` for this +path. This span observes the index directly, so it reports a real staged change if one exists; the +porcelain span above is its complement, catching an unstaged working-tree edit that `--cached` would +be blind to. Both are empty. + +Command 4 (`git grep`) output, verbatim: + +```text +UtilitiesCS/Threading/ProgressTrackerAsync.cs:33: UiDispatcher = UiThread.Dispatcher; +``` + +Exactly one line, at line **33**, the line number P0-T3 recorded. + +For context, the complete staged footprint under the two source directories after the `git add -A` +is exactly this plan's five owned files and nothing else: + +```text +M UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs +M UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs +M UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs +M UtilitiesCS.Test/Threading/UiThread_Tests.cs +M UtilitiesCS/Threading/UiThread.cs +``` + +## Why the fix in UiThread.cs alone converts this consumer's failure mode + +`ProgressTrackerAsync.InitializeAsync()` reads the process-global accessor at line 33 +(`UiDispatcher = UiThread.Dispatcher;`) and does not touch the resulting instance field until line 35 +(`await UiDispatcher.InvokeAsync(`). Before this change, a read taken before `UiThread.Initialize()` +had populated the backing field returned `null` silently at line 33, and the failure surfaced two +lines later as a bare `NullReferenceException` at the `InvokeAsync` dereference — an exception whose +message and stack named the dispatch site rather than the missing initialisation, which is what made +the defect hard to attribute. With the guard added to the `Dispatcher` getter in +`UtilitiesCS/Threading/UiThread.cs`, the property read on line 33 now throws an +`InvalidOperationException` whose message names `UiThread.Init()` and `UiThread.Initialize()`, and +control never reaches line 35. The consumer therefore receives a self-diagnosing exception at the +property-access line without any code change in this file, which is exactly what AC3 requires the +plan to record. + +## Acceptance + +All three clauses satisfied: the porcelain status command printed nothing, the `--cached` name-status +diff printed nothing, and the grep printed exactly one line whose line number is 33. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/other/p5-t10-footprint.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/other/p5-t10-footprint.md new file mode 100644 index 000000000..fda289048 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/other/p5-t10-footprint.md @@ -0,0 +1,111 @@ +# P5-T10 — Committed footprint verification + +Timestamp: 2026-09-03T22-30 + +Command: +```text +env -C git diff --name-status 87cb4df338322844abfa580abea14df77e738e5c..HEAD +env -C git status --porcelain -- UtilitiesCS UtilitiesCS.Test "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584 +``` + +EXIT_CODE: +- command 1 — 0 +- command 2 — 0 + +Aggregate EXIT_CODE: 0 + +## Output Summary + +### Command 1 — anchored name-status diff, verbatim + +```text +M QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs +M UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs +M UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs +M UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs +M UtilitiesCS.Test/Threading/UiThread_Tests.cs +M UtilitiesCS/Threading/UiThread.cs +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t10-utilitiescs-tests-coverage.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t11-quickfiler-tests.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t12-threshold-reconciliation.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t13-parallel-bucket-census.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t14-reflective-dispatcher-census.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t2-uithread-rederivation.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t3-progresstrackerasync-rederivation.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t4-test-rederivation.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t5-toolchain-resolution.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t6-mcp-probe.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t7-csharpier-check.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t8-analyzer-build.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t9-nullable-build.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/phase0-instructions-read.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/issue-updates/issue-584.2026-09-02T09-02.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/other/p3-t4-progresstrackerasync-unmodified.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p1-t5-donotparallelize.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p2-t2-nullforgiving-removed.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p2-t3-file-size.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p3-t1-analyzer-build.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p3-t5-no-timing-tokens.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t1-format.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t2-format-check.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t3-analyzer-build.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t4-nullable-build.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t5-utilitiescs-tests.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t6-quickfiler-tests.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t7-coverage-delta.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t8-loop-closure.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p1-t3-build-before-fix.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p1-t4-expect-fail.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p3-t2-regression-green.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p3-t3-at-risk-tests.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p3-t6-quickfiler-wpfuidispatcher.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p4-t6-first-pass-failure.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/issue.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/plan.2026-09-02T09-02.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/research/defect-scoping.2026-09-02T09-02.md +A docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md +``` + +### Command 2 — scoped porcelain status, verbatim + +```text + M docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/plan.2026-09-02T09-02.md +?? docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/other/p5-t10-footprint.md +``` + +## Acceptance + +1. **Source paths.** The anchored name-status diff lists exactly these six source paths and no other + source path, apart from paths under + `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/`: + + - `UtilitiesCS/Threading/UiThread.cs` (M) + - `UtilitiesCS.Test/Threading/UiThread_Tests.cs` (M) + - `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` (M) + - `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` (M) + - `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` (M) + - `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` (M) + + Every other entry in the diff is under the feature folder. Those entries appear with status `A` + because the feature folder was untracked at BASE and was committed for the first time by P5-T9. + +2. **Baseline format drift set.** The diff lists no path in the `BASELINE_FORMAT_DRIFT_SET` recorded + in P0-T7. That set is `NONE`, so the clause is satisfied by an empty set, and independently by the + fact that P4-T1's formatter write scope was restricted to the six owned paths and therefore + rewrote no unowned path at all. + +3. **Gated paths.** The diff lists no path under `.claude/`, `.codex/`, `.agents/`, + `config/blast-radius.json`, or `config/orchestration-routing.json`. This was checked by filtering + the diff output for those prefixes; the filter matched nothing and exited 1. Five untracked files + under `.claude/agent-memory/` exist in this worktree from earlier delegations; they remain + untracked and uncommitted, and they are outside this command's scope. + +4. **Porcelain.** The scoped porcelain output lists + `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/plan.2026-09-02T09-02.md`, + modified by this plan's own check-off of P5-T9, and no path outside + `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/`. P5-T11 commits + both the plan file and this artifact together. + +The two-dot form is correct and not vacuous here: P5-T9 has already committed, so `HEAD` is no longer +identical to BASE. Commit SHA created by P5-T9: `a88a0b0c0e70d8ba59d15e2c03a97324fb8d95e3`. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/other/p5-t12-ac-status-summary.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/other/p5-t12-ac-status-summary.md new file mode 100644 index 000000000..cf89f04a4 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/other/p5-t12-ac-status-summary.md @@ -0,0 +1,44 @@ +# P5-T12 — Acceptance-criteria status summary + +Timestamp: 2026-09-03T22-33 + +Command: +```text +env -C grep -n "^- \[.\] AC" docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md +``` + +EXIT_CODE: 0 + +## Output Summary + +Acceptance-criteria source (work mode full-bug): +`docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md`. + +Every criterion below was read from that file after the check-off tasks ran. Every evidence path +listed was confirmed to exist on disk. + +| Criterion | Check state in spec.md | Evidence artifact paths | +|---|---|---| +| AC1 | `- [x]` | `evidence/regression-testing/p1-t4-expect-fail.md`; `evidence/regression-testing/p3-t2-regression-green.md` | +| AC2 | `- [x]` | `evidence/qa-gates/p2-t2-nullforgiving-removed.md`; `evidence/qa-gates/p4-t4-nullable-build.md` | +| AC3 | `- [x]` | `evidence/other/p3-t4-progresstrackerasync-unmodified.md` | +| AC4 | `- [x]` | `evidence/qa-gates/p1-t5-donotparallelize.md`; `evidence/regression-testing/p3-t3-at-risk-tests.md`; `evidence/regression-testing/p3-t6-quickfiler-wpfuidispatcher.md`; `evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md`; `evidence/regression-testing/p4-t6-first-pass-failure.md`; `evidence/qa-gates/p4-t6-quickfiler-tests.md` | +| AC5 | `- [x]` | `evidence/qa-gates/p3-t5-no-timing-tokens.md`; `evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md` | +| AC6 | `- [x]` | `evidence/qa-gates/p4-t1-format.md`; `evidence/qa-gates/p4-t2-format-check.md`; `evidence/qa-gates/p4-t3-analyzer-build.md`; `evidence/qa-gates/p4-t4-nullable-build.md`; `evidence/qa-gates/p4-t5-utilitiescs-tests.md`; `evidence/qa-gates/p4-t6-quickfiler-tests.md`; `evidence/qa-gates/p4-t8-loop-closure.md` | +| AC7 | `- [x]` | `evidence/baseline/p0-t10-utilitiescs-tests-coverage.md`; `evidence/qa-gates/p4-t7-coverage-delta.md` | + +All evidence paths are relative to +`docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/`. + +### Acceptance Criteria Status + +- Source: `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md` +- Total AC items: 7 +- Checked off (delivered): 7 +- Remaining (unchecked): 0 +- Items remaining: none + +## Acceptance + +All seven criterion identifiers appear exactly once each in this artifact, each is recorded as +checked, and every named artifact path was confirmed present on disk before this file was written. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p1-t5-donotparallelize.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p1-t5-donotparallelize.md new file mode 100644 index 000000000..052bef6f8 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p1-t5-donotparallelize.md @@ -0,0 +1,77 @@ +# P1-T5 — Every remaining writer of UiThread._dispatcher moved out of the parallel bucket + +Timestamp: 2026-09-03T08-34 + +Command: +```text +env -C git grep -l -F '"_dispatcher"' -- UtilitiesCS.Test +env -C git grep -c -F DoNotParallelize -- UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs +``` + +EXIT_CODE: +- command 1 — 0 +- command 2 — 0 + +## Edits made (attribute-only, three files) + +1. `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` — ` [DoNotParallelize]` inserted + immediately after the `[TestClass]` on line 28, giving the two-line form. +2. `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` — ` [DoNotParallelize]` inserted + immediately after the `[TestClass]` on line 13, same two-line form. +3. `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` — line 14's ` [TestClass]` replaced with + the single line ` [TestClass, DoNotParallelize]`. The combined attribute list is used for this + one file only, because it is 514 lines at BASE and already exceeds the 500-line limit in + `.claude/rules/general-code-change.md`; the combined form adds the attribute without adding a + line, so this change does not deepen the pre-existing overage. + +**These changes are attribute-only.** No `using`, no assertion, no test body, and no member was +added, removed, or reordered in any of the three files. `IdleAsyncQueue_Tests.cs` and +`ProgressTrackerAsync_Tests.cs` in particular have every assertion unaltered, which is what keeps +this task compatible with AC4's "all pass, unmodified assertions" wording. `DoNotParallelize` +resolves in all three files without a new `using`, because each already imports +`Microsoft.VisualStudio.TestTools.UnitTesting`. + +Post-edit line counts, confirming the intended size effect: + +```text + 348 UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs (baseline 347, +1 line) + 206 UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs (baseline 205, +1 line) + 514 UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs (baseline 514, unchanged) +``` + +## Output Summary + +Command 1 output, verbatim: + +```text +UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs +UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs +UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs +UtilitiesCS.Test/Threading/UiThread_Tests.cs +``` + +Exactly the four expected paths and no other. The fourth is present because P1-T2 added the new +class's `DispatcherField()` helper to it. + +Command 2 output, verbatim: + +```text +UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs:1 +UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs:1 +UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs:1 +UtilitiesCS.Test/Threading/UiThread_Tests.cs:1 +``` + +Exactly four lines, one per path, each reporting a count of exactly `1`. + +## Acceptance + +Both clauses satisfied. This is the enforceable form of the isolation guarantee: every file in +`UtilitiesCS.Test` that names `UiThread._dispatcher` now carries the do-not-parallelize attribute, so +**zero writers of that field remain in the parallel bucket**. P0-T13 recorded the false-before state +for the identical second command (no output, exit 1), so the gate is demonstrably false before this +task and true after it. + +Moving a class from the parallel bucket to the serial bucket can only reduce the concurrency those +tests experience, so it cannot introduce a race. Whether it exposes a latent ordering dependency is +not asserted here; it is verified empirically by P3-T3 and P4-T5. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p2-t2-nullforgiving-removed.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p2-t2-nullforgiving-removed.md new file mode 100644 index 000000000..4cf8db328 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p2-t2-nullforgiving-removed.md @@ -0,0 +1,44 @@ +# P2-T2 — Null-forgiving suppression removed, nullable field declaration present + +Timestamp: 2026-09-03T08-36 + +Command: +```text +env -C git grep -c -F "null!" -- UtilitiesCS/Threading/UiThread.cs +env -C git grep -n -F "private static Dispatcher? _dispatcher;" -- UtilitiesCS/Threading/UiThread.cs +``` + +EXIT_CODE: +- command 1 — 1 (`git grep` exits 1 on zero matches) +- command 2 — 0 + +## Output Summary + +Command 1 output, verbatim: + +```text +``` + +No matching line. **Zero `null!` matches in `UtilitiesCS/Threading/UiThread.cs`.** + +Command 2 output, verbatim: + +```text +UtilitiesCS/Threading/UiThread.cs:149: private static Dispatcher? _dispatcher; +``` + +Exactly one line, whose path is `UtilitiesCS/Threading/UiThread.cs`. + +## Acceptance + +Both clauses satisfied. Both commands are scoped by pathspec to this one file, so neither is affected +by `null!` occurrences elsewhere in the repository (three remain in +`UtilitiesCS/Threading/ProgressTrackerAsync.cs`, which is deliberately outside this plan's write set +and untouched). + +The gate is false-before and true-after: P0-T2 recorded the pre-change state as exactly one `null!` +match, on line 140, in the backing-field declaration +`private static Dispatcher _dispatcher = null!; // set in Initialize() before any access`. That +declaration is now `private static Dispatcher? _dispatcher;` with both the null-forgiving initialiser +and its trailing comment removed, and the comment is no longer needed because the accessor now +enforces the invariant it used to assert in prose. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p2-t3-file-size.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p2-t3-file-size.md new file mode 100644 index 000000000..3ef104505 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p2-t3-file-size.md @@ -0,0 +1,50 @@ +# P2-T3 — File-size accounting across the five files this plan writes + +Timestamp: 2026-09-03T08-37 + +Command: +```text +env -C wc -l UtilitiesCS/Threading/UiThread.cs UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs +``` + +This is character-for-character the command P0-T13 ran (apart from the working-directory prefix), so +the before and after counts come from one counting idiom. + +EXIT_CODE: 0 + +## Output Summary + +```text + 172 UtilitiesCS/Threading/UiThread.cs + 179 UtilitiesCS.Test/Threading/UiThread_Tests.cs + 348 UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs + 206 UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs + 514 UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs + 1419 total +``` + +The trailing `total` row is ignored. + +| File | P0-T13 baseline | Post-change | Delta | Clause | Result | +|---|---|---|---|---|---| +| `UtilitiesCS/Threading/UiThread.cs` | 163 | **172** | +9 | 1 (< 500) | PASS | +| `UtilitiesCS.Test/Threading/UiThread_Tests.cs` | 104 | **179** | +75 | 1 (< 500) | PASS | +| `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` | 347 | **348** | +1 | 1 (< 500) | PASS | +| `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` | 205 | **206** | +1 | 1 (< 500) | PASS | +| `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` | 514 | **514** | 0 | 2 (<= baseline + 1) | PASS | + +PRE-EXISTING FILE-SIZE OVERAGE: UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs + +That overage exists at BASE `87cb4df338322844abfa580abea14df77e738e5c`, where the file is already +514 lines — above the 500-line limit in `.claude/rules/general-code-change.md` — and it is not +introduced by this change. P1-T5 added the attribute to that file by extending its existing attribute +list on line 14 to `[TestClass, DoNotParallelize]` rather than by adding a line, so the post-change +count is unchanged at 514, comfortably inside the baseline-plus-one tolerance. The tolerance exists +only because P4-T1's `csharpier` pass may split that attribute list onto two lines; P4-T8 re-audits +after the formatter has run. + +## Acceptance + +Clause 1 satisfied for the four in-scope files (172, 179, 348, and 206, each strictly less than 500). +Clause 2 satisfied for `ProgressTracker_Tests.cs` (514, equal to its baseline of 514 and therefore +less than or equal to baseline plus 1). diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md new file mode 100644 index 000000000..356c258d9 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md @@ -0,0 +1,119 @@ +# P2-T4 — EmailMoveMonitorTests reflection-target retarget + +Timestamp: 2026-09-03T21-36 + +Command: +```text +env -C git grep -c -F '"_dispatcher"' -- "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" +env -C git grep -c -F '"Dispatcher"' -- "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" +env -C git grep -c -F 'GetProperty(' -- "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" +env -C git grep -c -F '[TestMethod]' -- "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" +env -C wc -l "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" +env -C mkdir -p TestResults +env -C git diff 87cb4df338322844abfa580abea14df77e738e5c -- "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" > TestResults/p2-t4-emailmovemonitor.diff +env -C grep -E -i 'Thread\.Sleep|Task\.Delay|SpinWait|Retry|retries|Timeout\(|PushFrame' TestResults/p2-t4-emailmovemonitor.diff +env -C grep -E '^[-+]' TestResults/p2-t4-emailmovemonitor.diff | grep -F '.Should()' +``` + +EXIT_CODE: +- command 1 — 0 +- command 2 — 1 (`git grep` exits 1 on zero matches) +- command 3 — 1 (`git grep` exits 1 on zero matches) +- command 4 — 0 +- command 5 — 0 +- command 6 — 0 +- command 7 — 0 +- command 8 — 1 (`grep` exits 1 on zero matches) +- command 9 — 1 (second stage `grep` exits 1 on zero matches) + +Aggregate EXIT_CODE: 0 + +## Output Summary + +### Command 1 — field-name operand present exactly once + +```text +QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs:1 +``` + +### Command 2 — property-name operand absent + +```text +``` + +The command printed nothing and exited 1. `-c` suppresses the row entirely for a file with no match. + +### Command 3 — `GetProperty(` absent + +```text +``` + +The command printed nothing and exited 1. + +### Command 4 — test-method count unchanged + +```text +QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs:8 +``` + +### Command 5 — post-edit line count + +```text +320 QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs +``` + +Plan-stated pre-edit line count: 314. +Observed post-edit `wc -l` count: 320. +Difference: +6, accounted for entirely by the six inserted comment lines. The six modified lines (the +four lines of the cache declaration and the two `GetValue` call sites) add none. + +### Command 6 — results directory + +```text +``` + +`mkdir -p` printed nothing and exited 0. + +### Command 7 — BASE-anchored working-tree diff + +The command printed nothing to the terminal; its output was redirected. + +Byte size of `TestResults/p2-t4-emailmovemonitor.diff`: 2570 bytes. The diff is non-empty, so +clause 3 is satisfied and the two grep gates below had real content to inspect. + +### Command 8 — banned timing tokens in the whole diff + +```text +``` + +The command printed nothing and exited 1. None of the seven tokens `Thread.Sleep`, `Task.Delay`, +`SpinWait`, `Retry`, `retries`, `Timeout(`, `PushFrame` appears anywhere in the diff, on an added, +removed, or context line, searched case-insensitively. + +### Command 9 — assertions unchanged + +```text +``` + +The pipeline printed nothing and its second stage exited 1. No added and no removed line carries the +token `.Should()`, so no assertion in this file was altered. The one assertion inside the region this +task touches, `current.Should().BeSameAs(_capturedDispatcher);`, survives unchanged and appears in +the diff only as a context line. + +## Acceptance + +1. Satisfied. Command 1 reports a count of exactly `1`; commands 2 and 3 each print nothing and exit + 1. This is the false-before/true-after pair for the retarget: at BASE the file carried one + `"Dispatcher"` and one `GetProperty(` and zero `"_dispatcher"`, as recorded in P0-T14's census and + in the P4-T6 first-pass failure record; after this task it carries the reverse. +2. Satisfied. Command 4 reports a count of exactly `8`, unchanged from BASE. No test method was + added, removed, or renamed. +3. Satisfied. `TestResults/p2-t4-emailmovemonitor.diff` is 2570 bytes and therefore non-empty. +4. Satisfied. Command 8 printed nothing and exited 1. This span extends AC5's coverage to the sixth + owned file, which P3-T5's `UtilitiesCS UtilitiesCS.Test` pathspec does not reach. +5. Satisfied. Command 9 printed nothing and its second `grep` exited 1. +6. Satisfied. The post-edit `wc -l` count is exactly 320, strictly less than the 500-line limit in + `.claude/rules/general-code-change.md`. + +The change retargets one reflection lookup, alters no assertion, adds no `using` directive, and +leaves the `[TestMethod]` count at 8. No BLOCKED condition applies. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p3-t1-analyzer-build.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p3-t1-analyzer-build.md new file mode 100644 index 000000000..455d84fe2 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p3-t1-analyzer-build.md @@ -0,0 +1,32 @@ +# P3-T1 — Analyzer rebuild after the fix + +Timestamp: 2026-09-03T08-38 + +Command: +```text +env -C MSYS_NO_PATHCONV=1 msbuild.exe TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +``` + +EXIT_CODE: 0 + +## Output Summary + +Trailing MSBuild summary, verbatim: + +```text +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:18.39 +``` + +- Errors: **0** +- Warnings: **0** + +## Acceptance + +`EXIT_CODE: 0` and `0 Error(s)` — satisfied. The warning count of 0 is less than or equal to the +baseline analyzer warning count of 0 recorded in P0-T8 — satisfied. This is the first build that +compiles P1-T5's three attribute-only edits together with P2-T1's production fix, and it introduces +no analyzer diagnostic. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p3-t5-no-timing-tokens.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p3-t5-no-timing-tokens.md new file mode 100644 index 000000000..91155ebc6 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p3-t5-no-timing-tokens.md @@ -0,0 +1,61 @@ +# P3-T5 — AC5 verified across the whole change: no timing construct in any added line + +Timestamp: 2026-09-03T08-37 + +Command: +```text +env -C mkdir -p TestResults +env -C git diff 87cb4df338322844abfa580abea14df77e738e5c -- UtilitiesCS UtilitiesCS.Test > TestResults/p3-t5-source.diff +env -C grep -E '^\+' TestResults/p3-t5-source.diff | grep -E -i 'Thread\.Sleep|Task\.Delay|SpinWait|Retry|retries|Timeout\(|PushFrame' +``` + +EXIT_CODE: +- `mkdir -p TestResults` — 0 +- `git diff ... > TestResults/p3-t5-source.diff` — 0 +- the two-stage `grep` pipeline — 1 (the exit code of the second `grep`, which is what `grep` + returns when it finds no match) + +## Output Summary + +`TestResults/p3-t5-source.diff` byte size: **5626 bytes**. The diff is non-empty, so the gate had +real content to inspect; an empty diff would have been BLOCKED rather than PASS. + +The diff covers exactly this plan's five owned files: + +```text +diff --git a/UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs b/UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs +diff --git a/UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs b/UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs +diff --git a/UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs b/UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs +diff --git a/UtilitiesCS.Test/Threading/UiThread_Tests.cs b/UtilitiesCS.Test/Threading/UiThread_Tests.cs +diff --git a/UtilitiesCS/Threading/UiThread.cs b/UtilitiesCS/Threading/UiThread.cs +``` + +It contains **94** lines beginning with `+` (including the five `+++` file headers), so the filter +was applied to a substantial added-line set rather than to nothing. + +Third command output, verbatim: + +```text +``` + +Nothing. The second `grep` found no match among the added lines and exited 1. + +## Acceptance + +- `TestResults/p3-t5-source.diff` is non-empty (5626 bytes) — satisfied. +- The third command printed nothing and its second `grep` exited 1 — satisfied. + +None of the seven prohibited tokens — `Thread.Sleep`, `Task.Delay`, `SpinWait`, `Retry`, `retries`, +`Timeout(`, `PushFrame` — appears in any added line of this change, in any letter case. The search +was case-insensitive (`-i`) and did not distinguish code from comment, so the result covers the new +class's XML doc comment as well as its executable lines. The new regression test drives the accessor +contract directly through the private backing field, which is what makes it deterministic with no +timing construct at all. + +The diff span is anchored to BASE rather than left bare, so it cannot degrade into a +worktree-versus-index comparison, and it uses the single-ref working-tree form rather than the +two-dot form; at this point in the plan no commit has been made, so a two-dot span would have +returned an empty diff and the gate would have passed vacuously. + +`TestResults/` is gitignored by `.gitignore` line 39 (`[Tt]est[Rr]esult*/`) and `*.diff` files there +enter no porcelain, diff, or format gate in this plan. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t1-format.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t1-format.md new file mode 100644 index 000000000..228ad06eb --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t1-format.md @@ -0,0 +1,96 @@ +# P4-T1 — CSharpier format, write scope restricted to the six owned paths (second pass) + +Timestamp: 2026-09-03T21-44 + +Command: +```text +env -C git status --porcelain +env -C dotnet tool run csharpier format UtilitiesCS/Threading/UiThread.cs UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" +env -C git status --porcelain +``` + +The multi-path invocation was accepted by the pinned CSharpier 1.2.6 CLI, so the per-path fallback +was not used and only three commands were run. + +EXIT_CODE: +- command 1 (`git status --porcelain`, before) — 0 +- command 2 (`dotnet tool run csharpier format ...`) — 0 +- command 3 (`git status --porcelain`, after) — 0 + +## Output Summary + +### Formatter trailing summary line, verbatim + +```text +Formatted 6 files in 3827ms. +``` + +`N` in that line is the count of files PROCESSED, not the count rewritten, so the number alone is not +evidence of a no-op. The before-and-after tree observation below is what establishes what changed. + +### Unscoped porcelain, taken BEFORE the formatter ran + +```text + M "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" +M UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs +M UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs +M UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs +M UtilitiesCS.Test/Threading/UiThread_Tests.cs +M UtilitiesCS/Threading/UiThread.cs + M docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/plan.2026-09-02T09-02.md + M docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md +?? .claude/agent-memory/atomic-executor/project_bare_msbuild_not_on_path_in_git_bash.md +?? .claude/agent-memory/atomic-executor/project_msys_slash_switch_conversion_rule.md +?? .claude/agent-memory/atomic-executor/project_vstest_success_run_prints_no_failed_or_skipped_line.md +?? .claude/agent-memory/atomic-executor/project_worktree_isolation_guard_refuses_pwsh_from_bash.md +?? .claude/agent-memory/atomic-planner/project_584_uithread_dispatcher_guard_plan_seams.md +?? docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/ +``` + +### Unscoped porcelain, taken AFTER the formatter ran + +```text + M "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" +M UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs +M UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs +M UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs +M UtilitiesCS.Test/Threading/UiThread_Tests.cs +M UtilitiesCS/Threading/UiThread.cs + M docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/plan.2026-09-02T09-02.md + M docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md +?? .claude/agent-memory/atomic-executor/project_bare_msbuild_not_on_path_in_git_bash.md +?? .claude/agent-memory/atomic-executor/project_msys_slash_switch_conversion_rule.md +?? .claude/agent-memory/atomic-executor/project_vstest_success_run_prints_no_failed_or_skipped_line.md +?? .claude/agent-memory/atomic-executor/project_worktree_isolation_guard_refuses_pwsh_from_bash.md +?? .claude/agent-memory/atomic-planner/project_584_uithread_dispatcher_guard_plan_seams.md +?? docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/ +``` + +The two unscoped porcelain outputs are byte-identical. No entry appeared, disappeared, or changed +status, so no path outside the six owned paths was touched by the formatter, and no owned path +changed status either. + +RESTORED_UNOWNED_FORMAT_DRIFT: NOT APPLICABLE (formatter write scope restricted to the six owned paths) + +### Post-format line counts of the six owned paths, for reference by P4-T8 + +```text + 172 UtilitiesCS/Threading/UiThread.cs + 179 UtilitiesCS.Test/Threading/UiThread_Tests.cs + 348 UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs + 206 UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs + 514 UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs + 320 QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs +``` + +`QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` is unchanged at 320 lines, the same count +P2-T4 recorded, so the formatter did not re-wrap the retargeted `GetField(` call. + +## Acceptance + +Satisfied. `EXIT_CODE: 0` for the formatter, and the two unscoped porcelain outputs are identical, so +they differ in no entry at all, which trivially satisfies the clause that they differ only in entries +for the six owned paths. + +This task records a before-and-after tree observation in addition to the formatter's exit code, +because a formatter rewrites tracked source and still exits 0 after rewriting. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t2-format-check.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t2-format-check.md new file mode 100644 index 000000000..721e3021b --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t2-format-check.md @@ -0,0 +1,44 @@ +# P4-T2 — CSharpier check, whole repository (second pass) + +Timestamp: 2026-09-03T21-45 + +Command: +```text +env -C dotnet tool run csharpier check . +``` + +EXIT_CODE: 0 + +## Output Summary + +Console output, verbatim: + +```text +Checked 1576 files in 6384ms. +``` + +REPORTED_UNFORMATTED_SET: NONE + +CSharpier prints one `Error ` line per unformatted path when it finds any. No such line was printed +and the command exited 0, so the reported set is empty. + +## Acceptance + +Satisfied. None of this plan's six owned paths appears in the reported set, because the reported set +is empty: + +- `UtilitiesCS/Threading/UiThread.cs` — not reported +- `UtilitiesCS.Test/Threading/UiThread_Tests.cs` — not reported +- `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` — not reported +- `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` — not reported +- `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` — not reported +- `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` — not reported + +The subset clause is satisfied: `BASELINE_FORMAT_DRIFT_SET` recorded in P0-T7 is `NONE`, and the +empty reported set is a subset of the empty baseline set. As P4-T2's own wording anticipates, with a +`NONE` baseline this reduces to `EXIT_CODE: 0` with an empty reported set, which is what was +observed. No new drift was introduced during this plan's execution. + +This command was run over the whole repository (`.`), matching the read-only CI-parity command in +`.github/workflows/_format-check.yml`, so the check retains full repository scope even though +P4-T1's write scope is narrow. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t3-analyzer-build.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t3-analyzer-build.md new file mode 100644 index 000000000..5c1910944 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t3-analyzer-build.md @@ -0,0 +1,40 @@ +# P4-T3 — Analyzer build (second pass) + +Timestamp: 2026-09-03T21-47 + +Command: +```text +env -C MSYS_NO_PATHCONV=1 msbuild.exe TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +``` + +EXIT_CODE: 0 + +## Output Summary + +Trailing MSBuild summary, verbatim: + +```text +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:17.57 +``` + +- Errors: **0** +- Warnings: **0** + +Baseline analyzer warning count recorded in P0-T8: **0**. The observed warning count of 0 is less +than or equal to that baseline. + +The build ran `/t:Rebuild` as CLAUDE.md mandates, so `CoreCompile` was not skipped by MSBuild +incrementality and analyzers actually executed. All twelve projects in `TaskMaster.sln` reported +`Done Building Project ... (Rebuild target(s))`. The switch set is character-for-character the one +CLAUDE.md mandates; the only additions to the recorded line are the `env -C ` +working-directory prefix and the `MSYS_NO_PATHCONV=1 ` assignment required by constraint 4 of +"Shell constraints measured in this worktree". + +## Acceptance + +Satisfied: `EXIT_CODE: 0`, `0 Error(s)`, and the warning count of 0 is less than or equal to the +baseline analyzer warning count of 0 from P0-T8. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t4-nullable-build.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t4-nullable-build.md new file mode 100644 index 000000000..a1ba307cb --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t4-nullable-build.md @@ -0,0 +1,52 @@ +# P4-T4 — Type-check / nullable build (second pass) + +Timestamp: 2026-09-03T21-48 + +Command: +```text +env -C MSYS_NO_PATHCONV=1 msbuild.exe TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true +``` + +EXIT_CODE: 0 + +## Output Summary + +Trailing MSBuild summary, verbatim: + +```text +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:16.00 +``` + +- Errors: **0** +- Warnings: **0** + +Quoted command line, for the three clauses below: + +```text +env -C MSYS_NO_PATHCONV=1 msbuild.exe TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true +``` + +## Acceptance + +Satisfied on all four clauses: + +1. `EXIT_CODE: 0`. +2. `0 Error(s)`. +3. The quoted command line CONTAINS `msbuild.exe TaskMaster.sln`. The clause is worded as `contains` + rather than `begins with` because the recorded line begins with the `env -C ` + working-directory prefix and the `MSYS_NO_PATHCONV=1 ` assignment, and it records only the + executable spelling this shell requires (constraint 3 of "Shell constraints measured in this + worktree"). +4. The quoted command line contains no `Nullable=enable` substring and uses `/t:Rebuild`. These are + the substantive checks and both hold. The switch set is character-for-character the one CLAUDE.md + mandates and the one `.github/workflows/ci.yml` runs. + +This gate is what proves AC2's real value. With the backing field now declared `Dispatcher?` in a +file that opts into nullable analysis via its line-1 `#nullable enable` directive, a getter that +returned the field without narrowing it would raise `CS8603`, which +`/p:TreatWarningsAsErrors=true` promotes to a build error. The build reports `0 Error(s)`, so the +guarded getter narrows the field correctly. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t5-utilitiescs-tests.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t5-utilitiescs-tests.md new file mode 100644 index 000000000..f75938423 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t5-utilitiescs-tests.md @@ -0,0 +1,79 @@ +# P4-T5 — UtilitiesCS.Test run with Cobertura coverage (second pass) + +Timestamp: 2026-09-03T21-52 + +Command: +```text +env -C MSYS_NO_PATHCONV=1 dotnet-coverage collect --output coverage/p4-t5.cobertura.xml --output-format cobertura --settings coverage.config -- "\vstest.console.exe" UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /Logger:trx /ResultsDirectory:TestResults/p4-t5 /TestCaseFilter:TestCategory!=LiveOutlook +``` + +The flag set is identical to P0-T10's; the two differ only in the `--output` filename and the +`/ResultsDirectory` value. `/EnableCodeCoverage` is deliberately absent from both. + +EXIT_CODE: 0 + +## Output Summary + +### Test counts + +Console summary block, verbatim: + +```text +Test Run Successful. +Total tests: 4787 + Passed: 4787 +``` + +- **Total tests: 4787** (console summary block) +- **Passed: 4787** (console summary block) +- **Failed: 0** (read from the `failed` attribute of the single `` element in the TRX + written under `TestResults/p4-t5/` by this task's own `/Logger:trx` switch) +- **Skipped: 0** (derived as `total` minus `executed`) + +TRX `` values used for the derivation: + +- `total` = **4787** +- `executed` = **4787** +- `failed` = **0** +- derived `Skipped` = 4787 - 4787 = **0** + +The `notExecuted` attribute was NOT used, per constraint 5 of "Shell constraints measured in this +worktree"; the TRX logger hard-codes it to `0` regardless of the run's outcome. + +TRX SELECTED: most recently modified .trx in TestResults/p4-t5/ +Last-modified timestamp of the selected file: `2026-09-03 21:52:13.419159700 -0400`. +That directory held two `.trx` files at the moment this artifact was written (the one written by the +first Phase 4 pass on 2026-09-03 and the one this second pass produced), so the TRX selection rule +stated after constraint 5 applies. The selected file's own name is deliberately not recorded, and the +run's `Results File:` console line is deliberately not quoted, because `vstest.console.exe` composes +the default TRX filename from the host account name and the machine name and prints it inside a full +absolute host path. + +FAILING_TEST_SET: empty. + +### Post-change coverage figures + +Read from the root `` element of `coverage/p4-t5.cobertura.xml`: + +- `lines-covered` = **105935** +- `lines-valid` = **149761** +- `line-rate` = **0.7073603942281368** + +Supporting attributes on the same element, recorded for completeness: `branch-rate` += 0.46792920353982304, `branches-covered` = 13219, `branches-valid` = 28250, `complexity` = 31682. + +These are the raw unstripped `dotnet-coverage` figures for the `UtilitiesCS.Test` process and are not +the repository first-party figure CLAUDE.md's 80% refers to. They are the post-change figures P4-T7 +compares against the P0-T10 baseline. + +## Acceptance + +Satisfied on all four clauses: + +1. The failing-test set is empty, which is a subset of the empty `BASELINE_FAILURE_SET` recorded in + P0-T10, with no new member. +2. `Total tests` is 4787. The baseline `Total tests` recorded in P0-T10 is 4785, so 4787 is greater + than or equal to 4785 + 2. The two added tests are the pair P1-T2 added to + `UtilitiesCS.Test/Threading/UiThread_Tests.cs`. +3. The `total` (4787) and `executed` (4787) values from which `Skipped` was derived are recorded. +4. All three coverage attribute values are recorded as concrete numbers. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t6-quickfiler-tests.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t6-quickfiler-tests.md new file mode 100644 index 000000000..7b4c9e3e7 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t6-quickfiler-tests.md @@ -0,0 +1,112 @@ +# P4-T6 — QuickFiler.Test run (second pass, pass-after) + +Timestamp: 2026-09-03T22-07 + +Command: +```text +if [ -f docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p4-t6-first-pass-failure.md ]; then echo "FAIL_BEFORE_PRESERVATION: FOUND IN PLACE, LEFT UNTOUCHED"; else cp docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t6-quickfiler-tests.md docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p4-t6-first-pass-failure.md; echo "FAIL_BEFORE_PRESERVATION: CREATED FROM THE FIRST-PASS ARTIFACT"; fi +grep -n -F 'Failed: 8' docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p4-t6-first-pass-failure.md +env -C MSYS_NO_PATHCONV=1 PATH=":$PATH" vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /InIsolation /Logger:trx /ResultsDirectory:TestResults/p4-t6 /TestCaseFilter:TestCategory!=LiveOutlook +``` + +The first two commands ran from the worktree root, as the first action of this task and before the +test run. The test command's flag set is identical to P0-T11's; the two differ only in the +`/ResultsDirectory` value. + +EXIT_CODE: +- preservation conditional — 0 +- `grep -n -F 'Failed: 8' ...` — 0 +- `vstest.console.exe ...` — 0 + +Aggregate EXIT_CODE: 0 + +## Output Summary + +### FAIL_BEFORE_PRESERVATION: + +Branch line printed by the conditional, verbatim: + +```text +FAIL_BEFORE_PRESERVATION: CREATED FROM THE FIRST-PASS ARTIFACT +``` + +The destination `evidence/regression-testing/p4-t6-first-pass-failure.md` did not exist, so the +create branch ran and the first-pass artifact was copied to it before this task overwrote +`evidence/qa-gates/p4-t6-quickfiler-tests.md`. + +Verification `grep` output, verbatim: + +```text +23: Failed: 8 +30:- **Failed: 8** (read from the `failed` attribute of the single `` element in the TRX +105:| Fixed (P2-T1 applied) | `Total tests: 8` | **Failed: 8** | +``` + +The preserved file carries `Failed: 8` on three lines and the `grep` exited 0, so the fail-before +figure is verified against the file rather than assumed. No BLOCKED condition applies. + +### Test counts + +Console summary block, verbatim: + +```text +Test Run Successful. +Total tests: 1312 + Passed: 1312 +``` + +- **Total tests: 1312** (console summary block) +- **Passed: 1312** (console summary block) +- **Failed: 0** (read from the `failed` attribute of the single `` element in the TRX + written under `TestResults/p4-t6/` by this task's own `/Logger:trx` switch) +- **Skipped: 0** (derived as `total` minus `executed`) + +TRX `` values used for the derivation: + +- `total` = **1312** +- `executed` = **1312** +- `failed` = **0** +- derived `Skipped` = 1312 - 1312 = **0** + +The `notExecuted` attribute was NOT used, per constraint 5 of "Shell constraints measured in this +worktree". + +TRX SELECTED: most recently modified .trx in TestResults/p4-t6/ +Last-modified timestamp of the selected file: `2026-09-03 22:06:59.182210700 -0400`. +That directory held two `.trx` files at the moment this artifact was written (the one written by the +first, failing Phase 4 pass on 2026-09-03 and the one this second pass produced), so the TRX +selection rule stated after constraint 5 applies. The selected file's own name is deliberately not +recorded, and the run's `Results File:` console line is deliberately not quoted. + +FAILING_TEST_SET: empty. + +### The eight `EmailMoveMonitorTests` methods, console pass lines verbatim + +```text + Passed HookItem_FirstItemOfFolder_SubscribesBeforeItemMoveOnce_AndSharedFolderDoesNotResubscribe [143 ms] + Passed UnhookItem_RemovesLastItemForFolder_UnsubscribesBeforeItemMoveOnlyOnLastItem [4 ms] + Passed UnhookItem_Null_IsNoOp_NoComAccessNoMarshalInvocation [1 ms] + Passed UnhookItem_UsesCachedEntryIds_RemovesExactlyTheMatchingEntry [< 1 ms] + Passed AllComAccess_FlowsThroughInjectedMarshalDelegate [< 1 ms] + Passed UnhookAll_UnsubscribesEveryFolder_AndClearsState [< 1 ms] + Passed DuplicateHookOfSameItem_AndUnhookNeverHookedItem_DoNotThrowOrSpuriouslyUnsubscribe [4 ms] + Passed UnhookItem_InvokedFromThreadPoolThread_RunsComAccessOnMarshalTargetThread [1 ms] +``` + +These are exactly the eight methods the first pass of this task reported as failing, quoted from the +preserved first-pass copy at `evidence/regression-testing/p4-t6-first-pass-failure.md`. This block is +therefore the direct pass-after counterpart of that recorded fail-before. + +## Acceptance + +Satisfied on all five clauses: + +1. `Total tests` is **1312**, equal to the baseline `Total tests` recorded in P0-T11. No test was + added to or removed from this assembly, and the filter is unchanged. +2. `Passed` is **1312** and the failing-test set is EMPTY. +3. `Failed` is **0**, read from the `failed` attribute of the single `` element in the + TRX this task wrote under `TestResults/p4-t6/`. +4. The `total` (1312) and `executed` (1312) values from which `Skipped` was derived are recorded, and + the derived `Skipped` is **0**. +5. All eight `EmailMoveMonitorTests` methods are listed by name as passing in the console output, as + quoted verbatim above. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t7-coverage-delta.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t7-coverage-delta.md new file mode 100644 index 000000000..449916109 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t7-coverage-delta.md @@ -0,0 +1,123 @@ +# P4-T7 — Coverage delta and changed-line coverage + +Timestamp: 2026-09-03T22-12 + +Command: +```text +env -C git diff 87cb4df338322844abfa580abea14df77e738e5c -- UtilitiesCS/Threading/UiThread.cs +``` + +The Cobertura document `coverage/p4-t5.cobertura.xml` written by P4-T5 was then read to locate the +class node whose `filename` attribute ends in `Threading\UiThread.cs`, and that node's +`` elements were intersected with the added-line set derived from the diff. + +EXIT_CODE: 0 + +## Redaction + +`dotnet-coverage` writes the `filename` attribute as a full absolute host path containing a host +account name. Per this task's redaction rule, only the line-hit data is recorded below. The node is +identified by the repository-relative path `UtilitiesCS/Threading/UiThread.cs` and by its `name` +attribute `UtilitiesCS.UiThread`. No absolute path from the Cobertura file is reproduced in this +artifact. + +## Output Summary + +### Baseline coverage figures (from P0-T10) + +- `lines-covered` = **105901** +- `lines-valid` = **149719** +- `line-rate` = **0.7073317347831605** — raw unstripped dotnet-coverage line-rate for the UtilitiesCS.Test process; not the repository first-party figure CLAUDE.md's 80% refers to + +### Post-change coverage figures (from P4-T5) + +- `lines-covered` = **105935** +- `lines-valid` = **149761** +- `line-rate` = **0.7073603942281368** — raw unstripped dotnet-coverage line-rate for the UtilitiesCS.Test process; not the repository first-party figure CLAUDE.md's 80% refers to + +### Signed denominator difference + +`post-change lines-valid` minus `baseline lines-valid` = 149761 - 149719 = **+42** + +### Added-line set + +The diff produced one hunk, `@@ -134,10 +134,19 @@`. Its `+` lines carry these new-file line numbers: + +```text +137 138 139 140 141 142 143 144 145 146 149 +``` + +Eleven added lines. As a cross-check, the hunk's new-file length is 19 and it carries 8 context lines +(134, 135, 136, 147, 148, 150, 151, 152), and 8 + 11 = 19. + +### Intersection with the located class node + +The class node `UtilitiesCS.UiThread` contributes these `` elements inside +the added-line range: + +| line | hits | +|---|---| +| 138 | 1 | +| 139 | 1 | +| 140 | 1 | +| 141 | 1 | +| 142 | 1 | +| 143 | 1 | +| 145 | 1 | +| 146 | 1 | + +Intersected set: **{138, 139, 140, 141, 142, 143, 145, 146}** — eight line numbers, every one with +`hits` of 1 or more. + +Added lines 137, 144, and 149 carry no `` element in the coverage report. They are the `get` +accessor header, the closing brace of the `if` block, and the field declaration +`private static Dispatcher? _dispatcher;`, none of which is an emitted sequence point. Lines with no +`` element are not coverable and are not part of the intersected denominator. Line 147 does +carry a `` element with `hits` of 1, but it is a context line +(`private set => _dispatcher = value;`), not an added line, so it is outside the intersection. + +Two of the two other class nodes whose `filename` ends in `Threading\UiThread.cs` — +`UtilitiesCS.UiThread.SynchronizationContextAwaiter` and +`UtilitiesCS.UiThread.SynchronizationContextAwaiter.<>c` — contribute only lines 87 and 92-105, none +of which falls in the added-line set, so neither changes the intersection. + +### Changed-line coverage + +8 of 8 intersected lines have `hits` of 1 or more. + +**Changed-line coverage = 100.0%** + +Lines 141, 142, and 143 are the `throw new InvalidOperationException(` statement, its message +argument, and its closing `);`. Their `hits` of 1 records that the new regression test P1-T2 added +actually drives the throwing branch, so the guard's failure path is executed rather than merely +compiled. + +## Acceptance + +(a) **Satisfied.** The intersected set contains eight line numbers, which is at least two. The +coverage report resolved this file. + +(b) **Satisfied.** Every intersected line has `hits` of 1 or more, giving 100% changed-line coverage, +which satisfies the `>= 90%` new-code target from CLAUDE.md. + +(c) **Satisfied.** The signed `lines-valid` difference is **+42**, which is between 0 and 200 +inclusive. No `COVERAGE DENOMINATOR MISMATCH` is recorded, so clause (d) is evaluated rather than +VOID. The +42 is consistent with the source this plan adds: approximately six coverable lines in +`UtilitiesCS/Threading/UiThread.cs` and approximately seventy-five in +`UtilitiesCS.Test/Threading/UiThread_Tests.cs`, of which only the emitted sequence points enter the +denominator. + +(d) **Satisfied.** The post-change `line-rate` of 0.7073603942281368 is greater than or equal to the +baseline `line-rate` of 0.7073317347831605 minus 0.005, which is 0.7023317348. The observed change is ++0.0000286594, an increase, so the tolerance is not consumed at all. + +## Observation, not a gate + +The post-change `line-rate` of 0.7073603942281368 (70.74%) is below the `>= 80%` repository figure +from CLAUDE.md. That figure is explicitly non-comparable to this one: this is the raw, unstripped +`dotnet-coverage` line rate for the `UtilitiesCS.Test` process, whereas CLAUDE.md's 80% refers to the +repository's first-party testable denominator after third-party stripping. + +PRE-EXISTING FLOOR SHORTFALL. The baseline figure recorded in P0-T10, 0.7073317347831605 (70.73%), +was also below that floor, so the shortfall predates this change and is not caused by it. This change +moves the figure upward by 0.0000286594. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t8-loop-closure.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t8-loop-closure.md new file mode 100644 index 000000000..86261a35b --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t8-loop-closure.md @@ -0,0 +1,114 @@ +# P4-T8 — Phase 4 loop closure and post-format file-size re-audit + +Timestamp: 2026-09-03T22-15 + +Command: +```text +env -C wc -l UtilitiesCS/Threading/UiThread.cs UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" +see P4-T1..P4-T7 +``` + +EXIT_CODE: 0 + +## Output Summary + +### Phase 4 pass record — every pass, chronological order + +Phase 4 ran twice. Both passes are recorded below, earliest first, per clause 2. + +#### Pass 1 — 2026-09-03, first pass (FAILED at P4-T6) + +| Step | Outcome | Artifact path | +|---|---|---| +| P4-T1 Format | green | `evidence/qa-gates/p4-t1-format.md` (subsequently overwritten by pass 2) | +| P4-T2 Format check | green | `evidence/qa-gates/p4-t2-format-check.md` (subsequently overwritten by pass 2) | +| P4-T3 Analyze | green | `evidence/qa-gates/p4-t3-analyzer-build.md` (subsequently overwritten by pass 2) | +| P4-T4 Type-check | green | `evidence/qa-gates/p4-t4-nullable-build.md` (subsequently overwritten by pass 2) | +| P4-T5 Test UtilitiesCS.Test | green | `evidence/qa-gates/p4-t5-utilitiescs-tests.md` (subsequently overwritten by pass 2) | +| P4-T6 Test QuickFiler.Test | **FAILED**, EXIT_CODE 1, 8 of 1312 failing | preserved at `evidence/regression-testing/p4-t6-first-pass-failure.md` | +| P4-T7 Coverage delta | not reached | — | + +Pass 1 recorded `Total tests: 1312`, `Passed: 1304`, `Failed: 8` at P4-T6, with all eight failures in +`QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs`. That failure is the fail-before evidence +for P2-T4, and it is the reason this phase ran more than once. Its artifact was preserved under a +separate name by P4-T6's conditional preservation clause before pass 2 overwrote the qa-gates copy. + +Between pass 1 and pass 2, P2-T4 rewrote the tracked source file +`QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs`, retargeting its reflective dispatcher +snapshot from the public `Dispatcher` property to the private `_dispatcher` backing field. Because +that rewrite landed after pass 1's four earlier steps had run, pass 1's artifacts described a tree +that no longer existed, and every step in this phase was re-run in order against the tree P2-T4 left. + +#### Pass 2 — 2026-09-03, second pass (all steps green) + +| Step | EXIT_CODE | Artifact path | +|---|---|---| +| P4-T1 Format | 0 | `evidence/qa-gates/p4-t1-format.md` | +| P4-T2 Format check | 0 | `evidence/qa-gates/p4-t2-format-check.md` | +| P4-T3 Analyze | 0 | `evidence/qa-gates/p4-t3-analyzer-build.md` | +| P4-T4 Type-check | 0 | `evidence/qa-gates/p4-t4-nullable-build.md` | +| P4-T5 Test UtilitiesCS.Test | 0 | `evidence/qa-gates/p4-t5-utilitiescs-tests.md` | +| P4-T6 Test QuickFiler.Test | 0 | `evidence/qa-gates/p4-t6-quickfiler-tests.md` | +| P4-T7 Coverage delta | 0 | `evidence/qa-gates/p4-t7-coverage-delta.md` | + +Pass 2 is the pass whose artifacts this task lists, and it is the last entry. + +### Did any step after P4-T1 rewrite a tracked file? + +No. In pass 2: + +- P4-T1's own before-and-after unscoped `git status --porcelain` outputs are byte-identical, and the + formatter left all six owned files at the same line counts they had before it ran. +- P4-T2 is `csharpier check`, which is read-only; it reported no unformatted path. +- P4-T3 and P4-T4 are MSBuild `/t:Rebuild` invocations. They write build output under `bin/` and + `obj/`, both of which are gitignored, and they modify no tracked source file. +- P4-T5 and P4-T6 write only under `coverage/` and `TestResults/`, both gitignored. +- P4-T7 reads the diff and the Cobertura document; it writes only its evidence artifact. + +The unscoped `git status --porcelain` taken at this task reports exactly the same tracked-source +entries P4-T1 observed before the formatter ran: the six owned files and the two feature documents. +No tracked file outside that set changed state during the phase, so the loop did not need to restart +from P4-T1 after pass 2 and pass 2 is a single clean pass. + +### Post-format per-file line counts + +```text + 172 UtilitiesCS/Threading/UiThread.cs + 179 UtilitiesCS.Test/Threading/UiThread_Tests.cs + 348 UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs + 206 UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs + 514 UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs + 320 QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs +``` + +The trailing `total` row is ignored. + +| File | Baseline | Post-format | Clause | Result | +|---|---|---|---|---| +| `UtilitiesCS/Threading/UiThread.cs` | 163 (P0-T13) | **172** | P2-T3 clause 1 (< 500) | PASS | +| `UtilitiesCS.Test/Threading/UiThread_Tests.cs` | 104 (P0-T13) | **179** | P2-T3 clause 1 (< 500) | PASS | +| `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` | 347 (P0-T13) | **348** | P2-T3 clause 1 (< 500) | PASS | +| `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` | 205 (P0-T13) | **206** | P2-T3 clause 1 (< 500) | PASS | +| `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` | 514 (P0-T13) | **514** | P2-T3 clause 2 (<= baseline + 1) | PASS | +| `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` | 320 (P2-T4 post-edit) | **320** | clause 4 (< 500 and <= 320 + 2) | PASS | + +PRE-EXISTING FILE-SIZE OVERAGE: UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs + +That overage exists at BASE `87cb4df338322844abfa580abea14df77e738e5c`, where the file is already 514 +lines, and it is not introduced by this change. Its post-format count is unchanged at 514, so the +baseline-plus-one tolerance is not consumed. + +## Acceptance + +1. **Satisfied.** All seven steps are listed in order with their artifacts, and no step after P4-T1 + rewrote a tracked file, so the loop did not restart after pass 2. +2. **Satisfied.** Every Phase 4 pass is recorded explicitly, one entry per pass, in chronological + order. The first entry is the 2026-09-03 pass with P4-T1 through P4-T5 green and P4-T6 failing 8 + of 1312. The last entry is the pass whose artifacts this task lists. Two entries are recorded + because Phase 4 ran twice; the clause states no upper bound. +3. **Satisfied.** The first five post-format line counts satisfy both P2-T3 clauses evaluated against + the P0-T13 baseline counts, as tabulated above. +4. **Satisfied.** The sixth post-format line count is 320, which is strictly less than 500 and is + less than or equal to the post-edit count P2-T4 recorded (320) plus 2. `csharpier` did not re-wrap + the retargeted `GetField(` call, so the plan's intent of a count unchanged from P2-T4's was met + exactly and the plus-two tolerance was not consumed. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p1-t3-build-before-fix.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p1-t3-build-before-fix.md new file mode 100644 index 000000000..55ee83d90 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p1-t3-build-before-fix.md @@ -0,0 +1,31 @@ +# P1-T3 — Build with the new regression test against the UNFIXED production code + +Timestamp: 2026-09-03T08-33 + +Command: +```text +env -C MSYS_NO_PATHCONV=1 msbuild.exe TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +``` + +EXIT_CODE: 0 + +## Output Summary + +Trailing MSBuild summary, verbatim: + +```text +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:17.50 +``` + +- Errors: **0** +- Warnings: **0** (unchanged from the P0-T8 baseline warning count of 0) + +At this point `UtilitiesCS/Threading/UiThread.cs` still carries the unfixed accessor +(`get => _dispatcher;` over a `null!`-initialised field); only P1-T1 and P1-T2 have been applied. +The solution compiles cleanly with the new `UiThread_Dispatcher_Tests` class present, which is the +property that makes P1-T4's red a runtime assertion failure rather than a compile failure — a genuine +fail-before. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p1-t4-expect-fail.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p1-t4-expect-fail.md new file mode 100644 index 000000000..10874f804 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p1-t4-expect-fail.md @@ -0,0 +1,70 @@ +# P1-T4 [expect-fail] — The two new tests against the UNFIXED production code + +Timestamp: 2026-09-03T08-31 + +Command: +```text +env -C MSYS_NO_PATHCONV=1 PATH=":$PATH" vstest.console.exe UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll /InIsolation /Logger:trx /ResultsDirectory:TestResults/p1-t4 /TestCaseFilter:"FullyQualifiedName=UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests.Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize|FullyQualifiedName=UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests.Dispatcher_WhenBackingFieldIsPopulated_ReturnsThatSameInstance" +``` + +EXIT_CODE: 1 + +ExpectedExitCode: 1 + +## Output Summary + +Console summary block, verbatim: + +```text +Test Run Failed. +Total tests: 2 + Passed: 1 + Failed: 1 + Total time: 1.7757 Seconds +``` + +- **Total tests: 2** +- **Passed: 1** +- **Failed: 1** + +`Failed` is read from the console here, which is correct for this task: the aggregate `Failed:` line +is printed precisely because this run's failure counter is non-zero, by construction. Constraint 5 of +"Shell constraints measured in this worktree" restricts console-sourced `Failed` counts to exactly +this case. + +### Failing test + +`Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize` + +Verbatim FluentAssertions failure message: + +```text +Expected a to be thrown, but no exception was thrown. +``` + +The failure is a runtime assertion failure at +`UtilitiesCS.Test/Threading/UiThread_Tests.cs:line 150`, inside +`FluentAssertions.Specialized.DelegateAssertions.Throw`. It is not a compile failure — +P1-T3 recorded a clean `0 Error(s)` build immediately before this run — and it is not a harness +failure, because the sibling positive test passed in the same run. + +### Passing test + +`Dispatcher_WhenBackingFieldIsPopulated_ReturnsThatSameInstance` — **Passed [23 ms]**. + +Its passing here is expected and required: it proves the reflection arrangement (`typeof(UiThread) +.GetField("_dispatcher", BindingFlags.NonPublic | BindingFlags.Static)`), the field write, and the +`finally` restore path all work against the unfixed production code. The red in the negative test is +therefore attributable to the defect in the `Dispatcher` accessor and not to the test harness. + +### Fail-before evidence established + +Against the unfixed accessor `get => _dispatcher;`, reading `UiThread.Dispatcher` with a null backing +field returns `null` silently instead of throwing. That is the defect issue #584 reports: the +`NullReferenceException` then surfaces later and elsewhere, at the consumer's dereference site, with +no indication that `UiThread.Init()` was never called. + +Results-file reference is deliberately redacted: the TRX under `TestResults/p1-t4/` is identified by +its repository-relative results directory only. `vstest.console.exe` composes the default TRX +filename from the host account name and the machine name and prints it inside a full absolute host +path, so neither the filename nor the `Results File:` console line is recorded here. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p3-t2-regression-green.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p3-t2-regression-green.md new file mode 100644 index 000000000..101777a1b --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p3-t2-regression-green.md @@ -0,0 +1,59 @@ +# P3-T2 — The two new tests against the FIXED production code + +Timestamp: 2026-09-03T08-34 + +Command: +```text +env -C MSYS_NO_PATHCONV=1 PATH=":$PATH" vstest.console.exe UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll /InIsolation /Logger:trx /ResultsDirectory:TestResults/p3-t2 /TestCaseFilter:"FullyQualifiedName=UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests.Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize|FullyQualifiedName=UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests.Dispatcher_WhenBackingFieldIsPopulated_ReturnsThatSameInstance" +``` + +EXIT_CODE: 0 + +## Output Summary + +Console summary block, verbatim: + +```text +Test Run Successful. +Total tests: 2 + Passed: 2 + Total time: 1.8335 Seconds +``` + +- **Total tests: 2** (console summary block) +- **Passed: 2** (console summary block) +- **Failed: 0** (read from the `failed` attribute of the single `` element in the TRX + written under `TestResults/p3-t2/`; a green run prints no aggregate `Failed:` line at all, per + constraint 5 of "Shell constraints measured in this worktree") + +TRX ``: `total="2" executed="2" passed="2" failed="0"`. + +This task records no `Skipped` figure, so the `total` minus `executed` derivation does not apply and +the `notExecuted` attribute was not read. + +### Individual test outcomes (console-observed) + +```text + Passed Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize [99 ms] + Passed Dispatcher_WhenBackingFieldIsPopulated_ReturnsThatSameInstance [24 ms] +``` + +- `Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize` — **Passed**. + This test failed in P1-T4 against the unfixed accessor with + `Expected a to be thrown, but no exception was thrown.` It now + passes, and its `WithMessage("*UiThread.Initialize()*")` clause confirms the thrown message names + `UiThread.Initialize()`. +- `Dispatcher_WhenBackingFieldIsPopulated_ReturnsThatSameInstance` — **Passed**, as it also did + before the fix, confirming the fix did not change the populated-field path. + +`TestResults/p3-t2/` held exactly one `.trx` at the moment this artifact was written, so no selection +between multiple files was required. The file is identified by its repository-relative results +directory only; its own name is not recorded and the run's `Results File:` console line is not +quoted. + +## Acceptance + +`EXIT_CODE: 0` as observed from the shell; `Total tests: 2` and `Passed: 2` as observed in the +console summary block; `Failed: 0` read from the TRX `failed` attribute. All three clauses satisfied. + +Together with P1-T4 this establishes the fail-before / pass-after pair required by AC1. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p3-t3-at-risk-tests.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p3-t3-at-risk-tests.md new file mode 100644 index 000000000..1cfb152dd --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p3-t3-at-risk-tests.md @@ -0,0 +1,108 @@ +# P3-T3 — At-risk UtilitiesCS.Test classes after the fix + +Timestamp: 2026-09-03T08-35 + +Command: +```text +env -C MSYS_NO_PATHCONV=1 PATH=":$PATH" vstest.console.exe UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll /InIsolation /Logger:trx /ResultsDirectory:TestResults/p3-t3 /TestCaseFilter:"FullyQualifiedName~UtilitiesCS.Test.Threading.IdleAsyncQueue_Tests|FullyQualifiedName~UtilitiesCS.Test.Threading.ProgressTrackerAsync_Tests|FullyQualifiedName~UtilitiesCS.Test.ProgressTracker_Tests|FullyQualifiedName~WpfDispatcherYieldTests|FullyQualifiedName~OutlookFolderTreeServiceConcurrencyTests" +``` + +EXIT_CODE: 0 + +## Output Summary + +Console summary block, verbatim: + +```text +Test Run Successful. +Total tests: 41 + Passed: 41 + Total time: 2.2356 Seconds +``` + +- **Total tests: 41** (console summary block) +- **Passed: 41** (console summary block) +- **Failed: 0** (read from the `failed` attribute of the single `` element in the TRX + written under `TestResults/p3-t3/`) + +TRX ``: `total="41" executed="41" passed="41" failed="0"`. + +This task records no `Skipped` figure, so the `total` minus `executed` derivation does not apply and +the `notExecuted` attribute was not read. + +TRX SELECTED: most recently modified .trx in TestResults/p3-t3/ +Last-modified timestamp of the selected file: `2026-09-03 08:35:42.461615800 -0400`. +That directory held two `.trx` files (an earlier one dated 2026-09-02 from a prior preparation-cycle +run, and the one this task produced). The selected file's own name is not recorded and the run's +`Results File:` console line is not quoted. + +### Every executed test and its outcome (console-observed) + +```text + Passed YieldAsync_CanceledToken_ThrowsBeforeDispatcherYield [62 ms] + Passed GetSnapshotAsync_ConcurrentInitialRequests_CoalesceOntoOneBuild [62 ms] + Passed YieldAsync_ThreadAffinitizedDispatcherPresent_YieldsWithoutFallback [47 ms] + Passed YieldAsync_ThreadDispatcherAbsent_FallsBackToProcessGlobalDispatcher [2 ms] + Passed YieldAsync_WithoutDispatcher_RemainsStrict [1 ms] + Passed GetSnapshotAsync_WorkerOriginatedColdBuild_UsesCapturedStaDispatcher [96 ms] + Passed Increment_ShouldUpdateProgressAndForwardScaledValueAndJobName [9 ms] + Passed Report_ShouldClampValuesAboveOneHundred [< 1 ms] + Passed Report_ShouldThrowForNegativeValues [1 ms] + Passed SpawnChild_ShouldUseRemainingAllocationFromCurrentProgress [< 1 ms] + Passed Increment_ShouldAccumulateProgressValues [< 1 ms] + Passed Increment_ShouldClampAt100 [< 1 ms] + Passed Report_WithTupleOverload_ShouldSetValueAndJobName [< 1 ms] + Passed Report_DoubleOverload_ShouldThrowForNegative [< 1 ms] + Passed Report_DoubleOverload_ShouldClampAbove100 [< 1 ms] + Passed SpawnChild_WithAllocation_ShouldCreateChildWithSpecifiedAllocation [< 1 ms] + Passed SpawnChild_WithDoubleAllocation_ShouldRoundAndCreateChild [< 1 ms] + Passed Report_WithDoubleAndJobName_ShouldClampAt100 [< 1 ms] + Passed Report_WithDoubleAndJobName_ShouldThrowForNegative [< 1 ms] + Passed Constructor_WithParent_ShouldInheritJobName [< 1 ms] + Passed Report_WithJobName_RootReportsToStubPane [< 1 ms] + Passed SpawnChild_FromProgressedParent_MapsChildProgressIntoParentRange [< 1 ms] + Passed Report_At100Percent_SetsProgressToMaxAndForwardsToParent [< 1 ms] + Passed Report_WithValueAndJobName_UpdatesProgressAndForwardsMessage [< 1 ms] + Passed Report_ViaChild_ShiftsParentProgressByAllocatedRange [< 1 ms] + Passed Report_At100Percent_WhenRootTracker_ClosesProgressViewer [158 ms] + Passed Initialize_WithCurrentDispatcherAndScreen_InitializesViewerAndUpdatesUi [8 ms] + Passed ReportAsync_WithNegativeValue_ThrowsArgumentOutOfRangeException [1 ms] + Passed ReportAsync_WithValueOver100_ClampsTo100 [< 1 ms] + Passed ReportAsync_At100Percent_WhenRootTracker_ClosesProgressViewer [3 ms] + Passed Constructor_WithTokenSource_ShouldSetDefaultProperties [< 1 ms] + Passed Allocation_ShouldBeSettable [< 1 ms] + Passed StartingAt_ShouldBeSettable [< 1 ms] + Passed JobName_ShouldBeSettable [< 1 ms] + Passed Constructor_WithScreenOverload_HasSameDefaultsAsBasicConstructor [< 1 ms] + Passed Tracker_SetAllocationAndJobName_BothPropertiesReflectUpdatedValues [< 1 ms] + Passed ChildTracker_ConfiguredWithSubRange_AllocationAndStartingAtArePreserved [< 1 ms] + Passed InitializeAsync_WithCurrentDispatcher_InitializesAndReturnsTracker [5 ms] + Passed AddEntry_UseUiThreadFalse_ActionRunsExactlyOnce [12 ms] + Passed AddEntry_UseUiThreadTrue_DequeuesEntryAndSuppressesDispatcherException [1 ms] + Passed OnApplicationIdle_FirstItemThrows_SubsequentItemStillExecutes [< 1 ms] +``` + +41 lines, all `Passed`. No `Failed` and no `Skipped` per-test line appears. + +## Acceptance + +1. `Total tests` is **41**, greater than zero — the filter matched. Satisfied. +2. All five named tests are present in the executed set and all five passed: + - `AddEntry_UseUiThreadTrue_DequeuesEntryAndSuppressesDispatcherException` — Passed + - `YieldAsync_WithoutDispatcher_RemainsStrict` — Passed + - `InitializeAsync_WithCurrentDispatcher_InitializesAndReturnsTracker` — Passed + - `GetSnapshotAsync_WorkerOriginatedColdBuild_UsesCapturedStaDispatcher` — Passed + - `Initialize_WithCurrentDispatcherAndScreen_InitializesViewerAndUpdatesUi` — Passed (the + `[STATestMethod]` in `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` that writes + `UiThread._dispatcher`) + + Satisfied. +3. The failing set is **empty**, so it is trivially a subset of the empty `BASELINE_FAILURE_SET` + recorded in P0-T10. No `PRE-EXISTING FAILURE:` record is required. Satisfied. + +The two tests that exercise the "dispatcher unavailable" path — +`AddEntry_UseUiThreadTrue_DequeuesEntryAndSuppressesDispatcherException` and +`YieldAsync_WithoutDispatcher_RemainsStrict` — both still pass against the now-throwing accessor, +confirming their existing handling absorbs an `InvalidOperationException` from +`UiThread.Dispatcher` as it previously absorbed a `NullReferenceException`. No assertion in any of +these files was modified by this plan. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p3-t6-quickfiler-wpfuidispatcher.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p3-t6-quickfiler-wpfuidispatcher.md new file mode 100644 index 000000000..fec951802 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p3-t6-quickfiler-wpfuidispatcher.md @@ -0,0 +1,65 @@ +# P3-T6 — QuickFiler.Test WpfUiDispatcherTests after the fix + +Timestamp: 2026-09-03T08-38 + +Command: +```text +env -C MSYS_NO_PATHCONV=1 PATH=":$PATH" vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /InIsolation /Logger:trx /ResultsDirectory:TestResults/p3-t6 /TestCaseFilter:"FullyQualifiedName~QuickFiler.Controllers.Tests.WpfUiDispatcherTests" +``` + +EXIT_CODE: 0 + +## Output Summary + +Console summary block, verbatim: + +```text +Test Run Successful. +Total tests: 2 + Passed: 2 + Total time: 1.5160 Seconds +``` + +- **Total tests: 2** (console summary block) +- **Passed: 2** (console summary block) +- **Failed: 0** (read from the `failed` attribute of the single `` element in the TRX + written under `TestResults/p3-t6/`) + +TRX ``: `total="2" executed="2" passed="2" failed="0"`. + +This task records no `Skipped` figure, so the `total` minus `executed` derivation does not apply and +the `notExecuted` attribute was not read. + +TRX SELECTED: most recently modified .trx in TestResults/p3-t6/ +Last-modified timestamp of the selected file: `2026-09-03 08:38:21.447189000 -0400`. +That directory held two `.trx` files (an earlier one dated 2026-09-02 from a prior preparation-cycle +run, and the one this task produced). The selected file's own name is not recorded and the run's +`Results File:` console line is not quoted. + +### Individual test outcomes (console-observed) + +```text + Passed Construction_YieldsAnIUiDispatcher [31 ms] + Passed Invoke_InvokeAsync_BeginInvoke_ExecuteDelegateOnDispatcherThread [44 ms] +``` + +Both `[TestMethod]`s declared in `QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs` are listed by +name as passing. + +## Acceptance + +- `Total tests` is **2** as observed in the console summary block — satisfied. +- `Failed: 0` read from the TRX `failed` attribute — satisfied. +- Both `Construction_YieldsAnIUiDispatcher` and + `Invoke_InvokeAsync_BeginInvoke_ExecuteDelegateOnDispatcherThread` are listed by name as passing in + the console output — satisfied. + +## Why this class was run + +This class is not named in `spec.md` or in the research trail. It was found during the plan's +adversarial self-review by enumerating `new WpfUiDispatcher(` across the repository: the +parameterless constructor's provider closes over `UiThread.Dispatcher`, so a now-throwing accessor +could in principle change its behaviour. The plan-time expectation was that neither test would be +affected — the constructor only captures the provider lambda without invoking it, and the second test +installs a real dispatcher through `UiThreadDispatcherFixture` before any forwarding call. That +expectation is confirmed here by running the tests rather than asserted from reading. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p4-t6-first-pass-failure.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p4-t6-first-pass-failure.md new file mode 100644 index 000000000..30c8f6f0a --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p4-t6-first-pass-failure.md @@ -0,0 +1,139 @@ +# P4-T6 — QuickFiler.Test (final QA loop step 4, second assembly) — GATE FAILED + +Timestamp: 2026-09-03T08-42 + +Result: **FAILED**. This artifact records a gate that did not pass. Per the plan's fail-closed +evidence rule the outcome is BLOCKED, not PASS, and P4-T6 is left unchecked in the plan. + +Command: +```text +env -C MSYS_NO_PATHCONV=1 PATH=":$PATH" vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /InIsolation /Logger:trx /ResultsDirectory:TestResults/p4-t6 /TestCaseFilter:TestCategory!=LiveOutlook +``` + +EXIT_CODE: 1 + +## Output Summary + +Console summary block, verbatim: + +```text +Test Run Failed. +Total tests: 1312 + Passed: 1304 + Failed: 8 + Total time: 12.8534 Seconds +``` + +- **Total tests: 1312** (console summary block) — equal to the P0-T11 baseline of 1312, as expected, + since this plan adds no test to this assembly. +- **Passed: 1304** (console summary block) +- **Failed: 8** (read from the `failed` attribute of the single `` element in the TRX + written under `TestResults/p4-t6/`) +- **Skipped: 0** (derived as `total` minus `executed`) + +TRX `` values: `total="1312" executed="1312" passed="1304" failed="8"`. +Derived `Skipped` = 1312 - 1312 = **0**. The `notExecuted` attribute was NOT used. + +The TRX is identified by its repository-relative results directory `TestResults/p4-t6/` only; its +own name is not recorded and the run's `Results File:` console line is not quoted. + +## Failing set — 8 tests, all in one class + +All eight failures are in `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` +(`QuickFiler.Test` class `EmailMoveMonitorTests`), and each reports the same message, +`One or more errors occurred.`: + +```text + Failed HookItem_FirstItemOfFolder_SubscribesBeforeItemMoveOnce_AndSharedFolderDoesNotResubscribe [9 ms] + Failed UnhookItem_RemovesLastItemForFolder_UnsubscribesBeforeItemMoveOnlyOnLastItem [< 1 ms] + Failed UnhookItem_Null_IsNoOp_NoComAccessNoMarshalInvocation [< 1 ms] + Failed UnhookItem_UsesCachedEntryIds_RemovesExactlyTheMatchingEntry [< 1 ms] + Failed AllComAccess_FlowsThroughInjectedMarshalDelegate [< 1 ms] + Failed UnhookAll_UnsubscribesEveryFolder_AndClearsState [< 1 ms] + Failed DuplicateHookOfSameItem_AndUnhookNeverHookedItem_DoNotThrowOrSpuriouslyUnsubscribe [< 1 ms] + Failed UnhookItem_InvokedFromThreadPoolThread_RunsComAccessOnMarshalTargetThread [< 1 ms] +``` + +That is every `[TestMethod]` in the class. + +## Acceptance evaluation + +- The failing-test set is **not** empty and is **not** a subset of the `BASELINE_FAILURE_SET` + recorded in P0-T11, which was empty (1312 of 1312 passed at BASE). All eight are **new members**. + **Clause FAILED.** +- The `total` and `executed` values from which `Skipped` was derived are recorded. Clause satisfied. +- `Total tests` equals the baseline `Total tests` from P0-T11 (1312 = 1312). Clause satisfied. + +## Root cause, established by a controlled counterfactual rather than inferred + +`QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` reads `UiThread.Dispatcher` **reflectively, +through the property**, in both its `[TestInitialize]` and its `[TestCleanup]`: + +```csharp + private static readonly System.Reflection.PropertyInfo DispatcherProperty = + typeof(UiThread).GetProperty( + "Dispatcher", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static + ); + ... + [TestInitialize] + public void Setup() + { + _capturedDispatcher = DispatcherProperty?.GetValue(null); + _marshalInvocationCount = 0; + } + + [TestCleanup] + public void Cleanup() + { + object current = DispatcherProperty?.GetValue(null); + current.Should().BeSameAs(_capturedDispatcher); + } +``` + +The class's own comment states the design intent: "These tests never invoke the default (production) +marshal delegate, so they do not depend on UiThread being initialized." That premise held only while +the accessor returned `null` silently. With P2-T1's guard in place, `PropertyInfo.GetValue(null)` +invokes a getter that throws `InvalidOperationException`, which reflection wraps and MSTest surfaces +as an initialize/cleanup failure for every test in the class — hence 8 of 8, with sub-millisecond +durations consistent with bodies that never ran. + +### Counterfactual actually executed (not inferred) + +| Working-tree state of `UtilitiesCS/Threading/UiThread.cs` | Isolated run, `/TestCaseFilter:"FullyQualifiedName~EmailMoveMonitorTests"` | Result | +|---|---|---| +| Fixed (P2-T1 applied) | `Total tests: 8` | **Failed: 8** | +| BASE `87cb4df338322844abfa580abea14df77e738e5c` restored to the working tree, solution rebuilt | `Total tests: 8` | **Passed: 8** | + +Both runs used the same filter, the same assembly path, and the same worktree, and differed only in +the content of `UtilitiesCS/Threading/UiThread.cs`. The BASE file was restored to the working tree +only (the index was untouched), the counterfactual was run, and the fixed file was then restored and +the solution rebuilt; `git status --porcelain -- UtilitiesCS UtilitiesCS.Test` afterwards shows the +five owned paths at `M ` (working tree matching index), confirming the probe left no residue. + +The regression is therefore deterministic and attributable to P2-T1, not to load-related flakiness. + +## Why the plan's blast-radius analysis did not find this consumer + +`spec.md`'s Risks & Mitigations asserts that `git grep -n "UiThread.Dispatcher\b"` enumerated the +complete set of reads of the property. That search cannot match this call site, because the read is +not spelled `UiThread.Dispatcher` anywhere in the file: it is spelled +`typeof(UiThread).GetProperty("Dispatcher", ...)` and later `DispatcherProperty?.GetValue(null)`. A +reflective read over the property name is invisible to a source grep for the qualified member +expression. + +The plan's P0-T13 census covers the complementary case — reflective reads of the private **field**, +found with `git grep -n -F '"_dispatcher"'` — and correctly returned three files, all in +`UtilitiesCS.Test`. No equivalent census was run for reflective reads of the **property** name +`"Dispatcher"`, and that is the gap this failure occupies. + +## Remedy required, and why this executor did not apply it + +The remedy lies in `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs`, which is **outside this +plan's declared Scope**. The plan's "Scope: files this plan's diff writes" section names exactly five +source files, and the delegation instruction states that no file outside that list may be touched. +Adding this file to the write set and changing its snapshot/restore helpers to tolerate a throwing +getter is a new independent outcome that no task in this plan describes. + +Execution is therefore halted here and reported as BLOCKED, rather than resolved by editing an +out-of-scope file or by weakening this gate's acceptance. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/feature-audit.2026-09-04T04-05.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/feature-audit.2026-09-04T04-05.md new file mode 100644 index 000000000..5e150735d --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/feature-audit.2026-09-04T04-05.md @@ -0,0 +1,276 @@ +# Feature Audit — uithread-dispatcher-null-race-progresstrackerasync (#584) + +- **Date:** 2026-09-04 +- **Timestamp:** 2026-09-04T04-05 +- **Work Mode:** `full-bug` (marker at `issue.md:3`) +- **AC Source (sole, per work mode):** `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md`, Version 0.5 +- **Baseline:** `87cb4df338322844abfa580abea14df77e738e5c` +- **Branch:** `bug/uithread-dispatcher-null-race-progresstrackerasync-584` + +**BLOCKING FINDINGS COUNT (this artifact): 0** + +No `user-story.md` exists and none is expected: under `full-bug`, `spec.md` is the sole AC source. +Verified — the feature folder contains `issue.md`, `spec.md`, `plan.2026-09-02T09-02.md`, +`research/`, and `evidence/`, with no `user-story.md`. + +--- + +## 1. Acceptance Criteria Evaluation + +All seven criteria were already marked `[x]` in `spec.md` before this review. Each was re-evaluated +against its cited evidence, and the head-state facts underlying each were independently re-derived +where that was possible without running commands. **No criterion was found unsupported.** This +review checked off nothing new (nothing remained unchecked) and edited no AC text. + +### AC1 — Named `InvalidOperationException` instead of `NullReferenceException`, verified by a deterministic test — **PASS** + +| Clause | Verification | +|---|---| +| Throws a named `InvalidOperationException` | `UiThread.cs:139-144` — `if (_dispatcher is null) { throw new InvalidOperationException(...); }`. Read directly. | +| Message names the missing `Initialize()` | `UiThread.cs:142` — "The UI dispatcher has not been captured. Call UiThread.Init() so that UiThread.Initialize() runs before reading UiThread.Dispatcher." Names both the entry point and the initialiser. | +| Verified by a deterministic regression test | `UiThread_Tests.cs:133-158`. Asserts `Throw().WithMessage("*UiThread.Initialize()*")` at line 150-152. | +| Test does not rely on timing, sleeps, retries | Full read of the 179-line file: none of `Thread.Sleep`, `Task.Delay`, `SpinWait`, retry, `Timeout(`, `PushFrame`. The arrangement is a reflective field write. | +| Test does not rely on full-suite execution order | `[DoNotParallelize]` at `:122`; prior field value captured and restored in `finally` at `:155-157`. Passes in a 2-test isolated run (`p3-t2`). | + +**Fail-before / pass-after is genuine.** `p1-t4-expect-fail.md`: `Total tests: 2, Passed: 1, Failed: 1`, +with the verbatim message "Expected a `` to be thrown, but no +exception was thrown" at `UiThread_Tests.cs:150`. The build immediately preceding it +(`p1-t3-build-before-fix.md`) was clean, so this is an assertion-level RED, not a compile error, and +the sibling positive test passed in the same run, so it is not a harness failure. +`p3-t2-regression-green.md`: `Total tests: 2, Passed: 2`, TRX `failed="0"`. + +Cited evidence: `evidence/regression-testing/p1-t4-expect-fail.md`, +`evidence/regression-testing/p3-t2-regression-green.md`. Both exist and say what AC1 claims. + +### AC2 — `null!` removed, field declared `Dispatcher?`, nullable analyser verifies the guard — **PASS** + +| Clause | Verification | +|---|---| +| `null!` suppression removed | `p2-t2-nullforgiving-removed.md` records zero `null!` matches in the file (git grep exit 1). **Independently confirmed** by reading all 172 lines of `UiThread.cs`: no `null!` appears. | +| Field type is `Dispatcher?` | `UiThread.cs:149` — `private static Dispatcher? _dispatcher;`. Read directly. The stale trailing comment `// set in Initialize() before any access` is correctly removed with it. | +| Nullable analyser can verify the guard | `p4-t4-nullable-build.md`: exit 0, `0 Warning(s)`, `0 Error(s)` under `/p:TreatWarningsAsErrors=true` with `/t:Rebuild`. | + +**The gate is non-vacuous, and this was checked rather than assumed.** Two conditions had to hold for +the nullable build to constitute real evidence, and both do: + +1. `UiThread.cs:1` carries `#nullable enable` — verified by direct read. Nullable enforcement in this + repository is per-file opt-in, so without this directive the build would prove nothing about this + file. +2. The command uses `/t:Rebuild`, not `/t:Build`. A warm `/t:Build` skips `CoreCompile` when only + `/p:` values change and would return exit 0 having run no analysis. `p4-t4-nullable-build.md` + quotes the command line; `/t:Rebuild` is present and no `/p:Nullable=enable` substring appears, + matching CLAUDE.md and `.github/workflows/ci.yml`. + +With both true, a getter returning a `Dispatcher?` field as a non-nullable `Dispatcher` without +narrowing would raise `CS8603` and fail the build. `0 Error(s)` therefore proves the guard narrows +the field. AC2's substantive claim holds. + +### AC3 — `ProgressTrackerAsync.cs` left unmodified, with the verification recorded — **PASS** + +| Clause | Verification | +|---|---| +| File unmodified | `p3-t4-progresstrackerasync-unmodified.md`: empty `git status --porcelain` for that path *and* empty `git diff --name-status --cached 87cb4df3` for that path. The two spans are complementary — the porcelain catches an unstaged edit that `--cached` would miss, and vice versa. Both empty. | +| Complete staged footprint is only the owned files | Same artifact records exactly five `M` entries under `UtilitiesCS`/`UtilitiesCS.Test` and nothing else. | +| Consumer call site located | `git grep` returns exactly one hit at line 33: `UiDispatcher = UiThread.Dispatcher;`, matching the line number `p0-t3` recorded. | +| Verification paragraph recorded | Present in that artifact, §"Why the fix in UiThread.cs alone converts this consumer's failure mode". | + +The recorded reasoning is sound and I verified its premise: `InitializeAsync()` assigns from the +property at line 33 and does not dereference the resulting field until line 35, so with the guard in +place the exception is raised at the property access and control never reaches `InvokeAsync`. The +consumer receives a self-diagnosing failure with no code change in that file. + +Note for completeness: `p2-t2-nullforgiving-removed.md` records that three `null!` occurrences remain +in `ProgressTrackerAsync.cs`. That file is deliberately outside the write set and AC3 requires it to +be untouched, so leaving them is correct here, not an omission. + +### AC4 — No regression in the five named test files; assertions unmodified — **PASS** + +| Named file | Status | Evidence | +|---|---|---| +| `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` | Attribute-only change; all pass | `p1-t5-donotparallelize.md`; `p3-t3-at-risk-tests.md` (41/41) | +| `UtilitiesCS.Test/OutlookObjects/Folder/WpfDispatcherYieldTests.cs` | Unmodified; all pass | `p3-t3-at-risk-tests.md` | +| `UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderTreeServiceConcurrencyTests.cs` | Unmodified; all pass | `p3-t3-at-risk-tests.md` | +| `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` | Attribute-only change; all pass | `p1-t5-donotparallelize.md`; `p3-t3-at-risk-tests.md` | +| `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` | Reflection target retargeted; 8/8 pass | `p2-t4-*.md`; `p4-t6-first-pass-failure.md` (8 failing); `p4-t6-quickfiler-tests.md` (1312/1312) | + +**The binding "unmodified assertions" clause — verified against the complete file, not the diff +summary.** Read all 320 lines of `EmailMoveMonitorTests.cs`: + +- 8 `[TestMethod]` declarations, unchanged from the base count of 8. No test method added, removed, + or renamed. +- The only assertion inside the changed region is `current.Should().BeSameAs(_capturedDispatcher);` + at line 65. It is byte-identical to its pre-change form; only the expression producing `current` + changed, from `DispatcherProperty?.GetValue(null)` to `DispatcherField?.GetValue(null)`. +- No `Mock` setup, no `VerifyAdd`/`VerifyRemove`, no `Times` argument, and no helper + (`CountingPassThrough`, `CreateMail`, `CreateFolder`) is altered. +- No `using` directive added — the retarget reuses the fully-qualified `System.Reflection` spelling + the file already used. + +The change is exactly what the criterion permits: a retarget of one reflection lookup, plus six +explanatory comment lines. The clause holds. + +The two at-risk "dispatcher unavailable" tests are the substantive no-regression proof, and both +pass against the throwing accessor: +`AddEntry_UseUiThreadTrue_DequeuesEntryAndSuppressesDispatcherException` (asserts only that nothing +escapes the broad catch, with no type assertion) and `YieldAsync_WithoutDispatcher_RemainsStrict`. + +The amendment note on AC4 (round 15) is honest and correct: the criterion previously named four files +and carried a scope note asserting a regression in an unnamed fifth. Naming the fifth file and +returning the criterion to unchecked until the pass-after evidence existed was the right call — the +alternative would have left the repair with no criterion binding it and would have made the old scope +note literally false once the repair landed. + +### AC5 — No retry, sleep, or timing tolerance anywhere in the diff — **PASS** + +Two artifacts are required to make "anywhere in the diff" true, and both exist: + +| Artifact | Coverage | Result | +|---|---|---| +| `p3-t5-no-timing-tokens.md` | pathspec `UtilitiesCS UtilitiesCS.Test` — 5 of 6 owned files; 5626-byte BASE-anchored diff, 94 `+` lines | case-insensitive 7-token filter printed nothing, exit 1 | +| `p2-t4-emailmovemonitor-reflection-target.md` | the sixth file `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs`; 2570-byte BASE-anchored diff | identical filter over the whole diff (not just added lines) printed nothing, exit 1 | + +The union of the two pathspecs is exactly the six-file write set, so the criterion's "anywhere in the +diff" wording is now evidenced across the whole diff. The round-16 amendment note documenting *why* +this criterion was returned to unchecked — the round-15 write-set widening put a sixth file outside +`p3-t5`'s pathspec — is accurate and is the correct handling of an evidence gap. + +**Independently confirmed** for the file carrying the substantive new code: a full read of +`UiThread_Tests.cs` found none of `Thread.Sleep`, `Task.Delay`, `SpinWait`, retry/retries, +`Timeout(`, or `PushFrame`, in code or comment. The regression strategy is structural — force the +backing field to its pre-`Initialize()` state and assert on the accessor contract — which is why no +timing construct is needed. The pre-existing `new Thread(...)`/`Join()` at +`EmailMoveMonitorTests.cs:281-291` is a marshal-target simulation, untouched by this change, and is +not a wall-clock wait. + +### AC6 — Full C# toolchain passes in order in a single final pass, with per-step evidence — **PASS** + +| Step | Command | Result | Artifact | +|---|---|---|---| +| 1. Format | `dotnet tool run csharpier format .` | exit 0, `Formatted 6 files`, identical before/after unscoped porcelain | `p4-t1-format.md` | +| 2. Format check | `dotnet tool run csharpier check .` | exit 0, `Checked 1576 files`, empty reported set | `p4-t2-format-check.md` | +| 3. Analyze | `msbuild ... /t:Rebuild ... /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` | exit 0, `0 Warning(s)`, `0 Error(s)` | `p4-t3-analyzer-build.md` | +| 4. Type-check | `msbuild ... /t:Rebuild ... /p:TreatWarningsAsErrors=true` | exit 0, `0 Warning(s)`, `0 Error(s)` | `p4-t4-nullable-build.md` | +| 5. Test (`UtilitiesCS.Test`) | `dotnet-coverage collect ... -- vstest.console.exe ...` | exit 0, 4787/4787 | `p4-t5-utilitiescs-tests.md` | +| 6. Test (`QuickFiler.Test`) | `vstest.console.exe ...` | exit 0, 1312/1312 | `p4-t6-quickfiler-tests.md` | +| 7. Loop closure | — | all seven steps in order; both passes recorded | `p4-t8-loop-closure.md` | + +**"Single final pass" is satisfied correctly, not by omission.** Phase 4 ran twice, and both passes +are recorded chronologically. Pass 1 was green through P4-T5 and failed at P4-T6 (1304 of 1312). The +response was the policy-mandated one: P2-T4 rewrote a tracked source file, so the loop restarted from +step 1 and every step re-ran in order against the new tree. `p4-t8-loop-closure.md` then affirmatively +establishes that no step after P4-T1 rewrote a tracked file in pass 2 — the formatter's before/after +unscoped porcelain outputs are byte-identical, `csharpier check` is read-only, the msbuild steps write +only to gitignored `bin/` and `obj/`, and the test steps write only to gitignored `coverage/` and +`TestResults/`. Pass 2 is therefore a genuine single clean pass. The pass-1 failure artifact was +preserved under a distinct name before pass 2 overwrote the qa-gates copy, so the fail-before record +was not destroyed. + +Test-count arithmetic is consistent: baseline `UtilitiesCS.Test` 4785 -> 4787, exactly the two added +tests; `QuickFiler.Test` 1312 -> 1312, consistent with an attribute/reflection-only change. + +`p4-t7-coverage-delta.md` is correctly *not* cited under AC6; it is AC7's evidence. + +### AC7 — No repo-wide line-coverage regression; changed lines meet the `>= 90%` new-code target — **PASS** + +| Clause | Figure | Verdict | +|---|---|---| +| No regression vs baseline `line-rate` | 0.7073317347831605 -> 0.7073603942281368 (+0.0000286594) | PASS — an increase; the 0.005 tolerance is not consumed | +| Denominator comparability | `lines-valid` 149719 -> 149761 = **+42**, inside the 0-200 band | PASS — no `COVERAGE DENOMINATOR MISMATCH`, so the comparison stands rather than being VOID | +| Changed-line coverage >= 90% | 100.0% — 8 of 8 coverable added lines (138-143, 145, 146), each `hits >= 1` | PASS | + +The changed-line derivation is methodologically sound and I checked its two weak points: + +- **Non-coverable added lines are excluded correctly, and the exclusion is justified per line.** + Added lines 137, 144, and 149 carry no `` element (the `get` accessor header, the `if` + block's closing brace, and the field declaration). None is an emitted sequence point, so excluding + them from the denominator is correct rather than convenient. +- **The throw path is actually executed, not merely compiled.** Lines 141, 142, and 143 are the + `throw new InvalidOperationException(`, its message argument, and the closing `);`, and all three + carry `hits = 1`. This is the coverage-side corroboration of the AC1 test result. +- Context line 147 (`private set => _dispatcher = value;`) has a `` element with `hits = 1` but + is correctly excluded as a context line, not an added line. +- The other two class nodes for this file (`SynchronizationContextAwaiter` and its closure class) + contribute only lines 87 and 92-105, outside the added-line set, so they do not perturb the + intersection. This is the class-node double-count trap, and the artifact avoided it. + +**Recorded qualification, which this review endorses.** Both `line-rate` figures are raw, unstripped +`dotnet-coverage` rates for the whole `UtilitiesCS.Test` host process, and `p4-t7-coverage-delta.md` +labels them as such on every occurrence rather than presenting them as the repository figure. The +70.74% post-change rate is below CLAUDE.md's 80% and the uniform 85% floor, and the artifact records +this as an explicit `PRE-EXISTING FLOOR SHORTFALL` — the baseline at BASE was 70.73%, already below +it. AC7 does not claim the floor is met; it claims no regression and `>= 90%` on changed lines. Both +of those claims are true. The floor shortfall is recorded as a separate non-blocking FAIL row in +`policy-audit.2026-09-04T04-05.md` §5.2, where the denominator non-comparability is set out in full. + +--- + +## 2. Acceptance Criteria Status + +### Acceptance Criteria Status + +- Source: `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md` +- Total AC items: 7 +- Checked off (delivered): 7 +- Remaining (unchecked): 0 +- Items remaining: none + +Newly checked off by this review: **none** — all seven were already `[x]` and all seven are supported +by their cited evidence. No AC text was edited and no criterion was added or removed. + +| AC | Verdict | Cited evidence exists | Evidence supports the claim | +|---|---|---|---| +| AC1 | PASS | Yes | Yes — verified, plus head-state re-derivation | +| AC2 | PASS | Yes | Yes — verified, plus gate non-vacuity confirmed | +| AC3 | PASS | Yes | Yes — verified | +| AC4 | PASS | Yes | Yes — verified, plus full-file assertion audit | +| AC5 | PASS | Yes | Yes — verified, plus full-file token audit | +| AC6 | PASS | Yes | Yes — verified, including single-clean-pass semantics | +| AC7 | PASS | Yes | Yes — verified, including the exclusion and double-count checks | + +## 3. Baseline Comparison + +| Dimension | BASE `87cb4df3` | HEAD | Delta | +|---|---|---|---| +| `UiThread.Dispatcher` on unset field | returns `null` silently | throws `InvalidOperationException` naming `Init()`/`Initialize()` | defect removed at source | +| `_dispatcher` declaration | `private static Dispatcher _dispatcher = null!;` | `private static Dispatcher? _dispatcher;` | suppression removed; analyser re-engaged | +| `UtilitiesCS.Test` tests | 4785 passing | 4787 passing | +2, both new | +| `QuickFiler.Test` tests | 1312 passing | 1312 passing | 0 | +| Classes mutating `UiThread._dispatcher` in the parallel bucket | 3 | 0 | 4 classes serialised | +| Reflective reads of the `Dispatcher` *property* | 1 (`EmailMoveMonitorTests`) | 0 | retargeted to the field | +| Raw process-wide line rate | 0.70733 | 0.70736 | +0.0000287 | +| Files > 500 lines among changed files | 1 (`ProgressTracker_Tests.cs`, 514) | 1 (same file, 514) | 0 | + +## 4. Plan Delivery + +`plan.2026-09-02T09-02.md`, Version 2.1: **50 of 50 tasks complete, 0 unchecked** — verified by +checkbox count against the plan file. The write set declared in `spec.md` (six files) matches the +committed diff exactly, with no file added or omitted. + +## 5. Findings + +**BLOCKING FINDINGS: 0** + +No acceptance criterion is unsupported. No remediation is required and no +`remediation-inputs..md` artifact is produced. + +Non-blocking items carried in the companion artifacts: + +| ID | Severity | Artifact | Summary | +|---|---|---|---| +| F1 | FAIL (non-blocking) | policy-audit §5.2, §8 | Repo-wide raw coverage 70.736% line / 46.79% branch below the 85%/75% floors; non-comparable denominator, pre-existing, improved by this change | +| F2 | FAIL (non-blocking) | policy-audit §5.1, §8 | Canonical `artifacts/csharp/coverage.xml` absent; equivalent Cobertura documents present and parsed | +| F3 / CR-1 | PARTIAL (non-blocking) | policy-audit §2.9, code-review §3 | `ProgressTracker_Tests.cs` 514 lines > 500; pre-existing at BASE, branch delta 0 | +| F4 | PARTIAL (non-blocking) | policy-audit §8 | `artifacts/pr_context.*` stale — describe issue #565 on another branch; scope derived from the verified `87cb4df3..HEAD` range | +| F5 / CR-2 | Minor (non-blocking) | policy-audit §2.17, code-review §3 | Deferred follow-up recorded only as feature-folder prose; promote to a GitHub issue before merge | +| F8 | Low (non-blocking) | policy-audit §8, code-review §5 | `[DoNotParallelize]` census covered reflective writers only; indirect production writer path verified closed by this review | +| CR-3..CR-7 | Low (non-blocking) | code-review §3 | Test-code polish: asymmetric null guard, `?.` vacuous-assertion risk, `CurrentDispatcher` thread residue, attribute-form inconsistency, three null policies in one class | + +## 6. Verdict + +**ACCEPT.** All seven acceptance criteria are delivered and verified against evidence. The fix +addresses the reported defect at its structural root rather than its timing-dependent symptom, the +regression test is deterministic by construction with a provable assertion-level fail-before, and the +public-API behaviour change is accompanied by a blast-radius census that this review independently +reproduced and extended by one route (`using static`) the census had not enumerated. + +**Total blocking findings across all three artifacts: 0.** diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/issue.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/issue.md new file mode 100644 index 000000000..5e9c278c2 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/issue.md @@ -0,0 +1,178 @@ +# uithread-dispatcher-null-race-progresstrackerasync + +- Work Mode: full-bug +- Issue: #584 +- Issue URL: https://github.com/drmoisan/TaskMaster/issues/584 +- Promotion Type: bug +- Base Branch: main +- Merge Base: 5ebaaf105d8241f309f704d1ff90af2e32e5a6c1 +- Branch: bug/uithread-dispatcher-null-race-progresstrackerasync-584 +- Last Updated: 2026-09-02 + +## Note on this file's provenance + +`mcp__drm-copilot__new_active_feature_folder` produced `spec.md` and a plan template for this +folder but no `issue.md`, because there is no promotable source under +`docs/features/potential/**`: the GitHub issue body itself records that the originating potential +document was authored inside an epic-child worktree that is prohibited from committing under +`docs/features/potential/**`, so issue #584 is the durable record, not a file in this repository. +This `issue.md` was authored by hand from `gh issue view 584 --json body` plus the issue's comment +thread, per the recorded exception for this scenario. + +## Summary + +`UtilitiesCS.Threading.UiThread.Dispatcher` is a static property backed by a `null!`-initialised +field with no lazy initialisation. When it is read before `Initialize()` has completed, it returns +`null`, and `ProgressTrackerAsync.InitializeAsync()` dereferences it immediately. The result is a +non-deterministic `NullReferenceException` that surfaces only under full-suite CPU load. + +This was observed once during the post-change QC run for issue #449 and did not reproduce in +isolation or in two subsequent clean full-suite runs. No test was modified, no retry was added, and +no timing tolerance was applied to hide it. + +## Environment + +(not provided in the potential file; not required to reproduce — the defect is structural, not +environment-specific. See Root Cause below.) + +## Steps to Reproduce + +(not provided in the potential file as a deterministic repro; the issue was observed once during a +full-suite run and is not reliably reproducible on demand by timing alone. A deterministic +regression test is required instead — see "Deterministic repro strategy" below.) + +## Expected Behavior + +(not provided in the potential file; inferred from the issue's own "Proposed direction": accessing +`UiThread.Dispatcher` before `UiThread.Init()` has completed should either lazily initialise or fail +fast with a clear, explicit exception naming the missing `Initialize()` call, instead of silently +returning `null` and surfacing as an unattributed `NullReferenceException` at a downstream call +site.) + +## Actual Behavior + +(not provided in the potential file; from the issue body: a `NullReferenceException` is thrown at +`UtilitiesCS/Threading/ProgressTrackerAsync.cs:35`, inside `ProgressTrackerAsync.InitializeAsync()`, +because `UiThread.Dispatcher` returned `null`.) + +## Logs / Screenshots + +(not provided in the potential file.) + +## Impact / Severity + +(not provided in the potential file. From the issue body's "Impact" section: erodes trust in the +full-suite gate because a non-deterministic failure cannot be distinguished from a real regression; +production reachability of the same null-return path is unassessed; the `null!` suppression is +load-bearing and hides the hazard from the nullable analyser.) + +## Root cause — structural, not merely a timing flake + +Verified against `origin/main` at `5ebaaf105d8241f309f704d1ff90af2e32e5a6c1` on 2026-09-02. + +`UtilitiesCS/Threading/UiThread.cs:135-140`: + +```csharp +public static Dispatcher Dispatcher +{ + get => _dispatcher; + private set => _dispatcher = value; +} +private static Dispatcher _dispatcher = null!; // set in Initialize() before any access +``` + +The accessor returns the backing field unconditionally, with no guard and no lazy initialisation. +The `null!` null-forgiving operator suppresses the nullable-flow diagnostic that would otherwise +flag the hazard. + +`UiThread.AutoScaleFactor` and `UiThread.UiSyncContext` in the same file are lazy-initialising +counter-examples in the same class, demonstrating the omission on `Dispatcher` is unintentional +rather than a deliberate design choice. + +Consumer, `UtilitiesCS/Threading/ProgressTrackerAsync.cs:31-35`: + +```csharp +public async Task InitializeAsync() +{ + UiDispatcher = UiThread.Dispatcher; + + await UiDispatcher.InvokeAsync(() => +``` + +`UiDispatcher` is assigned from the static and dereferenced on the next statement with no guard, so +a null static becomes an unattributed `NullReferenceException` at the `InvokeAsync` call site. + +## Existing repository precedent for the fix shape + +`UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs:57-66` already treats a null +`UiThread.Dispatcher` fallback as an explicit, strict contract violation: + +```csharp +// UiThread.Dispatcher is set-once state populated by UiThread.Init() and is null +// outside a live host, so that null state is surfaced as InvalidOperationException to +// preserve the strict contract callers relied on. +Dispatcher? dispatcher = + _currentThreadDispatcherProvider() ?? _fallbackDispatcherProvider(); +if (dispatcher is null) +{ + throw new InvalidOperationException( + "The UI dispatcher has not been captured. Call UiThread.Init() before yielding folder tree work." + ); +} +``` + +This is the same idiom the issue's own "Proposed direction" item 2 recommends applying at the +source (`UiThread.Dispatcher` itself) instead of duplicating it per call site. +`UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` (`AddEntry_UseUiThreadTrue_...`) already +asserts only that no exception escapes `IdleAsyncQueue.OnApplicationIdle`'s internal +`catch (Exception ex)` when the dispatcher is unavailable, and does not assert on the concrete +exception type, so converting the failure from `NullReferenceException` to `InvalidOperationException` +at the source does not change that test's outcome. + +## Deterministic repro strategy + +Because the original failure is timing-dependent (1 of 3 full-suite runs; 0 of 1 isolated runs), the +regression test does not attempt to reproduce the race by timing. Instead it uses the same +reflection-based static-field access pattern already established in this test assembly (for example +`UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`'s `ForceDispatcherNull`/`RestoreDispatcher` +helpers) to force `UiThread`'s private `_dispatcher` backing field to its pre-`Initialize()` state +(`null`) and assert on `UiThread.Dispatcher`'s accessor contract directly. This is deterministic, +requires no sleep/retry/timing tolerance, and directly exercises the structural defect rather than +its timing-dependent symptom. + +## Scope boundary for this fix + +- In scope: `UtilitiesCS/Threading/UiThread.cs` (the `Dispatcher` accessor's null contract) and a + new deterministic regression test in `UtilitiesCS.Test/Threading/`. +- Verified not required: `UtilitiesCS/Threading/ProgressTrackerAsync.cs`. Once `UiThread.Dispatcher` + fails fast instead of returning `null`, the exception is raised at + `UiDispatcher = UiThread.Dispatcher;` (the property access itself), before + `UiDispatcher.InvokeAsync(...)` is ever reached, so the consumer already receives a + self-diagnosing failure without a code change. Recorded here because the assignment prompt named + this file as a fix site to verify, not assume. +- Out of scope, per the issue's own comment thread: the "injectable-seam conversion" replacing the + ~62 remaining direct reads of `UiThread.Dispatcher` across ~29 production files with the existing + `IUiDispatcher` seam. That is a multi-phase, multi-assembly refactor with no bounded blast radius, + already identified and explicitly deferred by the maintainer's own analysis on this issue thread. + Also out of scope: adding synchronization around the shared static in + `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`'s existing reflection-based mutation of + `UiThread._dispatcher` (a `#493`-shaped test-isolation concern in a different test assembly than + `#493` itself covered). This is noted as a candidate follow-up, not addressed here, to keep this + fix to the 1-3 production-file budget described in the assignment. +- Explicitly not touched, per binding scope constraints: `.claude/**`, `.codex/**`, `.agents/**`, + `config/blast-radius.json`, `config/orchestration-routing.json`. + +## Cross-references + +- #493 (`uithread-dispatcher-static-swap-no-restore`): adjacent, test-side isolation defect on the + same static; already resolved independently, does not touch `UtilitiesCS/Threading/UiThread.cs`. +- #508 (`wpf-dispatcher-yield-test-order-dependent`, archived): established the + `InvalidOperationException`-on-missing-dispatcher contract for `WpfDispatcherYield` and confirmed + via its own dependency/risk notes that "any broader refactor of `UiThread` static state" was out of + that issue's scope — the narrow fix proposed here is exactly that deferred, bounded follow-up for + the `Dispatcher` accessor itself. + +## Source + +From: docs/features/potential/2026-08-22-uithread-dispatcher-null-race-progresstrackerasync.md +(not present on disk; see "Note on this file's provenance" above) diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/plan.2026-09-02T09-02.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/plan.2026-09-02T09-02.md new file mode 100644 index 000000000..db655016e --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/plan.2026-09-02T09-02.md @@ -0,0 +1,2431 @@ +# uithread-dispatcher-null-race-progresstrackerasync (Plan) + +- **Issue:** #584 +- **Work Mode:** full-bug (AC source is `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md`; no `user-story.md` exists or is expected) +- **Owner:** drmoisan +- **Last Updated:** 2026-09-03 +- **Status:** In execution, revised mid-flight (revision round 17; findings D1-D11 from preflight round 1, B1-B3 and NB1-NB6 from preflight round 2, C1-C3 and N1-N2 from preflight round 3, E1-E3 and N-1/N-2/N-3 from preflight round 4, F1 from preflight round 5, and G1 plus O1-O4 from preflight round 6 applied, revision round 9 (backtick-removal presentation fix for the parallel-scheduling blast-radius harvester) applied, revision round 10 (mechanical backtick-removal correction: stripped backtick-wrapping from CLAUDE.md, .claude/rules/general-code-change.md, .claude/rules/general-unit-test.md, .claude/rules/quality-tiers.md, .claude/rules/csharp.md, .claude/rules/tonality.md, .claude/agent-memory/**, .claude/, .codex/, .agents/, config/blast-radius.json, config/orchestration-routing.json, and bare config/ mentions across plan.md and spec.md; no task, AC, command, or evidence-path substance changed) applied, revision round 11 (BASE re-anchor after the orchestrator's origin/main reconciliation merge) applied, revision round 15 (scope widened to a sixth owned file after P4-T6 found a reflective consumer no census in this plan had enumerated) applied, revision round 16 (preflight round 16 findings B1, B2, NB6, NB7 applied) applied, revision round 17 (preflight round 17 non-blocking findings N1-N4 applied) applied) +- **Version:** 2.1 +- **Branch:** `bug/uithread-dispatcher-null-race-progresstrackerasync-584` +- **BASE (merge base with `origin/main`):** `87cb4df338322844abfa580abea14df77e738e5c` + +**BASE re-anchor (revision round 11, 2026-09-03).** The orchestrator merged `origin/main` into this +item branch to reconcile seven sibling items that had merged upstream. The merge is recorded in this +worktree's git log as `merge origin/main: Merge made by the 'ort' strategy` producing commit +`a2ef517b`. It touched none of the five source files this plan wrote at that time — the sixth was +added in revision round 15, after this merge, and the merge did not touch it either: citations 63 through 66 below +re-read the owned-file anchors against the post-merge tree and found them unchanged apart from four +`.csproj` line numbers, which are corrected in this round, and `atomic-executor` separately measured +`git diff --name-status 87cb4df338322844abfa580abea14df77e738e5c..HEAD` in this worktree as listing +exactly the four feature-folder documents and zero source paths. The merge base with +`origin/main` therefore moved from the promotion-time value `5ebaaf105d8241f309f704d1ff90af2e32e5a6c1` +to the value stated above, which is also the current tip of `origin/main`. Every `git diff` command +and every acceptance clause in this plan is anchored to the value stated above; no command in this +plan is anchored to the superseded value. `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/issue.md` +line 8 still records the promotion-time value and is deliberately NOT edited by this plan, because +that file is outside this plan's write set; citation 19 and the corresponding `CITATION:` line at the +end of this file state that explicitly. Where a "Citations re-derived in the revision pass of ..." +section below names `5ebaaf105d8241f309f704d1ff90af2e32e5a6c1`, it records the then-current merge base +as a historical observation of an earlier round and does not assert the current BASE. + +**Scope widening (revision round 15, 2026-09-03).** `atomic-executor` executed this plan from P0-T1 +through P4-T5 and stopped at P4-T6. That task ran the full `QuickFiler.Test` assembly and returned +`Total tests: 1312`, `Passed: 1304`, `Failed: 8` against a P0-T11 baseline of 1312 of 1312 with an +empty `BASELINE_FAILURE_SET`. All eight failures are every `[TestMethod]` in +`QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs`, and the executor attributed them to P2-T1 +by an executed counterfactual rather than by inference: with BASE `UiThread.cs` restored to the +working tree and the solution rebuilt, the same eight tests passed; with the fixed file in place they +failed. That evidence is recorded in +`docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t6-quickfiler-tests.md`. + +The mechanism is that this test class snapshots the process-global dispatcher through the PUBLIC +`Dispatcher` property by reflection, in its `[TestInitialize]` and `[TestCleanup]`, rather than +through the private `_dispatcher` backing field the three `UtilitiesCS.Test` sites use. P2-T1 makes +that getter throw when the field is null, and `PropertyInfo.GetValue(null)` on a throwing getter +propagates the exception, so the class's setup fails outright. + +The gap that produced it is a census gap, and it is closed by a new task rather than by a narrative. +This plan ran `git grep -F '"_dispatcher"'` as the census for reflective reads of the private FIELD +(P0-T13) and correctly found three files. It never ran the corresponding census for reflective reads +of the PROPERTY name, and `spec.md`'s Risks & Mitigations rested on +`git grep -n "UiThread.Dispatcher\b"`, which matches only the literal qualified member expression and +therefore cannot match `typeof(UiThread).GetProperty("Dispatcher", ...)` at all. New task P0-T14 runs +that missing census across all nine test assemblies and repository-wide across `.cs` files, and +records its full result. + +This round therefore: adds `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` as a sixth owned +file; adds P0-T14 (the property-name census) and P2-T4 (the reflection-target repair); widens the +owned-path lists and pathspecs in P4-T1, P4-T2, P4-T8, P5-T9, P5-T10, P5-T11, P5-T12, and P5-T13 to +six paths; restates P4-T6's acceptance to require `1312` of `1312` passing; and amends `spec.md`'s +AC4, Write Set, Scope, and Risks sections. It changes no task ID and renumbers nothing. + +**Phase 4 first-pass marks cleared by this round.** P4-T1 through P4-T5 completed one pass and are +returned to `[ ]` in this round, because P2-T4 rewrites a tracked source file after that pass ran. +AC6 requires the toolchain to pass "in order in a single final pass", and the four steps' first-pass +artifacts describe a tree that no longer exists, so leaving them checked would let AC6 be claimed on +observations that predate the last source edit. The first pass is not erased: its outcome is recorded +in this note and P4-T8's acceptance requires the loop-closure artifact to record every pass in +chronological order, the first one included. Every +task in Phase 0, Phase 1, Phase 2 (P2-T1 through P2-T3), and Phase 3 keeps the check state the +executor left, because none of their acceptance conditions observes the file P2-T4 writes: P0-T13's +and P1-T5's censuses are pathspec-scoped to `UtilitiesCS.Test`, P2-T2's and P2-T3's spans name their +own files, P3-T4's names one file, P3-T5's diff pathspec is `UtilitiesCS UtilitiesCS.Test`, and +P3-T6 runs a different class in `QuickFiler.Test`. P3-T5's pathspec is the one span this widening +would otherwise leave short of AC5's "anywhere in the diff" wording, and P2-T4 carries the identical +seven-token filter over its own file so that gap is closed by a task rather than by a re-run of a +completed one. + +**Fail-closed evidence rule:** Every baseline, QA-gate, regression, and coverage-comparison task below +names its artifact path. If a required artifact is missing or is missing any of `Timestamp:`, +`Command:`, `EXIT_CODE:`, `Output Summary:`, the outcome is BLOCKED or INCOMPLETE, never PASS. + +**Evidence location invariant:** all evidence for this item is written under +`docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/` in the +sub-kinds `baseline/`, `regression-testing/`, `qa-gates/`, `issue-updates/`, and `other/`. No evidence +is written under `artifacts/`. + +--- + +## Scope: files this plan's diff writes + +Production and test source (exactly six files): + +- `UtilitiesCS/Threading/UiThread.cs` — the `Dispatcher` accessor and its backing field (P2-T1). +- `UtilitiesCS.Test/Threading/UiThread_Tests.cs` — one added `using`, one added `[TestClass]` + (P1-T1, P1-T2). +- `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` — attribute-only addition (P1-T5). +- `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` — attribute-only addition (P1-T5). +- `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` — attribute-only addition (P1-T5). +- `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` — reflection-target-only change to the + class's snapshot/restore helper: the private static `FieldInfo`/`PropertyInfo` cache and the two + `GetValue(null)` call sites in `[TestInitialize]` and `[TestCleanup]` (P2-T4). + +The three attribute-only files carry that change and nothing else: no assertion, no test body, no +`using`, and no member in those files is added, removed, or reordered. That constraint is what keeps +them compatible with AC4's "unmodified assertions" wording; P1-T5 states the enforceable form of it. +`QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` is held to the same standard by P2-T4: it +retargets one reflection lookup and renames the private static field that caches it, and it changes +no assertion, no test method signature, no `[TestMethod]`, and no `using` directive. + +**The sixth path contains a space and MUST be quoted in every command that names it.** The directory +is spelled `Helper Classes`, so every `csharpier`, `git add`, `git commit`, `git status`, `git diff`, +`git grep`, and `wc -l` operand naming it in this plan is written as the double-quoted +`"QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs"`. An unquoted operand would be split into +two words by the POSIX shell every command block in this plan runs through, and `git` would then +report `QuickFiler.Test/Helper` as an unmatched pathspec — a task failure, not a silent one, but one +that is avoided by quoting rather than diagnosed. + +### Why three additional test files are in scope (re-derived this pass) + +UtilitiesCS.Test/Properties/AssemblyInfo.cs line 18 declares `[assembly: Parallelize(`, so classes +in this assembly run concurrently by default. Exactly three files in `UtilitiesCS.Test` reflect over +the process-global `UiThread._dispatcher` backing field and write it: +`UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` line 144, +`UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` line 138, and +`UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` line 422. Re-derived this pass with +`git grep -n -F '"_dispatcher"' -- UtilitiesCS.Test`, which returns exactly those three lines in +exactly those three files. None of them is among the 18 files in `UtilitiesCS.Test` that already +carry `[DoNotParallelize]`. + +Marking only the new test class `[DoNotParallelize]` would rest the new class's determinism on an +assumption about how the MSTest adapter orders its parallel and non-parallel buckets — an assumption +this plan cannot verify against the repository tree. This plan instead states the isolation guarantee +in a form that is verifiable against the tree by a single command: **after P1-T5, zero writers of +`UiThread._dispatcher` remain in the parallel bucket**, because every file in `UtilitiesCS.Test` that +names that field carries the `DoNotParallelize` attribute on its test class. That is what P1-T5 +asserts and what P0-T13 baselines. It also removes, as a side effect, the concurrent-writer hazard that the new +negative test would otherwise share with the two existing tests that install a real dispatcher and +pump a `DispatcherFrame` (`UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` lines 150, 152, +and 162). + +### Why a sixth test file is in scope (re-derived in the revision round 15 pass) + +`QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` is the only file in the whole repository +that reads `UiThread.Dispatcher` reflectively through the PUBLIC property. Re-derived in this pass by +searching every tracked `.cs` file in the worktree for the literal `"Dispatcher"`, which returns five +lines in five files: this file's line 35, and four `` XML documentation +references (`QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs` line 14, +`UtilitiesCS/Threading/WpfUiDispatcher.cs` line 14, `UtilitiesCS/Threading/ThreadMonitor.cs` line 25, +and `UtilitiesCS/Threading/IUiDispatcher.cs` line 13). Only the first is a reflection site; the other +four name the WPF type in a documentation cross-reference and invoke nothing. + +The broader safety net was re-derived in the same pass by searching every tracked `.cs` file for +`typeof(UiThread)`, which returns seven lines in seven files. Six of the seven take a FIELD: +`UtilitiesCS.Test/Threading/UiThread_Tests.cs` line 127 (added by P1-T2), +`UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` line 421, +`UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` line 138, +`UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` line 144, +`QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` line 135, and +`UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs` line 469 — and the last of those +takes `_uiSyncContext`, not `_dispatcher`, so it is unrelated to this fix. The seventh is +`QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` line 34, the single `GetProperty` call. +P0-T14 runs both searches as executable commands with the repository-wide `'*.cs'` pathspec and +records their full output, so the census is auditable rather than asserted. + +The repair is to change that one lookup to the field the other six sites already use. Reading +`_dispatcher` observes exactly the state the property getter reads, and observes it without invoking +the guard P2-T1 installed. The alternative — catching or tolerating the exception in the test's setup +— was rejected: it would encode into a test the premise that a null dispatcher is an acceptable +observed state, which is the premise this whole change exists to reverse, and every other consumer in +this fix treats that state as a real defect condition. + +### Pre-existing file-size overage recorded, not deepened + +`UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` is 514 lines at BASE, already above the +500-line limit in the rule file .claude/rules/general-code-change.md. That overage is pre-existing +and is not caused by this change. To avoid deepening it, P1-T5 adds the attribute to that one file by +extending its existing attribute list on line 14 to `[TestClass, DoNotParallelize]` rather than +adding a line. The other two files have ample headroom (347 and 205 lines) and use the repository's +prevailing two-line idiom, which is `[TestClass]` on one line and `[DoNotParallelize]` on the next +(the existing pattern in UtilitiesCS.Test/Threading/CurrentStoreContextTests.cs, lines 15-16, +re-derived this pass). + +Documentation and evidence written by this plan: + +- `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/plan.2026-09-02T09-02.md` +- `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md` +- `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/` +- `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/` +- `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/` +- `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/issue-updates/` +- `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/other/` + +Explicitly NOT written by this plan (verified this pass, see P0-T3 and P3-T4): +UtilitiesCS/Threading/ProgressTrackerAsync.cs, UtilitiesCS.Test/UtilitiesCS.Test.csproj, +UtilitiesCS/UtilitiesCS.csproj, QuickFiler.Test/QuickFiler.Test.csproj, and anything under the Claude +runtime tree at .claude/, the Codex mirror tree at .codex/, the dot-agents tree at .agents/, +config/blast-radius.json, or config/orchestration-routing.json. + +QuickFiler.Test/QuickFiler.Test.csproj needs no edit for the sixth owned file: line 206 already +carries ``, re-derived in the revision +round 15 pass. That project uses the same explicit `Compile Include` wiring +UtilitiesCS.Test/UtilitiesCS.Test.csproj uses, so an EXISTING file requires no project-file change +while a NEW one would; P2-T4 edits an existing file for that reason among others. The file is also +already tracked at BASE, which is what keeps the single-ref `git diff` form used in P2-T4 from being +blind to it. + +### Test-file placement decision (made this pass, not deferred to the executor) + +The new regression test goes into the EXISTING file `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, +as a second `[TestClass]` alongside the existing `SynchronizationContextAwaiter_Tests`. Two +re-derived facts fix this decision: + +1. The file is currently 104 lines. The addition specified in P1-T2 is approximately 75 lines, so the + post-change file is approximately 180 lines — under the 500-line limit in the rule file + .claude/rules/general-code-change.md. The alternative file + UtilitiesCS.Test/Threading/UiThread_Dispatcher_Tests.cs is therefore NOT created. +2. UtilitiesCS.Test/UtilitiesCS.Test.csproj line 494 already carries + ``. This project uses explicit `Compile Include` + wiring, so reusing the existing file requires no `.csproj` edit, whereas a new file would. The + three files P1-T5 touches are wired at the same project's lines 477 + (`Threading\ProgressTracker_Tests.cs`), 479 (`Threading\ProgressTrackerAsync_Tests.cs`), and 490 + (`Threading\IdleAsyncQueue_Tests.cs`), all re-derived this pass against the post-merge tree (each + entry moved by +1 relative to the round-1 reading; all four are still present), so no `.csproj` + edit is required + for them either and UtilitiesCS.Test/UtilitiesCS.Test.csproj stays out of this plan's diff. + +--- + +## Threshold reconciliation (recorded, applied) + +CLAUDE.md (rank 1 in `policy-compliance-order`) sets repository line coverage `>= 80%` and new +module/class/method coverage `>= 90%`. The rule files .claude/rules/general-unit-test.md and +.claude/rules/quality-tiers.md (rank 3/4) set `>= 85%` line and `>= 75%` branch. This plan applies +the rank-1 CLAUDE.md figures (`>= 80%` repository line, `>= 90%` new code) and records the +divergence in P0-T12 rather than silently choosing one. The conflict is pre-existing and is NOT +resolved by this bug fix. + +The enforced repository-level gate in this plan is **no regression relative to the P0 baseline** +(P4-T7). The absolute `>= 80%` figure is recorded, not gated, because the baseline is measured, not +assumed: a floor asserted against an unmeasured baseline could be unsatisfiable for reasons this +change did not cause. + +--- + +## Acceptance criteria mapping (source: `spec.md` "## Acceptance Criteria", AC1-AC7) + +Each row below lists exactly the same evidence-artifact set as the corresponding `AC-MAPPING:` line +in the Planner Internal Review record at the end of this file. The two lists are one derivation, not +two. + +| AC | Implementation task | Test task | Evidence task | +|---|---|---|---| +| AC1 | P2-T1 | P1-T2, P1-T4, P3-T2 | `evidence/regression-testing/p1-t4-expect-fail.md`, `evidence/regression-testing/p3-t2-regression-green.md` | +| AC2 | P2-T1 | P2-T2, P4-T4 | `evidence/qa-gates/p2-t2-nullforgiving-removed.md`, `evidence/qa-gates/p4-t4-nullable-build.md` | +| AC3 | P0-T3 (verification, no edit) | P3-T4 | `evidence/other/p3-t4-progresstrackerasync-unmodified.md` | +| AC4 | P1-T5 (attribute-only, no assertion changed), P2-T4 (reflection-target-only, no assertion changed) | P3-T3, P3-T6, P4-T6 | `evidence/qa-gates/p1-t5-donotparallelize.md`, `evidence/regression-testing/p3-t3-at-risk-tests.md`, `evidence/regression-testing/p3-t6-quickfiler-wpfuidispatcher.md`, `evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md`, `evidence/regression-testing/p4-t6-first-pass-failure.md`, `evidence/qa-gates/p4-t6-quickfiler-tests.md` | +| AC5 | P1-T2, P1-T5, P2-T1, P2-T4 | P3-T5 | `evidence/qa-gates/p3-t5-no-timing-tokens.md`, `evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md` | +| AC6 | P4-T1, P4-T3, P4-T4 | P4-T5, P4-T6 | `evidence/qa-gates/p4-t1-format.md`, `evidence/qa-gates/p4-t2-format-check.md`, `evidence/qa-gates/p4-t3-analyzer-build.md`, `evidence/qa-gates/p4-t4-nullable-build.md`, `evidence/qa-gates/p4-t5-utilitiescs-tests.md`, `evidence/qa-gates/p4-t6-quickfiler-tests.md`, `evidence/qa-gates/p4-t8-loop-closure.md` | +| AC7 | P2-T1 | P4-T5, P4-T7 | `evidence/baseline/p0-t10-utilitiescs-tests-coverage.md`, `evidence/qa-gates/p4-t7-coverage-delta.md` | + +--- + +## Exact source text this plan will create (quoted verbatim for gate exoneration) + +The `Dispatcher` property region of `UtilitiesCS/Threading/UiThread.cs` (currently lines 135-140) +becomes exactly this, subject only to `csharpier` re-wrapping: + +```csharp + public static Dispatcher Dispatcher + { + get + { + if (_dispatcher is null) + { + throw new InvalidOperationException( + "The UI dispatcher has not been captured. Call UiThread.Init() so that UiThread.Initialize() runs before reading UiThread.Dispatcher." + ); + } + return _dispatcher; + } + private set => _dispatcher = value; + } + private static Dispatcher? _dispatcher; +``` + +The literal token asserted by P2-T2 is, verbatim and on one source line: +`private static Dispatcher? _dispatcher;` + +The literal token whose disappearance P2-T2 asserts is, verbatim: `null!` + +The two MSTest node identifiers created by P1-T2 are, verbatim: + +- `UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests.Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize` +- `UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests.Dispatcher_WhenBackingFieldIsPopulated_ReturnsThatSameInstance` + +The attribute text P1-T5 creates is quoted verbatim here so that the gate reads this quotation as the +executor's instruction rather than as a search for a literal that is absent from the tree today. + +In `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`, lines 28-29 become exactly: + +```csharp + [TestClass] + [DoNotParallelize] +``` + +In `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`, lines 13-14 become exactly: + +```csharp + [TestClass] + [DoNotParallelize] +``` + +In `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs`, line 14 becomes exactly this single line, +so that the file's length does not grow past its pre-existing 514: + +```csharp + [TestClass, DoNotParallelize] +``` + +The single-line token asserted by P1-T5 against all four files is, verbatim: `DoNotParallelize`. +That bare token is used deliberately rather than the bracketed spelling, because it matches both the +two-line idiom and the combined attribute list above. + +### The reflection-target change P2-T4 creates, quoted verbatim + +`QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` lines 33-37 read exactly this today +(re-derived in the revision round 15 pass): + +```csharp + private static readonly System.Reflection.PropertyInfo DispatcherProperty = + typeof(UiThread).GetProperty( + "Dispatcher", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static + ); +``` + +They become exactly this, subject only to `csharpier` re-wrapping: + +```csharp + private static readonly System.Reflection.FieldInfo DispatcherField = + typeof(UiThread).GetField( + "_dispatcher", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static + ); +``` + +Line 49 reads exactly ` _capturedDispatcher = DispatcherProperty?.GetValue(null);` today and +becomes exactly: + +```csharp + _capturedDispatcher = DispatcherField?.GetValue(null); +``` + +Line 58 reads exactly ` object current = DispatcherProperty?.GetValue(null);` today and +becomes exactly: + +```csharp + object current = DispatcherField?.GetValue(null); +``` + +Six lines are inserted immediately before the `private object _capturedDispatcher;` declaration on +line 32, and therefore immediately after the existing class comment that ends on line 31: a +` //` spacer followed by five comment lines stating why the field is read instead of the +property. Their text is quoted verbatim here so that it is the plan, and not the executor, that fixes +it: + +```csharp + // + // The snapshot reads the private _dispatcher backing field rather than the public + // Dispatcher property (issue #584): the property getter now throws + // InvalidOperationException when the field is null, and PropertyInfo.GetValue would + // surface that as a TargetInvocationException from this class's setup and teardown. + // Reading the field observes the same state without invoking the guard. +``` + +Those six inserted lines were checked in this pass against the seven-token case-insensitive filter +AC5 uses (`Thread.Sleep`, `Task.Delay`, `SpinWait`, `Retry`, `retries`, `Timeout(`, `PushFrame`) and +contain none of them, which is the same authoring constraint P1-T2 carries for its own doc comment. + +The single-line tokens P2-T4's four `git grep` spans assert against that file are, verbatim: + +- present exactly once after the change: `"_dispatcher"` +- absent after the change: `"Dispatcher"` +- absent after the change: `GetProperty(` +- present exactly eight times, unchanged from BASE: `[TestMethod]` + +Each is a short, single-line, non-interpolated literal that this section quotes verbatim, so none of +them is wrap-fragile and none contains a placeholder. The first three are the false-before/true-after +pair for the retarget and the fourth is the no-test-added check. + +The one assertion in the region P2-T4 touches MUST survive the change unaltered. It is, verbatim: + +- ` current.Should().BeSameAs(_capturedDispatcher);` + +No other assertion in the file is inside the region P2-T4 touches. The enforceable form of "no +assertion changed" for the file as a whole is stated in P2-T4's acceptance as two clauses: the file +still declares exactly eight `[TestMethod]` attributes (the count re-derived in this pass, and the +same eight P4-T6 reported as failing), and the BASE-anchored diff of this file contains no added line +and no removed line carrying the token `.Should()`. + +--- + +## Shell constraints measured in this worktree (binding on every command block below) + +Every command block in this plan runs through the executing agent's POSIX (git-bash) shell. Five +constraints of that shell and of the tools it invokes were measured directly in this worktree by the +reviewing executor during preflight rounds 3, 4, 5, and 6, by running the commands rather than by +reading documentation. They are recorded once here and are the reason several command shapes and +several evidence-sourcing rules below are spelled the way they are. They are environment +measurements, not repository-tree citations. + +One further rule, the "TRX selection rule", is stated at the end of this section after constraint 5. +It is a plan rule rather than an environment measurement, and it is placed here because it binds on +the same seven tasks constraint 5 binds on and because stating it once centrally is what keeps it +from being present in some of those tasks and absent from others. + +1. **A command whose name is `pwsh` is refused outright, in every argument shape.** The refusal is + keyed on `pwsh` occupying the command position, not on `-Command` versus `-File`. The verbatim + refusal text is: "this command runs pwsh in a plain command; what it reads or is handed as shell + text cannot be shown not to run git. Refusing to run it." Consequence: no task in this plan may + invoke `pwsh`, and the script scripts/vscode/Install-RepoDotNetSdk.ps1 cannot be run from this + shell in any form. P0-T5 step 1 performs the same download-and-extract with POSIX utilities + instead. No `pwsh` + invocation appears anywhere in this plan. + +2. **A command whose NAME is a quoted absolute path is refused** ("runs a command whose name is + computed at runtime in a plain command ... Refusing to run it"). A path used as an ARGUMENT — after + `--`, or as the value of a parameter — is not refused. Neither `vswhere.exe` nor + `vstest.console.exe` resolves on `PATH` by bare name. Consequence: every task that runs + `vstest.console.exe` leads its command line with a `PATH=` prefix so that the command NAME is the + bare filename `vstest.console.exe`, and the two `dotnet-coverage collect` tasks keep the executable + as a double-quoted ARGUMENT after `--`, which needs no prefix. The measured proof of the prefix + form is in P0-T5 step 2. + +3. **Bare `msbuild` does not resolve on `PATH` in this shell; `msbuild.exe` does.** Measured: + `msbuild -version` returns `command not found` with exit 127, and `msbuild.exe -version` exits 0 + printing `MSBuild version 18.9.1+a81b43525 for .NET Framework`. This is a git-bash PATH-resolution + property — MSYS bash does not append `.exe` when searching `PATH` for a bare name — and not a + deviation from CLAUDE.md. Every switch set below is character-for-character the one CLAUDE.md + mandates: no `/p:Nullable=enable` is ever added, and `/t:Rebuild` is always used in place of + `/t:Build`. + +4. **MSYS path conversion rewrites forward-slash switches, and is disabled per command line with a + leading `MSYS_NO_PATHCONV=1` assignment.** This constraint supersedes an earlier round's narrower + claim that only the executable SPELLING had to change; that claim was wrong, and it was wrong in a + way that reading the commands could not reveal. Measured in this worktree during preflight round 4 + by running the commands: + + - `msbuild.exe` receives mangled switches. A single-letter `/m` is converted to `M:/`, `/t:Rebuild` + to `t:Rebuild`, and `/p:...` to `p:...`, so MSBuild sees several bare operands and fails with + `MSB1008: Only one project can be specified.` All six `msbuild.exe` command blocks in this plan + failed this way when run without the prefix. + - `vstest.console.exe` receives a mangled switch whenever a multi-letter `/Switch` carries NO + colon. `/InIsolation` is converted to the msys-root path + `C:/Program Files/Git/InIsolation`, which vstest then treats as a test source, reporting + `The test source file ... was not found` and running zero tests. All eight vstest and + `dotnet-coverage` invocations in this plan failed this way when run without the prefix. + Colon-bearing switches such as `/Logger:trx`, `/ResultsDirectory:...`, `/Settings:...`, and + `/TestCaseFilter:...` are NOT converted, which is precisely why three rounds of reading these + command lines did not surface the defect: the only affected switch in the whole set is the one + without a colon. + - `msbuild.exe -version` in P0-T5 step 3 needs no prefix and does not carry one. Its only argument + begins with `-`, not `/`, so nothing is converted. This was verified separately. + + The remedy applied throughout this plan is a single leading environment-variable assignment, + `MSYS_NO_PATHCONV=1 `, placed on the same command line immediately before the executable name. It + changes no switch. Where a command line already carries a `PATH=` prefix, `MSYS_NO_PATHCONV=1` + is placed BEFORE it; the shell accepts any number of leading assignments on one command. The + fourteen command blocks carrying the prefix are P0-T8, P0-T9, P1-T3, P3-T1, P4-T3, and P4-T4 + (`msbuild.exe`), and P0-T10, P0-T11, P1-T4, P3-T2, P3-T3, P3-T6, P4-T5, and P4-T6 (vstest and + `dotnet-coverage`). Because the prefix is part of the recorded command line, the acceptance clauses + in P0-T9 and P4-T4 assert that the recorded line CONTAINS `msbuild.exe TaskMaster.sln` rather than + that it begins with it. The substantive clauses those gates enforce — no `/p:Nullable=enable`, and + `/t:Rebuild` rather than `/t:Build` — are unaffected and unchanged. + +5. **A green `vstest.console.exe` run prints no `Failed:` line and no `Skipped:` line on its console + output.** Measured across four green runs in this worktree during preflight round 5 — of 4783, + 1312, 41, and 2 tests, with and without `/Settings:`, and both under and outside + `dotnet-coverage collect`. The entire summary block a successful run emits is: + + ```text + Test Run Successful. + Total tests: 4783 + Passed: 4783 + Total time: 12.8452 Seconds + ``` + + A search of the captured output of each of those four runs for `Failed:` or `Skipped:` returned + zero matches. EACH of those two aggregate lines is printed only when its OWN counter is non-zero, + and not merely when the run has failures: a run with a skip and no failure prints `Skipped:` and + no `Failed:`, and a run with a failure and no skip prints `Failed:` and no `Skipped:`. Preflight + round 6 confirmed that the two are independent, by running a three-test probe — one passing, one + failing, one skipped — whose console printed both `Failed: 1` and `Skipped: 1`. That per-counter + rule is why P1-T4's `[expect-fail]` acceptance can and does read `Failed: 1` from the console — + P1-T4's run has a non-zero failure count by construction — while every + green-run task in this plan cannot read a `Failed` count from there at all. Per-test pass and fail + lines are a separate matter and ARE printed on a green run, so P3-T6's requirement that two named + tests appear by name as passing in the console output is unaffected. + + Both omitted counts are recoverable from the TRX file that every affected command in this plan + already writes through its `/Logger:trx` switch. The TRX carries the run's aggregate counts in a + single element, for example: + + ```text + + ``` + + **How `Failed` and `Skipped` are sourced from that element, and why `notExecuted` is prohibited.** + `Failed` is read from the `failed` attribute of the single `` element. `Skipped` is + DERIVED from that same element as `total` minus `executed`; record `total`, `executed`, and the + derived `Skipped` value. The `notExecuted` attribute MUST NOT be used: `vstest.console.exe`'s TRX + logger populates only `total`, `executed`, `passed`, and `failed` on the `` element + and hard-codes every other attribute (`notExecuted`, `error`, `timeout`, `aborted`, + `inconclusive`, ...) to `0` regardless of the run's actual outcome. Measured in preflight round 6: + a run whose console printed `Skipped: 1` produced a TRX with `notExecuted="0"`; the derivation + `total` minus `executed` correctly returned `1` on that run and `0` on an all-passing run. In the + same file, the skipped test's own `` element was present + and correct, so it is the aggregate `` element alone that mis-reports. Sourcing + `Skipped` from `notExecuted` would therefore record a constant `0` whatever the run did — an + acceptance value that cannot fail, which is the same defect class this constraint exists to remove. + + This plan therefore sources `Total tests` and `Passed` from the console summary block and `Failed` + from the `failed` attribute in all seven TRX-reading tasks (P0-T10, P0-T11, P3-T2, P3-T3, P3-T6, + P4-T5, and P4-T6), and derives `Skipped` as `total` minus `executed` in the four of those that + record a `Skipped` count (P0-T10, P0-T11, P4-T5, and P4-T6); P3-T2, P3-T3, and P3-T6 record no + `Skipped` figure and read only `Failed`. No command line changes: the `/Logger:trx` and + `/ResultsDirectory:` switches every one of those tasks already carries are what produce the file, + and each task writes to its own results directory, so no TRX can be attributed to the wrong task. + Repeated runs of the SAME task do leave more than one `.trx` file in that task's own directory, + which is what the TRX selection rule stated below governs. The TRX files are + gitignored by .gitignore line 39 `[Tt]est[Rr]esult*/` and excluded from the format gate by + .csharpierignore line 8 `*.trx` (both re-derived this pass), so reading them adds nothing to any + porcelain, diff, or format gate in this plan. + + **Redaction rule binding on all seven of those tasks.** Record the `total`, `executed`, and + `failed` values and the derived `Skipped` value, and identify the file they came from by its + repository-relative results directory + (`TestResults/p0-t10/`, `TestResults/p0-t11/`, `TestResults/p3-t2/`, `TestResults/p3-t3/`, + `TestResults/p3-t6/`, `TestResults/p4-t5/`, `TestResults/p4-t6/`). Do NOT record the TRX file's own + name in any evidence artifact: `vstest.console.exe` composes the default TRX filename from the host + account name and the machine name, so recording it verbatim would place an account-name disclosure + into committed evidence. Do NOT quote a vstest run's `Results File:` console line in any evidence + artifact either: that line carries the full absolute host path of the same TRX, so it discloses the + account name and the machine name as well as the worktree's absolute location. This is the same + disclosure P4-T7's redaction rule prevents for the + Cobertura `filename` attribute, applied to the second artifact class this plan now reads. The four + recorded numeric values themselves carry no path. + + This constraint was invisible to preflight rounds 1 through 4. Until round 5, the `/InIsolation` + mangling recorded in constraint 4 above made every `vstest.console.exe` invocation in this plan + execute zero tests, so no round before round 5 had ever observed what a genuinely successful run's + console output contains. It is the same defect class the `atomic-plan-contract` rule "Observe a + command's success-case output before asserting over that output" exists to prevent: a value that + documentation or intuition suggests is printed, but that the tool does not print on a successful + run. + + Round 5's own first remedy fell into that same class and was corrected in round 6. That remedy + sourced `Skipped` from the TRX `notExecuted` attribute, which the tool writes but never populates, + so the recorded value would have been a constant `0` on every run. Round 6 built the three-test + probe described above and read the attribute rather than assuming it, which is what produced the + `total` minus `executed` derivation this constraint now states. + +**TRX selection rule, binding on all seven TRX-reading tasks (P0-T10, P0-T11, P3-T2, P3-T3, P3-T6, +P4-T5, and P4-T6).** If a task's results directory holds more than one `.trx` file at the moment that +task's evidence artifact is written, read the most recently modified one, and record in that task's +artifact a line beginning `TRX SELECTED: most recently modified .trx in ` and completed by that +task's own results directory — for P4-T5, exactly +`TRX SELECTED: most recently modified .trx in TestResults/p4-t5/` — together with that file's +last-modified timestamp. Do NOT record the selected file's name: the +redaction rule in constraint 5 binds on this line too, because the default TRX filename carries the +host account name and the machine name, and the last-modified timestamp identifies the selection +without disclosing either. + +This rule is stated once here rather than repeated inside individual tasks, because it applies to all +seven of them and because the plan explicitly anticipates re-running three. P3-T3's own text treats a +zero-test run as a failure of that task requiring a corrected filter and a re-run, and P4-T8's +loop-closure text restarts the Phase 4 loop from P4-T1 when a step rewrote a tracked file, which +re-runs P4-T5 and P4-T6. `vstest.console.exe` composes each default TRX filename with a timestamp and +does not overwrite an existing one — reported by the preflight round-6 reviewer, and recorded here as +a reported measurement rather than as a repository-tree citation — so a second run of the same task +leaves two `.trx` files in that task's directory. Without this rule those three tasks would have no +stated way to choose between them and the choice would fall to the executor. + +### Worktree state assumed by Phase 0 + +The worktree in which this plan was authored and reviewed already contains `.dotnet-sdk/` (SDK +8.0.205), a completed `packages/` NuGet restore, and built `Debug` output. All three are gitignored +and none of them is committed by this plan. A FRESH worktree has none of them, so Phase 0's +bootstrap and restore steps in P0-T5 are mandatory and are written to run unconditionally where they +are cheap (`nuget restore`, `dotnet tool restore`) and conditionally where they are expensive and +idempotently detectable (the SDK bootstrap, which runs only when the first `dotnet --version` probe +fails). No task in this plan may be skipped on the assumption that a prior run already performed it. + +### Phase 0 — Baseline capture, policy reads, and tree re-derivation + +Phase 0 does not assume an empty `git status --porcelain`. This worktree already carries modified or +untracked files under the agent-memory tree at .claude/agent-memory/, written by the planning and +preflight delegations that ran in this same preparation cycle. That state is expected and affects no +gate in this plan: every terminal porcelain and diff gate here is pathspec-scoped to `UtilitiesCS`, +`UtilitiesCS.Test`, and the feature folder, and the two deliberately unscoped porcelain spans in +P4-T1 are compared before-against-after rather than asserted empty, precisely so that ambient state +cannot satisfy or falsify them. This plan's own commits never touch the Claude runtime tree at +.claude/, the Codex mirror tree at .codex/, the dot-agents tree at .agents/, +config/blast-radius.json, or config/orchestration-routing.json, and P5-T10 asserts that. + +- [x] [P0-T1] Read the policy files in the order required by `policy-compliance-order`: CLAUDE.md, then .claude/rules/general-code-change.md, then .claude/rules/general-unit-test.md, then .claude/rules/quality-tiers.md, then .claude/rules/csharp.md, then .claude/rules/tonality.md. Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/phase0-instructions-read.md` containing `Timestamp:`, `Policy Order:` (the six paths in the order read), and an explicit list of the files read. Acceptance: the artifact exists, lists all six paths, and its `Policy Order:` line matches the order above. + +- [x] [P0-T2] Re-derive the defect site by reading `UtilitiesCS/Threading/UiThread.cs` in full. Record: the file's total line count; the line numbers of the `Dispatcher` property and its backing field; the verbatim backing-field declaration line; whether the file carries a nullable-enable directive on line 1; and the line numbers of the two lazy-initialising sibling properties `UiSyncContext` and `AutoScaleFactor`. Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t2-uithread-rederivation.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:`. Acceptance: the artifact records a total line count of 163, records that the backing-field line contains `null!`, records the property at lines 135-139 and the field at line 140, and records the nullable-enable directive on line 1. If any of these five recorded values differs from the value stated here, stop and report BLOCKED rather than editing, because the fix text quoted above was derived from them. + +- [x] [P0-T3] Re-derive the AC3 hypothesis by reading UtilitiesCS/Threading/ProgressTrackerAsync.cs in full. Record the verbatim text and line number of the statement that assigns `UiThread.Dispatcher` to the instance field, and the verbatim text and line number of the first statement that dereferences that instance field. Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t3-progresstrackerasync-rederivation.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:`. Acceptance: the artifact records that line 33 is `UiDispatcher = UiThread.Dispatcher;` and that line 35 is the first dereference (`await UiDispatcher.InvokeAsync(`), and states the conclusion that a throwing getter raises at line 33 before line 35 executes, so no edit to this file is required. If line 33 is not the property read, record the actual ordering, add UtilitiesCS/Threading/ProgressTrackerAsync.cs to the write-target list in the "Scope" section of this plan, and report the overturned conclusion to the caller before proceeding. + +- [x] [P0-T4] Re-derive the test-side facts by reading `UtilitiesCS.Test/Threading/UiThread_Tests.cs` in full, reading the `DispatcherField`/`ForceDispatcherNull`/`RestoreDispatcher` helper region of `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`, and reading UtilitiesCS.Test/Properties/AssemblyInfo.cs. Record: the total line count of `UtilitiesCS.Test/Threading/UiThread_Tests.cs`; its namespace; its existing `using` directives; the reflection idiom used to reach the private static backing field; and whether the assembly declares class-level parallelisation. Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t4-test-rederivation.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:`. Acceptance: the artifact records 104 total lines, namespace `UtilitiesCS.Test.Threading`, absence of a `System.Reflection` using directive, the reflection idiom taking the field by the name `_dispatcher` with non-public static binding flags, and the presence of an assembly-level parallelisation attribute on line 18 of UtilitiesCS.Test/Properties/AssemblyInfo.cs (which is the justification for the do-not-parallelize attribute in P1-T2 and P1-T5). + +- [x] [P0-T5] Resolve and record the toolchain entry points. Run these commands from the worktree root, in the order given. + + **1. Probe the .NET SDK before anything that depends on it.** + + ```text + dotnet --version + ``` + + If this fails with an error containing `The repo-local .NET SDK is missing`, the worktree has no `.dotnet-sdk/` directory, and global.json (`"paths": [".dotnet-sdk", "$host$"]`, re-derived this pass) then resolves to no SDK at all, so every `dotnet` command in this plan — `dotnet tool restore` in this task, `dotnet tool run csharpier --version` in this task, and the formatter and format-check commands in P4-T1 and P4-T2 — fails. Bootstrap the SDK by running, from the worktree root: + + ```text + mkdir -p .dotnet-sdk + curl -L -o .dotnet-sdk/sdk.zip https://builds.dotnet.microsoft.com/dotnet/Sdk/8.0.205/dotnet-sdk-8.0.205-win-x64.zip + unzip -q -o .dotnet-sdk/sdk.zip -d .dotnet-sdk + rm -f .dotnet-sdk/sdk.zip + ``` + + then re-run `dotnet --version`. Record every command actually run and every `dotnet --version` reading taken, in the order taken. + + This is deliberately NOT an invocation of `scripts/vscode/Install-RepoDotNetSdk.ps1`, and that script MUST NOT be run here. The script can only be started through `pwsh`, and a command whose name is `pwsh` is refused outright by this worktree's shell in every argument shape — `-File` and `-Command` alike, because the guard keys on `pwsh` occupying the command position. The verbatim refusal text measured in this worktree is: "this command runs pwsh in a plain command; what it reads or is handed as shell text cannot be shown not to run git. Refusing to run it." The four POSIX commands above perform the same download-and-extract the script performs: the URL is character-for-character the one the script builds at `scripts/vscode/Install-RepoDotNetSdk.ps1` line 26 for its default `$Version` of `8.0.205` and default `$Architecture` of `x64` (re-derived this pass), and `.dotnet-sdk` at the worktree root is the same destination the script resolves at line 36 (`Join-Path $PSScriptRoot '..\..\.dotnet-sdk'`, re-derived this pass). Version `8.0.205` is also what global.json pins, so the acceptance below is unchanged by the substitution. + + The bootstrap leaves nothing in any porcelain or diff gate later in this plan: .gitignore line 350 is `.dotnet*/` (re-derived this pass), which already ignores `.dotnet-sdk/`. + + Fail-closed rule for this step: if any of the four commands exits non-zero — including `curl` failing to download and including `unzip` being unavailable in this shell, which this plan has not measured — record `SDK_BOOTSTRAP: BLOCKED` in the artifact together with the failing command and its verbatim output, and halt. Do not proceed to any later task, because no formatting gate in this plan can run without an SDK and the evidence produced would be unverifiable. Exactly one fallback is authorised, and only for a failure of the `unzip` command specifically: run `PATH="/c/Windows/System32:$PATH" tar.exe -xf .dotnet-sdk/sdk.zip -C .dotnet-sdk`, record it with its own exit code, and declare `SDK_BOOTSTRAP: BLOCKED` if it too exits non-zero. That fallback uses the libarchive `tar.exe` Windows ships in `System32`, which reads zip archives; the GNU `tar` on the shell's default `PATH` does not, which is why the `PATH=` prefix is required to select the right one. No other substitution is authorised for any of the four commands. + + **2. Resolve the `vstest.console.exe` directory** by calling `vswhere.exe` through a `PATH=` prefix, so that the command NAME is the bare filename rather than a quoted absolute path: + + ```text + PATH="/c/Program Files (x86)/Microsoft Visual Studio/Installer:$PATH" vswhere.exe -latest -products '*' -find "Common7\IDE\Extensions\TestPlatform\vstest.console.exe" + ``` + + This exact command was run in this worktree during preflight round 3 and printed `C:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe`. The `PATH=`-prefix spelling is required, not stylistic: a command whose NAME is a quoted absolute path is refused by this worktree's shell with "runs a command whose name is computed at runtime in a plain command ... Refusing to run it", so the previously planned `"/c/Program Files (x86)/.../vswhere.exe" -latest ...` form cannot run at all. A path supplied as an ARGUMENT is not refused, which is why the `-find` pattern and the `dotnet-coverage` operands in P0-T10 and P4-T5 need no such treatment. + + Two quoting details are load-bearing. `-products` is quoted as `'*'` so the shell does not expand it as a filename glob against the worktree root. The `-find` pattern is double-quoted so its backslashes survive word expansion; an unquoted `Common7\IDE\...` would have its backslashes stripped and would match nothing. If the command prints more than one path, take the first. + + From the printed full path, derive the two values every later vstest task substitutes, by stripping the trailing `\vstest.console.exe`: + + - `` — the directory exactly as `vswhere.exe` printed it, backslash-spelled, for the example above `C:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform`. It is used ONLY inside the double-quoted operand after `--` in P0-T10 and P4-T5, where it is an argument handed to `dotnet-coverage` and must be a native Windows path. + - `` — the same directory in the POSIX spelling this shell uses for `PATH` entries, obtained mechanically by replacing the leading `C:` with `/c` and every `\` with `/`; for the example above `/c/Program Files/Microsoft Visual Studio/18/Community/Common7/IDE/Extensions/TestPlatform`. It is used ONLY in the `PATH=":$PATH"` prefix of P0-T11, P1-T4, P3-T2, P3-T3, P3-T6, and P4-T6. The POSIX spelling is required there because this shell splits `PATH` on `:`, so a native `C:\...` entry would split at the drive-letter colon and resolve nothing. + + Both derivations are mechanical, so a third party re-running this task obtains the same two values from the same printed line. + + **3. Confirm the MSBuild entry point.** + + ```text + msbuild.exe -version + ``` + + The `.exe` suffix is required and is not optional shorthand. Measured in this worktree during preflight round 3: `msbuild -version` returns `command not found` with exit 127, while `msbuild.exe -version` exits 0 and prints `MSBuild version 18.9.1+a81b43525 for .NET Framework`. MSYS bash does not append `.exe` when searching `PATH` for a bare name, so the bare spelling names nothing. No `vswhere`-based resolution step is added for MSBuild and none is needed: `msbuild.exe` is on `PATH`, and this plan invokes it by that name in P0-T8, P0-T9, P1-T3, P3-T1, P4-T3, and P4-T4 with the switch sets CLAUDE.md mandates, unchanged. This probe carries NO `MSYS_NO_PATHCONV=1` prefix and must not be given one: its single argument `-version` begins with `-`, not `/`, so MSYS path conversion has nothing to rewrite here. The six full `msbuild.exe` build commands do carry the prefix, for the reason given in constraint 4 of "Shell constraints measured in this worktree". No switch is added, removed, or altered in any of them. + + **4. Restore NuGet packages for the solution.** + + ```text + nuget.exe restore TaskMaster.sln + ``` + + This step is required and is not redundant with `dotnet tool restore` in step 5. `dotnet tool restore` restores only the local tool manifest, which in this repository is dotnet-tools.json at the worktree root (there is no `.config/` directory, re-derived this pass); it performs no NuGet package restore. A fresh worktree has no `packages/` directory at all, and every project in this solution is `packages.config`-based — 18 `packages.config` files exist across the solution's projects, including `UtilitiesCS/packages.config` and `UtilitiesCS.Test/packages.config`, re-derived this pass. Without this restore the FIRST build task in this plan (P0-T8) fails with 37 errors, comprising `CS0246` type-not-found errors and MSBuild `.targets`-file-not-found errors from the `packages.config` import elements; that failure was reproduced directly in this worktree during preflight round 4. + + The command name is the bare `nuget.exe`, which resolves on `PATH` in this shell and therefore needs no `PATH=` prefix. It MUST NOT be spelled as a quoted absolute path: constraint 2 in "Shell constraints measured in this worktree" records that this shell refuses any command whose NAME is a quoted absolute path. It also needs no `MSYS_NO_PATHCONV=1` prefix, because neither of its two arguments begins with `/`. + + This step is CI parity, not a local deviation: .github/workflows/_build-analyzers.yml line 45 runs `nuget restore $env:SOLUTION_PATH` with `SOLUTION_PATH: TaskMaster.sln` (line 17) immediately before its analyzer build, and .github/workflows/_build-nullable.yml line 45 and .github/workflows/_mstest-coverage.yml line 45 do the same before their respective gates. All three re-derived this pass. + + The restore writes only into `packages/` at the worktree root, which .gitignore line 191 ignores as `**/[Pp]ackages/*` (re-derived this pass), so it enters no porcelain or diff gate later in this plan. The one un-ignored path under that pattern is `!**/[Pp]ackages/build/` on line 193; no `packages/build/` directory exists in this worktree after a completed restore, re-derived this pass. If a restore in a fresh worktree does produce one, record it in this artifact and report it, because it would otherwise appear as an untracked path in P4-T1's two unscoped porcelain spans — where, being present in both, it would cancel — and it lies outside every scoped gate in Phase 5. + + **5.** `dotnet tool restore` + **6.** `dotnet tool run csharpier --version` + **7.** `dotnet-coverage --version` + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t5-toolchain-resolution.md` with `Timestamp:`, `Command:` (every command actually run, in the order run, including every `dotnet --version` attempt and every bootstrap command when bootstrap was required), `EXIT_CODE:` (per command), and `Output Summary:` recording: + + - a `SDK_BOOTSTRAP:` field, whose value is EITHER the bootstrap outcome (the first `dotnet --version` result, the fact that the four-command POSIX bootstrap was run, the resulting `.dotnet-sdk` path, and the post-bootstrap `dotnet --version` result) OR, when the first probe already succeeded and no bootstrap was performed, the literal value `NOT REQUIRED (first probe already reported a version beginning 8.0.2)`. The second form is the correct one whenever `.dotnet-sdk/` is already present — for example in a worktree where an earlier partial run bootstrapped it — because in that case no bootstrap runs and there is no post-bootstrap reading to record. Recording the literal is not a skip: the first `dotnet --version` command is still run and still recorded under `Command:` and `EXIT_CODE:`; + - a `NUGET_RESTORE:` field recording the exit code of step 4's `nuget.exe restore TaskMaster.sln` and the restore summary line it printed (for example the count of packages installed, or its statement that all packages are already installed); + - the verbatim path line `vswhere.exe` printed; + - the derived `RESOLVED_VSTEST_DIR_NATIVE:` and `RESOLVED_VSTEST_DIR:` values described in step 2; + - the reported MSBuild version, the CSharpier version, and the `dotnet-coverage` version. + + Acceptance: the last `dotnet --version` reading recorded from step 1 reports a version beginning `8.0.2` and exits 0; `nuget.exe restore TaskMaster.sln` exits 0; `RESOLVED_VSTEST_DIR_NATIVE:` and `RESOLVED_VSTEST_DIR:` are both recorded as non-empty concrete directory paths, and the file `vstest.console.exe` exists inside that directory; `msbuild.exe -version` exits 0; `dotnet tool restore` exits 0; and the reported CSharpier version is `1.2.6` (the version pinned by dotnet-tools.json). The two recorded directory values are the substitutions for the `` and `` placeholders used in P0-T10, P0-T11, P1-T4, P3-T2, P3-T3, P3-T6, P4-T5, and P4-T6; no task in this plan records or substitutes a full `vstest.console.exe` file path as a command name. If `dotnet-coverage` is absent, record `dotnet-coverage: UNAVAILABLE`, install it with `dotnet tool install --global dotnet-coverage`, re-run the version probe, and record both attempts; do not proceed to P0-T10 with an unresolved collector. + +- [x] [P0-T6] Probe MCP availability by attempting one call to `mcp__drm-copilot__validate_orchestration_artifacts` with `artifact_type: "plan"` and `artifact_path: "docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/plan.2026-09-02T09-02.md"`. Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t6-mcp-probe.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:`. Acceptance: the artifact records either the validator result or the exact string `MCP VALIDATOR UNAVAILABLE` plus the error text. This task never halts the plan: an unavailable validator is recorded and execution continues. + +- [x] [P0-T7] Capture the format baseline. Run `dotnet tool run csharpier check .` from the worktree root. Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t7-csharpier-check.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:`. The `Output Summary:` MUST enumerate, one per line under the heading `BASELINE_FORMAT_DRIFT_SET:`, every repository-relative path the command reports as unformatted, or the single line `BASELINE_FORMAT_DRIFT_SET: NONE` when it reports none. Acceptance: the artifact exists and carries a `BASELINE_FORMAT_DRIFT_SET:` block. A non-zero exit code here is a recorded baseline fact, not a failure of this task; P4-T2 and P5-T10 are both written to be satisfiable against a non-empty drift set. + +- [x] [P0-T8] Capture the analyzer baseline. Run: + + ```text + MSYS_NO_PATHCONV=1 msbuild.exe TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true + ``` + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t8-analyzer-build.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` recording the trailing `N Warning(s)` and `N Error(s)` counts printed by MSBuild. Acceptance: `EXIT_CODE: 0` and the artifact records `0 Error(s)` together with the baseline warning count (referred to below as the baseline analyzer warning count). + +- [x] [P0-T9] Capture the nullable/type-check baseline. Run: + + ```text + MSYS_NO_PATHCONV=1 msbuild.exe TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true + ``` + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t9-nullable-build.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` recording the trailing `N Error(s)` count and the quoted command line. Acceptance: `EXIT_CODE: 0`, the artifact records `0 Error(s)`, and the quoted command line contains `msbuild.exe TaskMaster.sln`, contains no `Nullable=enable` substring, and uses `/t:Rebuild` rather than `/t:Build`. The first of those three clauses is worded as `contains` rather than `begins with` because the recorded line begins with the `MSYS_NO_PATHCONV=1 ` assignment required by constraint 4 in "Shell constraints measured in this worktree"; it checks only the executable spelling this shell requires. The two substantive clauses are unchanged from CLAUDE.md and are what this gate actually enforces. + +- [x] [P0-T10] Capture the `UtilitiesCS.Test` baseline run with Cobertura coverage, using the native vstest directory resolved in P0-T5: + + ```text + MSYS_NO_PATHCONV=1 dotnet-coverage collect --output coverage/p0-t10.cobertura.xml --output-format cobertura --settings coverage.config -- "\vstest.console.exe" UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /Logger:trx /ResultsDirectory:TestResults/p0-t10 /TestCaseFilter:TestCategory!=LiveOutlook + ``` + + This task does NOT use the `PATH=`-prefix form that P0-T11, P1-T4, P3-T2, P3-T3, P3-T6, and P4-T6 use. Here `vstest.console.exe` is not the command name — `dotnet-coverage` is — and the executable is an ARGUMENT after `--`, which this worktree's shell does not refuse. It DOES carry the `MSYS_NO_PATHCONV=1` prefix, which every vstest invocation in this plan carries regardless of which of the two forms it uses: the prefix suppresses the conversion of the colon-free `/InIsolation` switch, and that switch is passed through to `vstest.console.exe` identically in both forms. Without it, `/InIsolation` arrives as `C:/Program Files/Git/InIsolation`, vstest treats it as a test source, reports `The test source file ... was not found`, and runs zero tests — a run that would produce a coverage file describing nothing and a test count of zero. See constraint 4 in "Shell constraints measured in this worktree". The operand is double-quoted so the backslashes in `` survive word expansion and `dotnet-coverage` receives a valid native Windows path; the native spelling rather than the POSIX one is used for that same reason. P4-T5 uses this identical form, which is what keeps the two runs command-identical apart from their `--output` and `/ResultsDirectory` values, as P4-T7's comparison requires. + + Every other path in this command is written with forward slashes deliberately. Every command block in this plan is executed through a POSIX shell, which removes an unquoted backslash inside a word; a backslash-spelled `coverage\p0-t10.cobertura.xml` would therefore be created as `coveragep0-t10.cobertura.xml` at the worktree root, where .gitignore's `coverage/*` rule (line 144, re-derived this pass) does not match it and where P4-T7 could not read it. `msbuild.exe`, `vstest.console.exe`, and `dotnet-coverage` all accept forward-slash paths on Windows. Backslashes survive in exactly three places in this plan, and all three are inside double quotes, which is what preserves them: the `-find` pattern in P0-T5 step 2, and the `"\vstest.console.exe"` operands in this task and in P4-T5. + + This command's flag set is deliberately identical to P4-T5's. The two differ only in the `--output` filename and the `/ResultsDirectory` value. In particular neither passes `/EnableCodeCoverage`, because activating the vstest in-process collector underneath `dotnet-coverage collect` changes the loaded-module set and therefore the `lines-valid` denominator, which would make the P4-T7 comparison between these two runs not apples-to-apples. + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t10-utilitiescs-tests-coverage.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:`. The `Output Summary:` MUST record the numeric `Total tests`, `Passed`, `Failed`, and `Skipped` counts for this run, each drawn from the source named below, together with the TRX `total` and `executed` values from which the `Skipped` figure is derived, and, read from the root `` element of `coverage/p0-t10.cobertura.xml`, the numeric `lines-covered`, `lines-valid`, and `line-rate` attribute values (referred to below as the baseline coverage figures). + + **Where each of the four test counts is read from.** `Total tests` and `Passed` are read from the console summary block this command prints. `Failed` is read from the `failed` attribute of the single `` element in the TRX file this task's own `/Logger:trx` switch writes under `TestResults/p0-t10/`. `Skipped` is DERIVED from that same element as `total` minus `executed`; record `total`, `executed`, and the derived `Skipped` value. The `notExecuted` attribute MUST NOT be used: `vstest.console.exe`'s TRX logger populates only `total`, `executed`, `passed`, and `failed` on the `` element and hard-codes every other attribute (`notExecuted`, `error`, `timeout`, `aborted`, `inconclusive`, ...) to `0` regardless of the run's actual outcome. Measured in preflight round 6: a run whose console printed `Skipped: 1` produced a TRX with `notExecuted="0"`; the derivation `total` minus `executed` correctly returned `1` on that run and `0` on an all-passing run. The console/TRX split is required, not stylistic. Measured across four green runs in this worktree during preflight round 5, a successful `vstest.console.exe` run prints no `Failed:` line and no `Skipped:` line at all; the entire summary block it emits is: + + ```text + Test Run Successful. + Total tests: 4783 + Passed: 4783 + Total time: 12.8452 Seconds + ``` + + The TRX carries the run's aggregate counts in one element, for example: + + ```text + + ``` + + where `failed` supplies `Failed` and `total` minus `executed` supplies `Skipped`. See constraint 5 in "Shell constraints measured in this worktree", which states this rule once centrally and binds it on all seven TRX-reading tasks. `TestResults/p0-t10/` is written by this task and by no other task in this plan, so the file is located without any choice being left to the executor; if this task has been run more than once, the "TRX selection rule" stated immediately after constraint 5 governs which `.trx` in that directory is read and what is recorded about the selection. + + **Redaction rule for the TRX reference.** Identify the TRX in the artifact by its repository-relative results directory `TestResults/p0-t10/`, and do NOT record the file's own name, and do NOT quote the run's `Results File:` console line. `vstest.console.exe` composes the default TRX filename from the host account name and the machine name, and the `Results File:` line prints that filename inside a full absolute host path, so recording either verbatim would place an account-name disclosure into committed evidence — the same disclosure P4-T7's redaction rule prevents for the Cobertura `filename` attribute. Record the `total`, `executed`, and `failed` values and the derived `Skipped` value themselves, which carry no path. + + Acceptance: the artifact records all four test counts and all three numeric coverage attribute values as concrete numbers, not placeholders; it records the `total` and `executed` values from which the `Skipped` figure was derived; and it identifies `TestResults/p0-t10/` as the results directory `Failed` and `Skipped` were read from without recording a TRX filename and without quoting a `Results File:` line. If `Failed` is non-zero, record the failing test names as `BASELINE_FAILURE_SET:` and treat that set, not zero, as the comparison target in P3-T3 and P4-T5; a pre-existing failure is a recorded baseline fact, not a blocker for this plan. + +- [x] [P0-T11] Capture the `QuickFiler.Test` baseline run. Command: + + ```text + MSYS_NO_PATHCONV=1 PATH=":$PATH" vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /InIsolation /Logger:trx /ResultsDirectory:TestResults/p0-t11 /TestCaseFilter:TestCategory!=LiveOutlook + ``` + + The `PATH=`-prefix form is used by every task in this plan that runs `vstest.console.exe` as the command NAME (P0-T11, P1-T4, P3-T2, P3-T3, P3-T6, P4-T6). It is required because this worktree's shell refuses a command whose name is a quoted absolute path, and `vstest.console.exe` does not resolve on the default `PATH` by bare name; see constraint 2 in "Shell constraints measured in this worktree". `` is the POSIX-spelled directory recorded in P0-T5 step 2. + + The `MSYS_NO_PATHCONV=1` assignment ahead of the `PATH=` assignment is separately required and is not decoration. Without it, MSYS path conversion rewrites the colon-free `/InIsolation` switch into `C:/Program Files/Git/InIsolation`; vstest then treats that as a test source, prints `The test source file ... was not found`, and runs zero tests while the colon-bearing switches on the same line pass through untouched. Ordering the two assignments the other way round works identically — the shell accepts any number of leading assignments — but the order shown here is used uniformly across all six tasks so the recorded command lines are comparable. See constraint 4 in "Shell constraints measured in this worktree". + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t11-quickfiler-tests.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` recording the numeric `Total tests`, `Passed`, `Failed`, and `Skipped` counts, the TRX `total` and `executed` values from which the `Skipped` figure is derived, plus a `BASELINE_FAILURE_SET:` list when `Failed` is non-zero. + + The same sourcing rule P0-T10 states applies here unchanged. `Total tests` and `Passed` are read from the console summary block; `Failed` is read from the `failed` attribute of the single `` element in the TRX file this task's own `/Logger:trx` switch writes under `TestResults/p0-t11/`; and `Skipped` is DERIVED from that same element as `total` minus `executed`, with `total`, `executed`, and the derived `Skipped` value all recorded. The `notExecuted` attribute MUST NOT be used: `vstest.console.exe`'s TRX logger populates only `total`, `executed`, `passed`, and `failed` on the `` element and hard-codes every other attribute (`notExecuted`, `error`, `timeout`, `aborted`, `inconclusive`, ...) to `0` regardless of the run's actual outcome. Measured in preflight round 6: a run whose console printed `Skipped: 1` produced a TRX with `notExecuted="0"`; the derivation `total` minus `executed` correctly returned `1` on that run and `0` on an all-passing run. The counts come from the TRX rather than the console because a green `vstest.console.exe` run prints no `Failed:` line and no `Skipped:` line at all (constraint 5 in "Shell constraints measured in this worktree", measured across four green runs in preflight round 5). Only the SOURCE of the `Failed` and `Skipped` values changes; the `BASELINE_FAILURE_SET:` mechanism keyed off a non-zero `Failed` count is unchanged, and the failing test names it lists are still taken from the run's per-test output. `TestResults/p0-t11/` is written by this task and by no other task in this plan; if this task has been run more than once, the "TRX selection rule" stated immediately after constraint 5 governs which `.trx` in that directory is read and what is recorded about the selection. The TRX reference is subject to the same redaction rule P0-T10 states: identify the file by its repository-relative results directory only, never by its own name and never by quoting the run's `Results File:` console line, because `vstest.console.exe` composes the default TRX filename from the host account name and the machine name and prints it inside a full absolute host path. + + Acceptance: all four counts are recorded as concrete numbers, the `total` and `executed` values from which `Skipped` was derived are recorded, and the artifact identifies `TestResults/p0-t11/` as the results directory `Failed` and `Skipped` were read from without recording a TRX filename and without quoting a `Results File:` line. This assembly is baselined because the sibling audit in P3-T6 found that QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs constructs the parameterless `WpfUiDispatcher`, whose provider closes over `UiThread.Dispatcher`. + +- [x] [P0-T12] Record the coverage-threshold reconciliation. Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t12-threshold-reconciliation.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` naming CLAUDE.md as the rank-1 authority supplying `>= 80%` repository line coverage and `>= 90%` new-code coverage, naming .claude/rules/general-unit-test.md and .claude/rules/quality-tiers.md as the rank-3/rank-4 sources supplying `>= 85%` line and `>= 75%` branch, stating that the rank-1 figures are the ones this plan enforces, and quoting the baseline `line-rate` recorded in P0-T10. Acceptance: the artifact names CLAUDE.md explicitly as the superseding authority and quotes the concrete baseline `line-rate` value. + +- [x] [P0-T13] Baseline the parallel-bucket census and the file sizes of the five files this plan writes. Run: + + ```text + git grep -n -F '"_dispatcher"' -- UtilitiesCS.Test + git grep -c -F DoNotParallelize -- UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs + git grep -n -F "[TestClass" -- UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs + wc -l UtilitiesCS/Threading/UiThread.cs UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs + ``` + + The counting idiom for every line-count assertion in this plan is `wc -l`, the physical newline count, used identically in P0-T13, P2-T3, and P4-T8. A before/after line-count comparison taken across two different counting idioms is incommensurable, so the idiom is fixed here rather than left to the executor. `Measure-Object -Line` MUST NOT be substituted for it: that cmdlet does not count blank lines and under-reports every file in this set by 17 to 92 lines, confirmed against this tree in this pass (`UiThread_Tests.cs` has 17 blank lines out of 104 and `ProgressTracker_Tests.cs` has 92 out of 514, so the substitution would report 87 and 422 in place of 104 and 514, tripping this task's own BLOCKED clause below and contradicting the pre-existing-overage narrative this plan records for `ProgressTracker_Tests.cs`). + + `wc -l` given more than one file prints one row per file and then a trailing `total` row. The acceptance below reads the five named per-file rows; it does not assert anything about the whole command output as a single value, and the trailing `total` row is expected and is not a defect. + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t13-parallel-bucket-census.md` with `Timestamp:`, `Command:` (all four), `EXIT_CODE:` (per command), and `Output Summary:` quoting each command's output verbatim under the headings `BASELINE_DISPATCHER_WRITERS:`, `BASELINE_DONOTPARALLELIZE_COUNTS:`, `BASELINE_TESTCLASS_LINES:`, and `BASELINE_LINE_COUNTS:`. Acceptance, each value re-derived by the planner against the working tree in this pass: + + 1. The first command prints exactly three lines, one each in `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` at line 144, `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` at line 422, and `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` at line 138. + 2. The second command prints nothing and exits 1, because `git grep` exits 1 on zero matches. This is the false-before half of P1-T5's gate: none of the four files carries the attribute at BASE. + 3. The third command prints exactly four lines, at `UtilitiesCS.Test/Threading/UiThread_Tests.cs:8`, `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs:28`, `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs:13`, and `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs:14`. + 4. The fourth command's five per-file rows report 163 for `UtilitiesCS/Threading/UiThread.cs`, 104 for `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, 347 for `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`, 205 for `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`, and 514 for `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs`. These five per-file rows are the recorded baseline counts referred to by P2-T3 and P4-T8; the trailing `total` row is ignored. + + If any of these values differs from the value stated here, stop and report BLOCKED rather than editing, because P1-T5's edit sites and P2-T3's size accounting were derived from them. The artifact MUST additionally carry the line `PRE-EXISTING FILE-SIZE OVERAGE: UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs 514` together with a statement that the overage exists at BASE `87cb4df338322844abfa580abea14df77e738e5c` and is not introduced by this change. + +- [x] [P0-T14] Census the reflective reads of the `Dispatcher` PROPERTY and of the `UiThread` type across every test assembly and, through the two repository-wide commands below, across every `.cs` file in the repository, closing the census gap that produced the P4-T6 regression. This task is the property-name counterpart of P0-T13's field-name census, and it exists because P0-T13's `git grep -F '"_dispatcher"'` cannot match a reflective read spelled `GetProperty("Dispatcher", ...)`, and `spec.md`'s `git grep -n "UiThread.Dispatcher\b"` cannot match it either: neither search's operand appears in the file at all. Run, from the worktree root: + + ```text + git grep -n -F '"Dispatcher"' -- QuickFiler.Test SVGControl.Test Tags.Test TaskMaster.Test TaskTree.Test TaskVisualization.Test ToDoModel.Test UtilitiesCS.Test VBFunctions.Test + git grep -n -F 'typeof(UiThread)' -- '*.cs' + git grep -n -F '"Dispatcher"' -- '*.cs' + ``` + + The nine pathspecs of the FIRST command are every test assembly in this repository, re-derived in the revision round 15 pass by enumerating the `*.Test.csproj` files at the worktree root: `QuickFiler.Test`, `SVGControl.Test`, `Tags.Test`, `TaskMaster.Test`, `TaskTree.Test`, `TaskVisualization.Test`, `ToDoModel.Test`, `UtilitiesCS.Test`, and `VBFunctions.Test`. The second and third commands are repository-wide over `.cs` files and are what cover production code; their `'*.cs'` pathspecs are load-bearing and are not decoration. A bare repository-wide `git grep -F '"Dispatcher"'` with no pathspec also matches this plan file and `spec.md`, both of which quote that literal in their own prose, so its acceptance would be asserting against text this plan itself writes. Limiting the second and third commands to `.cs` files removes that self-match while keeping full repository coverage. + + The second command's pathspec was widened from the nine-test-assembly list to `'*.cs'` in the revision round 16 pass, and the reason is stated here rather than left implicit. Clause 3 below draws a repository-wide conclusion about PRODUCTION code, and it rests on commands 2 and 3 together: command 3 enumerates every occurrence of the reflective name operand `"Dispatcher"` and command 2 enumerates every occurrence of the reflection entry point `typeof(UiThread)`. While command 2 was scoped to the nine test assemblies, that conclusion was broader than the census's own output supported, which is a defect in an auditable gate independently of whether the conclusion happened to be true. Widening costs nothing here: the widened command returns the same seven files the nine-assembly list returned, so clause 2's asserted count is unchanged at seven. + + No command carries an `MSYS_NO_PATHCONV=1` prefix and none needs one: no argument of any of the three begins with `/`, so MSYS path conversion has nothing to rewrite. The single-quoting of each `-F` operand and of each `'*.cs'` pathspec is load-bearing, because each contains characters the shell would otherwise consume — the double quotes in the first and third, the parentheses in the second, and the `'*.cs'` glob in the second and third, which must reach `git` unexpanded rather than being expanded against the worktree root. + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t14-reflective-dispatcher-census.md` with `Timestamp:`, `Command:` (all three), `EXIT_CODE:` (per command), and `Output Summary:` quoting all three commands' complete output verbatim under the headings `REFLECTIVE_PROPERTY_NAME_HITS:`, `REFLECTIVE_UITHREAD_TYPE_HITS:`, and `REPOSITORY_WIDE_PROPERTY_NAME_HITS:`, and classifying every hit, one line per hit: as `REFLECTION` or `DOC-COMMENT` for the first and third commands, and as `FIELD:` or `PROPERTY:` for the second. Quoting the full output, rather than a count, is what lets a later reviewer see what the census returned instead of taking the conclusion on trust; that is the specific failure this task exists to prevent recurring. + + Acceptance, each value re-derived by the planner against the working tree in the revision round 15 pass, with clauses 2 and 3 re-derived again in the revision round 16 pass after the second command's pathspec was widened: + + 1. The first command prints exactly two lines, in exactly two files: one in `QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs`, classified `DOC-COMMENT` because it is the XML `` reference in that file's class summary and invokes nothing; and one in `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs`, classified `REFLECTION` because it is the name operand of a `GetProperty` call. The FILE SET is the assertion. A line number that differs from the round-15 readings of 14 and 35 for the same file and the same construct is not a failure of this task and is recorded as observed; a file in the printed set that is not one of those two, or either of those two missing, IS a failure and must be reported to the caller before P2-T4 runs, because it would mean a second reflective consumer exists that this revision does not repair. + 2. The second command prints exactly seven lines, in exactly seven files: `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` (classified `PROPERTY:Dispatcher`), `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` (`FIELD:_dispatcher`), `UtilitiesCS.Test/Threading/UiThread_Tests.cs` (`FIELD:_dispatcher`), `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` (`FIELD:_dispatcher`), `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` (`FIELD:_dispatcher`), `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` (`FIELD:_dispatcher`), and `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs` (`FIELD:_uiSyncContext`). Exactly one of the seven is classified `PROPERTY:`, and it is the file P2-T4 repairs. `UtilitiesCS.Test/Threading/UiThread_Tests.cs` appears in this set because P1-T2 added the `DispatcherField()` helper to it; it is not present at BASE, and this clause describes the tree as it stands when this task runs, not the tree at BASE. `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs` is in the set but is unrelated to this fix: it reflects over `_uiSyncContext`, a different static, and is recorded so that a reviewer can see it was examined rather than overlooked. The count of seven and the seven-file set are unchanged by the revision round 16 widening of this command's pathspec to `'*.cs'`. That count was re-derived in the round 16 pass by searching every tracked `.cs` file in the worktree for the literal `typeof(UiThread)`: the result is the same seven files, because no production `.cs` file in this repository contains that literal at all, so every hit the widened command returns falls inside one of the nine test assemblies the previous pathspec named. + 3. The third command prints exactly five lines, in exactly five files: the two files clause 1 names, plus `UtilitiesCS/Threading/WpfUiDispatcher.cs`, `UtilitiesCS/Threading/ThreadMonitor.cs`, and `UtilitiesCS/Threading/IUiDispatcher.cs`. All three of the added files are classified `DOC-COMMENT`: each is an XML `` cross-reference to the WPF type and none invokes anything. The conclusion this clause establishes, and which the artifact must state explicitly, is that no PRODUCTION file in this repository reads `UiThread.Dispatcher` reflectively, so the blast radius of P2-T1's guard through the reflective route is confined to the single test file P2-T4 repairs. That conclusion rests on commands 2 and 3 TOGETHER, and after the round 16 widening both are repository-wide over `.cs` files, so the census's own output covers every file the conclusion speaks about: command 3 shows that the only `.cs` occurrences of the reflective name operand `"Dispatcher"` outside the one repaired test file are four documentation cross-references, and command 2 shows that the reflection entry point `typeof(UiThread)` occurs in no production file. The artifact MUST cite both commands' recorded output when it states this conclusion, rather than stating it under command 3 alone. + + If clause 1, clause 2, or clause 3 reports a file set other than the one stated, stop and report BLOCKED rather than proceeding, because P2-T4 repairs exactly one file and an unlisted reflective consumer would leave a second regression in place. + +### Phase 1 — Deterministic regression test, red before the fix + +- [x] [P1-T1] In `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, add `using System.Reflection;` to the existing using block, preserving the existing directives and their order (`System`, `System.Reflection`, `System.Threading`, `FluentAssertions`, `Microsoft.VisualStudio.TestTools.UnitTesting`). Do not add `using System.Windows.Threading;`; the new test refers to the WPF dispatcher type by its fully-qualified name so no new type name enters this file's lookup scope. Acceptance: reading the file shows exactly one added using directive and five using directives total, in the order listed above, with no existing directive removed or reordered. No `git diff` is asserted at this point; the diff-based gates in this plan all use the single-ref working-tree form `git diff 87cb4df338322844abfa580abea14df77e738e5c -- ` and are stated in P3-T5 and P4-T7. + +- [x] [P1-T2] In the same file `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, append a second test class inside the existing `UtilitiesCS.Test.Threading` namespace, after the closing brace of `SynchronizationContextAwaiter_Tests`. The class is named `UiThread_Dispatcher_Tests`, carries `[TestClass]` on one line and `[DoNotParallelize]` on the next (justified by the assembly-level parallelisation attribute recorded in P0-T4 and by the fact that both tests mutate the process-global static `UiThread._dispatcher`), carries an XML doc comment containing the literal token `#584` and stating why reflection is used, and contains a private static helper returning the `FieldInfo` for `_dispatcher` plus exactly these two `[TestMethod]`s. The XML doc comment MUST NOT contain the token `DoNotParallelize`, because P1-T5 asserts an exact occurrence count of 1 for that token in this file. The XML doc comment MUST ALSO NOT contain any of the seven tokens `Thread.Sleep`, `Task.Delay`, `SpinWait`, `Retry`, `retries`, `Timeout(`, or `PushFrame`, in any letter case. P3-T5 searches the added lines of this change case-insensitively for exactly those seven tokens, and it reads added lines without distinguishing code from comment, so a doc comment explaining that the test needs no retry or sleep would trip AC5's gate on its own compliant documentation. State the rationale without those words — for example, by saying the test drives the accessor contract directly through the private backing field and is therefore deterministic without any timing construct. + + ```csharp + [TestMethod] + public void Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize() + { + // Arrange + var field = DispatcherField(); + field.Should().NotBeNull(); + var prior = field.GetValue(null); + field.SetValue(null, null); + try + { + // Act + Action act = () => + { + _ = UiThread.Dispatcher; + }; + + // Assert + act.Should() + .Throw() + .WithMessage("*UiThread.Initialize()*"); + } + finally + { + field.SetValue(null, prior); + } + } + + [TestMethod] + public void Dispatcher_WhenBackingFieldIsPopulated_ReturnsThatSameInstance() + { + // Arrange + var field = DispatcherField(); + var prior = field.GetValue(null); + var expected = System.Windows.Threading.Dispatcher.CurrentDispatcher; + field.SetValue(null, expected); + try + { + // Act / Assert + UiThread.Dispatcher.Should().BeSameAs(expected); + } + finally + { + field.SetValue(null, prior); + } + } + ``` + + The helper is: + + ```csharp + private static FieldInfo DispatcherField() + { + return typeof(UiThread).GetField( + "_dispatcher", + BindingFlags.NonPublic | BindingFlags.Static + ); + } + ``` + + Acceptance: the file contains exactly two `[TestClass]` attributes; the new class contains exactly two `[TestMethod]` attributes; the new code contains no occurrence of `Thread.Sleep`, `Task.Delay`, `Thread.CurrentThread.Join`, `SpinWait`, or `Dispatcher.PushFrame`; both tests restore the captured prior field value in a `finally` block; the file's total line count is under 500; and `git grep -c -F "#584" -- UtilitiesCS.Test/Threading/UiThread_Tests.cs` prints a count of 1 or more, which is the enforceable form of the XML-doc-comment requirement stated above. + +- [x] [P1-T3] Build the solution so the new test compiles against the UNFIXED production code. Run: + + ```text + MSYS_NO_PATHCONV=1 msbuild.exe TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true + ``` + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p1-t3-build-before-fix.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` recording the trailing `N Warning(s)` and `N Error(s)` counts. Acceptance: `EXIT_CODE: 0` and `0 Error(s)`. This confirms the regression test's red state in P1-T4 is a runtime assertion failure and not a compile failure, which is the property that makes it a genuine fail-before. + +- [x] [P1-T4] [expect-fail] Run the two new tests against the unfixed production code, using the POSIX vstest directory resolved in P0-T5: + + ```text + MSYS_NO_PATHCONV=1 PATH=":$PATH" vstest.console.exe UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll /InIsolation /Logger:trx /ResultsDirectory:TestResults/p1-t4 /TestCaseFilter:"FullyQualifiedName=UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests.Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize|FullyQualifiedName=UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests.Dispatcher_WhenBackingFieldIsPopulated_ReturnsThatSameInstance" + ``` + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p1-t4-expect-fail.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `ExpectedExitCode: 1`, and `Output Summary:` recording `Total tests`, `Passed`, `Failed`, the name of the failing test, and the verbatim FluentAssertions failure message. Acceptance: the run reports `Total tests: 2`, `Passed: 1`, `Failed: 1`; the single failure is `Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize`; and the recorded failure message states that no exception was thrown. The positive test passing here is expected and required: it proves the reflection arrangement and the restore path work before the production change, so the red in the negative test is attributable to the defect and not to the test harness. + +- [x] [P1-T5] Move every remaining writer of `UiThread._dispatcher` out of the parallel bucket, by an attribute-only edit to three existing test files. Make exactly these three edits and nothing else in these files — no `using`, no assertion, no test body, no member added, removed, or reordered: + + 1. `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`: insert ` [DoNotParallelize]` immediately after the `[TestClass]` on line 28, giving the two-line form quoted verbatim in the "Exact source text this plan will create" section. + 2. `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`: insert ` [DoNotParallelize]` immediately after the `[TestClass]` on line 13, same two-line form. + 3. `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs`: replace line 14's ` [TestClass]` with the single line ` [TestClass, DoNotParallelize]`. The combined attribute list is used for this one file only, because the file is 514 lines at BASE and already exceeds the 500-line limit in .claude/rules/general-code-change.md; the combined form adds the attribute without adding a line, so this change does not deepen a pre-existing overage. + + Then run: + + ```text + git grep -l -F '"_dispatcher"' -- UtilitiesCS.Test + git grep -c -F DoNotParallelize -- UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs + ``` + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p1-t5-donotparallelize.md` with `Timestamp:`, `Command:` (both), `EXIT_CODE:` (per command), and `Output Summary:` quoting both outputs verbatim. Acceptance: + + - The first command prints exactly these four paths and no other: `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`, `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs`, `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`, `UtilitiesCS.Test/Threading/UiThread_Tests.cs`. The fourth is present because P1-T2 added the new class's `DispatcherField()` helper to it. + - The second command prints exactly four lines, one per path, each reporting a count of exactly `1`. This is the enforceable form of the isolation guarantee: every file in `UtilitiesCS.Test` that names `UiThread._dispatcher` carries the do-not-parallelize attribute, so zero writers of that field remain in the parallel bucket. P0-T13 recorded the false-before state for this same command (zero matches, exit 1), so the gate is false before this task and true after it. + + `DoNotParallelize` resolves in all three files without a new `using`: each already imports `Microsoft.VisualStudio.TestTools.UnitTesting` at `IdleAsyncQueue_Tests.cs:6`, `ProgressTrackerAsync_Tests.cs:7`, and `ProgressTracker_Tests.cs:8`, re-derived this pass. This task is placed after P1-T4 deliberately: P1-T3's build and P1-T4's expect-fail run are scoped to the two new tests by an explicit `/TestCaseFilter`, so neither depends on these three files, and the first build that compiles these edits is P3-T1. + + This task changes no assertion in any of the three files, which is what keeps it compatible with AC4's "all pass, unmodified assertions" wording. Moving a class from the parallel bucket to the serial bucket can only reduce the concurrency those tests experience, so it cannot introduce a race; the possibility that it exposes a latent ordering dependency is not asserted away here but is verified empirically by P3-T3 and P4-T5. + +### Phase 2 — Minimal production fix and its one reflective consumer + +- [x] [P2-T1] In `UtilitiesCS/Threading/UiThread.cs`, replace the `Dispatcher` property and its backing field (lines 135-140 as re-derived in P0-T2) with the text quoted verbatim in the "Exact source text this plan will create" section above: an expression-free `get` accessor that throws `new InvalidOperationException("The UI dispatcher has not been captured. Call UiThread.Init() so that UiThread.Initialize() runs before reading UiThread.Dispatcher.")` when `_dispatcher is null` and otherwise returns `_dispatcher`; the `private set => _dispatcher = value;` accessor unchanged; and the backing field redeclared as `private static Dispatcher? _dispatcher;` with the `null!` initialiser and its trailing comment removed. Change nothing else in this file. Acceptance: the property's declared return type is still the non-nullable `Dispatcher`; the file contains exactly one `throw new InvalidOperationException(`; the file's total line count is 172 or fewer; and no other member of the file is modified. + +- [x] [P2-T2] Verify the null-forgiving suppression is gone and the nullable field declaration is present. Run: + + ```text + git grep -c -F "null!" -- UtilitiesCS/Threading/UiThread.cs + git grep -n -F "private static Dispatcher? _dispatcher;" -- UtilitiesCS/Threading/UiThread.cs + ``` + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p2-t2-nullforgiving-removed.md` with `Timestamp:`, `Command:` (both), `EXIT_CODE:` (per command), `Output Summary:` quoting each command's output. Acceptance: the first command prints no matching line and exits 1 (`git grep` exits 1 on zero matches), and the second command prints exactly one line whose path is `UtilitiesCS/Threading/UiThread.cs`. Both commands are scoped by pathspec to this one file, so neither is affected by `null!` occurrences elsewhere in the repository. The pre-change state of the first command was exactly one match on line 140, recorded in P0-T2, so this gate is false before the edit and true after it. + +- [x] [P2-T3] Account for the file-size limit in .claude/rules/general-code-change.md across all five files this plan writes. Run: + + ```text + wc -l UtilitiesCS/Threading/UiThread.cs UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs + ``` + + This is character-for-character the command P0-T13 ran, for the reason stated there: the before and after counts must come from one counting idiom or the comparison is incommensurable. Read the five named per-file rows and ignore the trailing `total` row. + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p2-t3-file-size.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, and `Output Summary:` quoting all five reported per-file line counts alongside the corresponding baseline counts recorded in P0-T13. Acceptance: + + 1. The counts for `UtilitiesCS/Threading/UiThread.cs`, `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`, and `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` are each strictly less than 500. + 2. `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` is exempt from clause 1 because it is 514 lines at BASE, above the limit before this plan touches it. Its acceptance is instead that its post-change count is less than or equal to its P0-T13 baseline count plus 1. The plan's intent is a count unchanged at 514, achieved by the combined attribute list in P1-T5; the plus-one tolerance exists solely because a later `csharpier format .` pass may split that attribute list onto two lines, which is a formatter decision this plan does not control. The artifact MUST carry the line `PRE-EXISTING FILE-SIZE OVERAGE: UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` and state that the overage exists at BASE and is not introduced by this change. If the post-change count exceeds baseline plus 1, that is a real regression in this file and the task fails. + + This task predates the revision round 15 scope widening and audits the five files owned at that time. The sixth owned file's size is audited by P2-T4 for the pre-format tree and by P4-T8 for the post-format tree; no count in this task changes. + +- [x] [P2-T4] Retarget the reflective dispatcher snapshot in `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` from the public `Dispatcher` property to the private `_dispatcher` backing field, so this class's `[TestInitialize]` and `[TestCleanup]` observe the same state without invoking the guard P2-T1 installed. Make exactly the four edits quoted verbatim in "The reflection-target change P2-T4 creates, quoted verbatim" above and nothing else in this file: the private static cache declaration (lines 33-37 today), the `GetValue` call in `Setup()` (line 49 today), the `GetValue` call in `Cleanup()` (line 58 today), and the six inserted comment lines before `private object _capturedDispatcher;` (line 32 today). Do NOT add a `using` directive: the file reaches `System.Reflection.FieldInfo` and `System.Reflection.BindingFlags` by their fully-qualified names exactly as it reaches `System.Reflection.PropertyInfo` today, and adding a directive would be a change beyond the reflection target. Do NOT add, remove, rename, or reorder any `[TestMethod]`, and do NOT alter any assertion, any mock setup, or the `[TestClass]`/`[DoNotParallelize]` attribute pair the file already carries on lines 21-22. + + The idiom this task adopts is the one already established for the same static in four other files: `typeof(UiThread).GetField("_dispatcher", BindingFlags.NonPublic | BindingFlags.Static)`, used by `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`'s `DispatcherField()` / `ForceDispatcherNull()` / `RestoreDispatcher()` helpers at lines 142-187, by the helper P1-T2 added to `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, by `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs`, and by `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`. This file spells the binding flags fully qualified because it declares no `using System.Reflection;`, which is the only difference from those four sites. + + The behavioural equivalence that makes this a narrow change rather than a weakened test is worth stating, because it is the reason no assertion needs to move. `Cleanup()` asserts `current.Should().BeSameAs(_capturedDispatcher)`. Before P2-T1 the property getter returned the field's value unconditionally, so the property read and the field read were the same observation; after P2-T1 they differ only in that the property read additionally throws when the value is null. The field read therefore preserves the assertion's meaning exactly — the static was not mutated across the test — and removes only the newly added failure mode, which is a diagnostic for production callers and not a state this bookkeeping test asserts about. + + Then run, from the worktree root: + + ```text + git grep -c -F '"_dispatcher"' -- "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" + git grep -c -F '"Dispatcher"' -- "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" + git grep -c -F 'GetProperty(' -- "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" + git grep -c -F '[TestMethod]' -- "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" + wc -l "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" + mkdir -p TestResults + git diff 87cb4df338322844abfa580abea14df77e738e5c -- "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" > TestResults/p2-t4-emailmovemonitor.diff + grep -E -i 'Thread\.Sleep|Task\.Delay|SpinWait|Retry|retries|Timeout\(|PushFrame' TestResults/p2-t4-emailmovemonitor.diff + grep -E '^[-+]' TestResults/p2-t4-emailmovemonitor.diff | grep -F '.Should()' + ``` + + Every operand naming the sixth owned file is double-quoted because the path contains a space; see the note in the "Scope" section. The `git diff` span uses the single-ref working-tree form anchored to BASE, for the same reason P3-T5 and P4-T7 do: this plan's first commit is P5-T9, so a two-dot `87cb4df338322844abfa580abea14df77e738e5c..HEAD` span would compare BASE against a HEAD that is identical to it across this pathspec and print nothing whatever the executor wrote, and both of the two `grep` gates below would then pass vacuously. The file is tracked at BASE, so the single-ref form's blindness to untracked files does not apply to it. The redirection target is forward-slash spelled for the reason stated in P0-T10; `TestResults/` is ignored by .gitignore line 39 `[Tt]est[Rr]esult*/` and `*.diff` files inside it enter no porcelain, diff, or format gate in this plan. + + The eighth command is deliberately NOT the two-stage `grep -E '^\+' ... | grep -E -i ...` pipeline P3-T5 uses, and the difference is deliberate rather than an inconsistency. P3-T5's first stage exists to restrict the search to added lines across a five-file diff. Here the whole diff is six added comment lines, six modified lines (the four lines of the cache declaration at 33-36 and the two `GetValue` call sites at 49 and 58), and their context, and the file ALREADY contains the token `Thread` on pre-existing context lines — `int marshalTargetThreadId = Thread.CurrentThread.ManagedThreadId;` and the surrounding body of `UnhookItem_InvokedFromThreadPoolThread_RunsComAccessOnMarshalTargetThread`. Searching the whole diff rather than only its added lines is therefore the STRICTER gate here, because it would also report a banned token appearing on a context or removed line, and none of the seven tokens is present anywhere in this file at BASE: `Thread.CurrentThread` and `ThreadPool` do not contain `Thread.Sleep`, and none of `Task.Delay`, `SpinWait`, `Retry`, `retries`, `Timeout(`, or `PushFrame` occurs in the file. That last statement was re-derived by reading the file in full in the revision round 15 pass, and it is what makes the stricter form satisfiable rather than unsatisfiable. + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md` with `Timestamp:`, `Command:` (all nine), `EXIT_CODE:` (per command), and `Output Summary:` quoting each command's output verbatim, recording the byte size of `TestResults/p2-t4-emailmovemonitor.diff`, and recording both the plan-stated pre-edit line count of 314 and the observed post-edit `wc -l` count. Acceptance: + + 1. The first command prints one line reporting a count of exactly `1` for that path, and the second and third each print nothing and exit 1 (`git grep` exits 1 on zero matches; `-c` suppresses the row entirely for a file with no match). Together these are the false-before/true-after pair for the retarget: at BASE the file contains one `"Dispatcher"` and one `GetProperty(` and zero `"_dispatcher"`, and after this task it contains the reverse. The pre-change state of all three is recorded in P0-T14's census and in the P4-T6 failure artifact. + 2. The fourth command prints one line reporting a count of exactly `8` for that path, unchanged from BASE. This is the enforceable form of "no test method added, removed, or renamed"; eight is the count re-derived in the revision round 15 pass and is also the number of failures P4-T6 reported, which is every `[TestMethod]` in the class. + 3. `TestResults/p2-t4-emailmovemonitor.diff` is non-empty. An empty diff means this gate had nothing to inspect and the result is BLOCKED, not PASS. + 4. The eighth command prints nothing and exits 1, which is what `grep` returns when it finds no match. If it prints any line, the change violates AC5 and the offending construct must be removed before proceeding. This span is what extends AC5's coverage to the sixth owned file; P3-T5's diff pathspec is `UtilitiesCS UtilitiesCS.Test` and does not reach it. + 5. The ninth command prints nothing and its second `grep` exits 1. No added and no removed line carries the token `.Should()`, which is the enforceable form of AC4's "unmodified assertions" for this file. + 6. The post-edit `wc -l` count is exactly 320, which is strictly less than the 500-line limit in .claude/rules/general-code-change.md. The pre-edit count is 314, re-derived in the revision round 15 pass; the six inserted comment lines account for the entire difference, and the six modified lines add none. An exact figure is used rather than a ceiling because `csharpier` has not run at this point in the plan — P4-T1 is the formatter step — so the count is fully determined by the four edits this task describes, and any other value means a line was added or removed that this task does not describe. The post-format count is audited separately by P4-T8, which carries a two-line formatter tolerance for exactly the reason this clause does not need one. + + If clause 1's second or third command prints a line, the retarget is incomplete and the task fails. Do not proceed to Phase 4 with a partially retargeted file, because P4-T6 would then still report the same eight failures and the cause would be ambiguous between an incomplete edit and a second consumer. + +### Phase 3 — Targeted verification of the fix and its blast radius + +- [x] [P3-T1] Rebuild with analyzers after the fix. Run: + + ```text + MSYS_NO_PATHCONV=1 msbuild.exe TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true + ``` + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p3-t1-analyzer-build.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` recording the trailing `N Warning(s)` and `N Error(s)` counts. Acceptance: `EXIT_CODE: 0`, `0 Error(s)`, and the warning count is less than or equal to the baseline analyzer warning count recorded in P0-T8. + +- [x] [P3-T2] Run the two new tests against the fixed code, using the POSIX vstest directory resolved in P0-T5: + + ```text + MSYS_NO_PATHCONV=1 PATH=":$PATH" vstest.console.exe UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll /InIsolation /Logger:trx /ResultsDirectory:TestResults/p3-t2 /TestCaseFilter:"FullyQualifiedName=UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests.Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize|FullyQualifiedName=UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests.Dispatcher_WhenBackingFieldIsPopulated_ReturnsThatSameInstance" + ``` + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p3-t2-regression-green.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` recording `Total tests`, `Passed`, `Failed`, and both test names with their individual outcomes. Acceptance: `EXIT_CODE: 0` as observed from the shell; `Total tests: 2` and `Passed: 2` as observed in the console summary block; and `Failed: 0` read from the `failed` attribute of the single `` element in the TRX file this task's own `/Logger:trx` switch writes under `TestResults/p3-t2/`. + + The `Failed` value is sourced from the TRX rather than the console because a green `vstest.console.exe` run prints no `Failed:` line at all — measured across four green runs in this worktree during preflight round 5, the whole summary block a successful run emits is `Test Run Successful.` followed by `Total tests`, `Passed`, and `Total time` only. See constraint 5 in "Shell constraints measured in this worktree". Both test names and their individual outcomes remain console-observed, because per-test result lines ARE printed on a green run. This task records no `Skipped` figure, so the `total` minus `executed` derivation stated in constraint 5 does not apply here; the `notExecuted` attribute is not read by this task and MUST NOT be introduced into it. `TestResults/p3-t2/` is written by this task and by no other task in this plan; if this task has been run more than once, the "TRX selection rule" stated immediately after constraint 5 governs which `.trx` in that directory is read and what is recorded about the selection. The TRX reference is subject to the same redaction rule P0-T10 states: identify the file by its repository-relative results directory only, never by its own name and never by quoting the run's `Results File:` console line, because `vstest.console.exe` composes the default TRX filename from the host account name and the machine name and prints it inside a full absolute host path. + +- [x] [P3-T3] Run the four `UtilitiesCS.Test` classes the spec names as at risk, plus the fifth class P1-T5 modifies, using the POSIX vstest directory resolved in P0-T5: + + ```text + MSYS_NO_PATHCONV=1 PATH=":$PATH" vstest.console.exe UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll /InIsolation /Logger:trx /ResultsDirectory:TestResults/p3-t3 /TestCaseFilter:"FullyQualifiedName~UtilitiesCS.Test.Threading.IdleAsyncQueue_Tests|FullyQualifiedName~UtilitiesCS.Test.Threading.ProgressTrackerAsync_Tests|FullyQualifiedName~UtilitiesCS.Test.ProgressTracker_Tests|FullyQualifiedName~WpfDispatcherYieldTests|FullyQualifiedName~OutlookFolderTreeServiceConcurrencyTests" + ``` + + `UtilitiesCS.Test.ProgressTracker_Tests` is the fully-qualified name of the class declared in `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs`; that file declares `namespace UtilitiesCS.Test` on line 12, not `UtilitiesCS.Test.Threading`, re-derived this pass. It is included here because P1-T5 modifies it, and it is not a prefix of any other class name in this filter. + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p3-t3-at-risk-tests.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` recording `Total tests`, `Passed`, `Failed`, and the name and outcome of every executed test. `Total tests` and `Passed` are read from the console summary block; `Failed` is read from the `failed` attribute of the single `` element in the TRX file this task's own `/Logger:trx` switch writes under `TestResults/p3-t3/`, because a green run prints no aggregate `Failed:` line on the console (constraint 5 in "Shell constraints measured in this worktree"). The per-test names and outcomes remain console-observed. This task records no `Skipped` figure, so the `total` minus `executed` derivation stated in constraint 5 does not apply here; the `notExecuted` attribute is not read by this task and MUST NOT be introduced into it. `TestResults/p3-t3/` is written by this task and by no other task in this plan; if this task has been run more than once — which its own clause 1 below anticipates, since a zero-test run requires a corrected filter and a re-run — the "TRX selection rule" stated immediately after constraint 5 governs which `.trx` in that directory is read and what is recorded about the selection. The TRX reference is subject to the same redaction rule P0-T10 states: identify the file by its repository-relative results directory only, never by its own name and never by quoting the run's `Results File:` console line. Acceptance: + + 1. `Total tests` is greater than zero. A zero-test run means the filter matched nothing and proves nothing; treat it as a failure of this task and correct the filter. + 2. The executed set includes, by name, `AddEntry_UseUiThreadTrue_DequeuesEntryAndSuppressesDispatcherException`, `YieldAsync_WithoutDispatcher_RemainsStrict`, `InitializeAsync_WithCurrentDispatcher_InitializesAndReturnsTracker`, `GetSnapshotAsync_WorkerOriginatedColdBuild_UsesCapturedStaDispatcher`, and `Initialize_WithCurrentDispatcherAndScreen_InitializesViewerAndUpdatesUi` (the last is the `[STATestMethod]` at `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` line 412 that writes `UiThread._dispatcher`, re-derived this pass). + 3. The failing set is empty, or every member of it is also a member of the `BASELINE_FAILURE_SET` recorded in P0-T10. Any failing test that is not in that baseline set fails this task. If one of the five named tests appears in the failing set and also in the baseline set, record `PRE-EXISTING FAILURE: ` in the artifact and report it to the caller before AC4 is marked, because AC4's wording is "all pass". + +- [x] [P3-T4] Re-verify AC3 against the committed tree rather than against the P0 reading. Run: + + ```text + git add -A -- UtilitiesCS UtilitiesCS.Test + git status --porcelain -- UtilitiesCS/Threading/ProgressTrackerAsync.cs + git diff --name-status --cached 87cb4df338322844abfa580abea14df77e738e5c -- UtilitiesCS/Threading/ProgressTrackerAsync.cs + git grep -n -F "UiDispatcher = UiThread.Dispatcher;" -- UtilitiesCS/Threading/ProgressTrackerAsync.cs + ``` + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/other/p3-t4-progresstrackerasync-unmodified.md` with `Timestamp:`, `Command:` (all four), `EXIT_CODE:` (per command), `Output Summary:`. The `Output Summary:` MUST state, in one paragraph, why the fix in `UtilitiesCS/Threading/UiThread.cs` alone converts this consumer's failure mode: the property read on line 33 now throws before the dereference on line 35 is reached, so the consumer receives a self-diagnosing `InvalidOperationException` at the property-access line without a code change. Acceptance: the porcelain status command prints nothing, the `--cached` name-status diff prints nothing, and the grep prints exactly one line whose line number is 33. + + On what each span actually observes: `git status --porcelain` reports both the index and the working tree, so it is the span that observes the staged state produced by the preceding `git add`. A two-dot `git diff A..HEAD` never observes the index at all — it compares two commits — and before this plan's first commit (P5-T9) it would compare BASE against a HEAD that is identical to it across this task's pathspec — the reconciliation merge recorded in the BASE re-anchor note changed no path under `UtilitiesCS` or `UtilitiesCS.Test` relative to BASE — and so print nothing whatever the executor wrote, which is a vacuous pass. The `--cached` form used above compares the index against the named commit, so it observes the staged state directly and reports a real change to this path if one is staged. The two spans are complementary: `--cached` is blind to an unstaged working-tree edit, and porcelain is the span that catches that case. + +- [x] [P3-T5] Verify AC5 across the whole change. Run: + + ```text + mkdir -p TestResults + git diff 87cb4df338322844abfa580abea14df77e738e5c -- UtilitiesCS UtilitiesCS.Test > TestResults/p3-t5-source.diff + grep -E '^\+' TestResults/p3-t5-source.diff | grep -E -i 'Thread\.Sleep|Task\.Delay|SpinWait|Retry|retries|Timeout\(|PushFrame' + ``` + + The filter is a plain POSIX `grep` pipeline. It has to be: this worktree's shell refuses any command named for a PowerShell 7 host, in every argument shape (see constraint 1 in "Shell constraints measured in this worktree"), so a PowerShell-based filter would leave this gate with no runnable command at all. `grep` is available in the same shell that runs the two preceding spans. The `-i` flag supplies case-insensitive matching, and the seven-token list is the same list P1-T2's authoring constraint enumerates. + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p3-t5-no-timing-tokens.md` with `Timestamp:`, `Command:` (all three), `EXIT_CODE:` (per command), and `Output Summary:` recording the byte size of `TestResults/p3-t5-source.diff` and quoting the last command's output verbatim. Acceptance: `TestResults/p3-t5-source.diff` is non-empty (an empty diff means the gate had nothing to inspect and the result is BLOCKED, not PASS), and the third command prints nothing and its second `grep` exits 1, which is what `grep` returns when it finds no match. If it prints any line, the change violates AC5 and the offending construct must be removed before proceeding. + + Two properties of the diff span are load-bearing and must not be "simplified". First, it is anchored to the BASE SHA rather than left bare, so it cannot silently degrade into a worktree-versus-index comparison. Second, it uses the **single-ref** form `git diff -- `, which compares the working tree against that commit, and NOT the two-dot form `git diff ..HEAD`. This plan's first commit is P5-T9, so at Phase 3 the branch HEAD is still identical to BASE across `UtilitiesCS` and `UtilitiesCS.Test` — the reconciliation merge recorded in the BASE re-anchor note changed no path under either directory relative to BASE — and a two-dot span would emit an empty diff no matter what the executor wrote; the `grep` filter would then print nothing and the gate would pass vacuously. The single-ref form is blind to untracked files, which is harmless here because all five files this plan writes are already tracked at BASE (re-derived this pass from UtilitiesCS.Test/UtilitiesCS.Test.csproj lines 477, 479, 490, and 494, and from the presence of `UtilitiesCS/Threading/UiThread.cs` at BASE). + + The redirection target is written with forward slashes for the reason stated in P0-T10: a backslash-spelled `TestResults\p3-t5-source.diff` would be created as `TestResultsp3-t5-source.diff` at the worktree root, which .gitignore's `[Tt]est[Rr]esult*/` rule on line 39 does not match, which P5-T10's scoped porcelain check does not see, and which the following `grep -E '^\+' TestResults/p3-t5-source.diff` would then fail to open — producing a second, independent vacuous pass of this gate. + +- [x] [P3-T6] Run the `QuickFiler.Test` class that constructs the parameterless `WpfUiDispatcher`, whose provider closes over `UiThread.Dispatcher`. This class is NOT named in `spec.md` or in the research trail; it was found during this plan's adversarial self-review by enumerating `new WpfUiDispatcher(` across the repository. Command: + + ```text + MSYS_NO_PATHCONV=1 PATH=":$PATH" vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /InIsolation /Logger:trx /ResultsDirectory:TestResults/p3-t6 /TestCaseFilter:"FullyQualifiedName~QuickFiler.Controllers.Tests.WpfUiDispatcherTests" + ``` + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p3-t6-quickfiler-wpfuidispatcher.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` recording `Total tests`, `Passed`, `Failed`, and each test name with its outcome. Acceptance: `Total tests` is 2 as observed in the console summary block; `Failed: 0` read from the `failed` attribute of the single `` element in the TRX file this task's own `/Logger:trx` switch writes under `TestResults/p3-t6/`; and both `Construction_YieldsAnIUiDispatcher` and `Invoke_InvokeAsync_BeginInvoke_ExecuteDelegateOnDispatcherThread` are listed by name as passing in the console output. + + The `Failed` value is sourced from the TRX because a green `vstest.console.exe` run prints no `Failed:` line at all, measured across four green runs in this worktree during preflight round 5 (constraint 5 in "Shell constraints measured in this worktree"). The by-name clause is unaffected by that measurement and is unchanged: the console DOES print per-test pass and fail lines on a green run, and the two names above are the two `[TestMethod]`s declared in QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs at lines 24 and 50, re-derived this pass. This task records no `Skipped` figure, so the `total` minus `executed` derivation stated in constraint 5 does not apply here; the `notExecuted` attribute is not read by this task and MUST NOT be introduced into it. `TestResults/p3-t6/` is written by this task and by no other task in this plan; if this task has been run more than once, the "TRX selection rule" stated immediately after constraint 5 governs which `.trx` in that directory is read and what is recorded about the selection. The TRX reference is subject to the same redaction rule P0-T10 states: identify the file by its repository-relative results directory only, never by its own name and never by quoting the run's `Results File:` console line, because `vstest.console.exe` composes the default TRX filename from the host account name and the machine name and prints it inside a full absolute host path. + + The plan-time expectation is that neither is affected — the constructor only captures the provider lambda without invoking it, and the second test installs a real dispatcher through `UiThreadDispatcherFixture` before any forwarding call — but that expectation is verified by running the tests, not asserted from reading. + +### Phase 4 — Final QA loop (format, analyze, type-check, test, coverage) + +**Second pass, and why the first pass's marks were cleared.** P4-T1 through P4-T5 completed one pass +on 2026-09-03 and P4-T6 then failed, which is what produced the revision round 15 scope widening +recorded in the header. P2-T4 rewrites a tracked source file after that pass ran, so the four earlier +steps' artifacts describe a tree that no longer exists. AC6 requires the toolchain to pass "in order +in a single final pass", and that requirement is what the cleared marks restore: every task in this +phase runs again, in order, against the tree P2-T4 leaves. The per-step artifact paths are unchanged, +so each is overwritten by the second pass; the first pass is not lost, because P4-T8's acceptance +below requires the loop-closure artifact to record every pass in chronological order, the first one +included, and to state the first pass's outcome including P4-T6's eight failures. No task ID and no command line in this phase changed in round 15 +apart from the owned-path lists in P4-T1, P4-T2, and P4-T8 and the restated acceptance in P4-T6. + +- [x] [P4-T1] Format, with the formatter's write scope restricted to the six paths this plan owns. Run, from the worktree root: + + ```text + git status --porcelain + dotnet tool run csharpier format UtilitiesCS/Threading/UiThread.cs UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" + git status --porcelain + ``` + + This plan's owned file set is exactly the six paths named on that command line: + + - `UtilitiesCS/Threading/UiThread.cs` + - `UtilitiesCS.Test/Threading/UiThread_Tests.cs` + - `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` + - `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` + - `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` + - `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` + + The sixth operand is double-quoted because its directory is spelled `Helper Classes`; see the note in the "Scope" section. An unquoted operand would be split by the shell and CSharpier would be handed two paths that do not exist. + + **Option chosen for the repo-wide-drift problem, and why.** The preflight round-2 review identified that `dotnet tool run csharpier format .` formats the ENTIRE repository, so drift it repairs outside this plan's owned paths — in `QuickFiler/`, `TaskMaster/`, `ToDoModel/`, and elsewhere — would be rewritten, would not be restored by a check scoped to those two directories, would not be committed, and would be invisible to every terminal porcelain gate in this plan, which are all scoped the same way. Two remedies were available: widen the porcelain check and the `git checkout --` restoration to the whole worktree, or restrict the formatter's write scope so the unowned drift is never created. This plan takes the second. The reason is that the first remedy makes the gate depend on the whole worktree's ambient state, including tracked directories this plan has no relationship with (.claude/agent-memory/ is tracked in this repository), so a concurrent or pre-existing modification anywhere would either fail the gate or force an exclusion list that grows without bound. Restricting the formatter's write scope removes the failure mode at its source: a file the formatter is never given cannot be rewritten. + + Repository policy is preserved by this choice. CSharpier is file-based and formats exactly the paths it is given, so the six owned files receive character-for-character the formatting `csharpier format .` would have applied to them. The repo-wide obligation is discharged on the verification side, unchanged: P4-T2 still runs `dotnet tool run csharpier check .` over the whole tree, which is the same read-only, CI-parity command .github/workflows/_format-check.yml line 41 runs (`dotnet csharpier check .`, re-derived this pass). A formatting regression anywhere in the repository therefore still surfaces; what this task no longer does is silently repair a pre-existing one. + + If the multi-path invocation is rejected by the pinned CSharpier 1.2.6 CLI, run `dotnet tool run csharpier format ` once per owned path instead and record all six invocations. Both forms have identical write scope, so the acceptance below is unaffected. + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t1-format.md` with `Timestamp:`, `Command:` (all three, or all eight if the per-path fallback was used), `EXIT_CODE:` (per command), and `Output Summary:` quoting the formatter's trailing summary line verbatim (CSharpier prints a `Formatted N files in Xms` line, where `N` is the count of files processed rather than the count rewritten, so the number alone is not evidence of a no-op), the unscoped porcelain output taken before the formatter ran, the unscoped porcelain output taken after it ran, and the single line `RESTORED_UNOWNED_FORMAT_DRIFT: NOT APPLICABLE (formatter write scope restricted to the six owned paths)`. Acceptance: `EXIT_CODE: 0` for the formatter; and the two unscoped porcelain outputs differ, if at all, only in entries for the six owned paths above. The porcelain spans are deliberately UNSCOPED here — unlike the terminal gates in P5-T10 and P5-T11, whose pathspecs exist to keep unrelated tracked state from making them unsatisfiable — because the property this task must establish is precisely that no path outside the owned five changed, and a scoped span cannot observe that. Comparing the before and after outputs, rather than asserting an empty one, is what makes the observation independent of whatever ambient modifications already existed when the task began. + + This task records a before-and-after tree observation in addition to the formatter's exit code, because a formatter rewrites tracked source and still exits 0 after rewriting: its exit code alone is identical on a clean run and on a repairing one. + +- [x] [P4-T2] Verify formatting. Run `dotnet tool run csharpier check .`. Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t2-format-check.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` enumerating every path the command reports as unformatted. Acceptance: none of this plan's six owned paths — `UtilitiesCS/Threading/UiThread.cs`, `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`, `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`, `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs`, `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` — appears in the reported set, and the reported set is a subset of the `BASELINE_FORMAT_DRIFT_SET` recorded in P0-T7. When that baseline set was `NONE`, this reduces to `EXIT_CODE: 0` with an empty reported set. + + The subset clause is the whole-tree half of this gate and is not slack. P4-T1 restricts the formatter's write scope to the six owned paths, so it repairs no pre-existing drift and creates none: every path this command reports must therefore already have been reported at P0-T7. A path in the reported set that is absent from `BASELINE_FORMAT_DRIFT_SET` is new drift introduced during this plan's execution and fails this task. This command is run over the whole repository (`.`), matching .github/workflows/_format-check.yml line 41 exactly, so the check retains full repository scope even though P4-T1's write scope is narrow. + +- [x] [P4-T3] Analyze. Run: + + ```text + MSYS_NO_PATHCONV=1 msbuild.exe TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true + ``` + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t3-analyzer-build.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` recording the trailing `N Warning(s)` and `N Error(s)` counts. Acceptance: `EXIT_CODE: 0`, `0 Error(s)`, and the warning count is less than or equal to the baseline analyzer warning count from P0-T8. + +- [x] [P4-T4] Type-check. Run: + + ```text + MSYS_NO_PATHCONV=1 msbuild.exe TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true + ``` + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t4-nullable-build.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` recording the trailing `N Error(s)` count and the quoted command line. Acceptance: `EXIT_CODE: 0`, `0 Error(s)`, and the quoted command line contains `msbuild.exe TaskMaster.sln`, contains no `Nullable=enable` substring, and uses `/t:Rebuild`. As in P0-T9, that first clause is worded as `contains` because the recorded line begins with the `MSYS_NO_PATHCONV=1 ` assignment, and it records only the executable spelling this shell requires; the `Nullable=enable` and `/t:Rebuild` clauses are the substantive checks and are unchanged. This gate is the one that proves AC2's real value: with the backing field now declared `Dispatcher?` in a file that opts into nullable analysis, a getter that returned the field without narrowing it would raise `CS8603` and fail here. + +- [x] [P4-T5] Test `UtilitiesCS.Test` with coverage, using the native vstest directory resolved in P0-T5: + + ```text + MSYS_NO_PATHCONV=1 dotnet-coverage collect --output coverage/p4-t5.cobertura.xml --output-format cobertura --settings coverage.config -- "\vstest.console.exe" UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /Logger:trx /ResultsDirectory:TestResults/p4-t5 /TestCaseFilter:TestCategory!=LiveOutlook + ``` + + This command's flag set is deliberately identical to P0-T10's; the two differ only in the `--output` filename and the `/ResultsDirectory` value. The executable operand is spelled identically to P0-T10's — the same double-quoted `` substitution, supplied as an ARGUMENT after `--` rather than as a command name, and therefore without the `PATH=` prefix the six direct vstest tasks carry. The `MSYS_NO_PATHCONV=1` prefix IS carried here, exactly as in P0-T10, and its presence on both sides of the pair is part of what keeps the two runs command-identical; a prefix on one side only would mean one run executed the suite and the other executed nothing. Keeping the two spellings identical is a precondition of P4-T7's comparison. In particular `/EnableCodeCoverage` is deliberately absent from both. Adding it here alone would activate a second, nested collector underneath `dotnet-coverage collect`, changing the loaded-module set and therefore the `lines-valid` denominator, and P4-T7's baseline-to-post-change comparison would no longer be a comparison of like with like. `dotnet-coverage collect` alone already produces the Cobertura file P4-T7 reads. + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t5-utilitiescs-tests.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` recording `Total tests`, `Passed`, `Failed`, `Skipped`, the TRX `total` and `executed` values from which the `Skipped` figure is derived, and the `lines-covered`, `lines-valid`, and `line-rate` attribute values read from the root `` element of `coverage/p4-t5.cobertura.xml`. `Total tests` and `Passed` are read from the console summary block; `Failed` is read from the `failed` attribute of the single `` element in the TRX file this task's own `/Logger:trx` switch writes under `TestResults/p4-t5/`; and `Skipped` is DERIVED from that same element as `total` minus `executed`, with `total`, `executed`, and the derived `Skipped` value all recorded. The `notExecuted` attribute MUST NOT be used: `vstest.console.exe`'s TRX logger populates only `total`, `executed`, `passed`, and `failed` on the `` element and hard-codes every other attribute (`notExecuted`, `error`, `timeout`, `aborted`, `inconclusive`, ...) to `0` regardless of the run's actual outcome. Measured in preflight round 6: a run whose console printed `Skipped: 1` produced a TRX with `notExecuted="0"`; the derivation `total` minus `executed` correctly returned `1` on that run and `0` on an all-passing run. Both counts come from the TRX rather than the console because a green run prints neither aggregate line on the console (constraint 5 in "Shell constraints measured in this worktree"). The failing test names, when there are any, remain console-observed. `TestResults/p4-t5/` is written by this task and by no other task in this plan; if this task has been run more than once — which P4-T8's loop-restart text anticipates — the "TRX selection rule" stated immediately after constraint 5 governs which `.trx` in that directory is read and what is recorded about the selection. The TRX reference is subject to the same redaction rule P0-T10 states: identify the file by its repository-relative results directory only, never by its own name and never by quoting the run's `Results File:` console line. Acceptance: the failing-test set is empty, or is a subset of the `BASELINE_FAILURE_SET` recorded in P0-T10 with no new member; `Total tests` is greater than or equal to the baseline `Total tests` plus 2; the `total` and `executed` values from which `Skipped` was derived are recorded; and all three coverage attribute values are recorded as concrete numbers. + +- [x] [P4-T6] Test `QuickFiler.Test`, using the POSIX vstest directory resolved in P0-T5: + + ```text + MSYS_NO_PATHCONV=1 PATH=":$PATH" vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /InIsolation /Logger:trx /ResultsDirectory:TestResults/p4-t6 /TestCaseFilter:TestCategory!=LiveOutlook + ``` + + This command's flag set is deliberately identical to P0-T11's, including the `MSYS_NO_PATHCONV=1` and `PATH=` prefixes and the bare `vstest.console.exe` command name; the two differ only in the `/ResultsDirectory` value. `/EnableCodeCoverage` is deliberately absent from both: this task records no coverage figure, so the flag would have no consumer here, and its presence on one side of a baseline-to-post-change pair and not the other is exactly the asymmetry that makes two runs incomparable. + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t6-quickfiler-tests.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` recording `Total tests`, `Passed`, `Failed`, `Skipped`, and the TRX `total` and `executed` values from which the `Skipped` figure is derived. `Total tests` and `Passed` are read from the console summary block; `Failed` is read from the `failed` attribute of the single `` element in the TRX file this task's own `/Logger:trx` switch writes under `TestResults/p4-t6/`; and `Skipped` is DERIVED from that same element as `total` minus `executed`, with `total`, `executed`, and the derived `Skipped` value all recorded. The `notExecuted` attribute MUST NOT be used: `vstest.console.exe`'s TRX logger populates only `total`, `executed`, `passed`, and `failed` on the `` element and hard-codes every other attribute (`notExecuted`, `error`, `timeout`, `aborted`, `inconclusive`, ...) to `0` regardless of the run's actual outcome. Measured in preflight round 6: a run whose console printed `Skipped: 1` produced a TRX with `notExecuted="0"`; the derivation `total` minus `executed` correctly returned `1` on that run and `0` on an all-passing run. Both counts come from the TRX rather than the console because a green run prints neither aggregate line on the console (constraint 5 in "Shell constraints measured in this worktree"). The failing test names, when there are any, remain console-observed. `TestResults/p4-t6/` is written by this task and by no other task in this plan; if this task has been run more than once — which P4-T8's loop-restart text anticipates — the "TRX selection rule" stated immediately after constraint 5 governs which `.trx` in that directory is read and what is recorded about the selection. The TRX reference is subject to the same redaction rule P0-T10 states: identify the file by its repository-relative results directory only, never by its own name and never by quoting the run's `Results File:` console line. Acceptance, restated in revision round 15 so that it names the concrete figures this run must produce rather than only a subset relation: + + 1. `Total tests` equals the baseline `Total tests` recorded in P0-T11, which is `1312`. This plan adds no test to this assembly and P2-T4 adds none either, so any other value means the assembly or the filter changed and the task fails. + 2. `Passed` equals `1312` and the failing-test set is EMPTY. The subset-of-`BASELINE_FAILURE_SET` wording this clause replaces was correct in form but vacuous in substance here, because P0-T11 recorded an empty `BASELINE_FAILURE_SET` (1312 of 1312 passed at BASE): the only set that is a subset of the empty set is the empty set, and stating the figure directly is what makes the gate readable against the artifact. + 3. `Failed` equals `0`, read from the `failed` attribute of the single `` element in the TRX this task writes under `TestResults/p4-t6/`. + 4. The `total` and `executed` values from which `Skipped` was derived are recorded, and the derived `Skipped` is `0`. + 5. All eight `EmailMoveMonitorTests` methods are listed by name as passing in the console output: `HookItem_FirstItemOfFolder_SubscribesBeforeItemMoveOnce_AndSharedFolderDoesNotResubscribe`, `UnhookItem_RemovesLastItemForFolder_UnsubscribesBeforeItemMoveOnlyOnLastItem`, `UnhookItem_Null_IsNoOp_NoComAccessNoMarshalInvocation`, `UnhookItem_UsesCachedEntryIds_RemovesExactlyTheMatchingEntry`, `AllComAccess_FlowsThroughInjectedMarshalDelegate`, `UnhookAll_UnsubscribesEveryFolder_AndClearsState`, `DuplicateHookOfSameItem_AndUnhookNeverHookedItem_DoNotThrowOrSpuriouslyUnsubscribe`, and `UnhookItem_InvokedFromThreadPoolThread_RunsComAccessOnMarshalTargetThread`. These are exactly the eight the first pass of this task reported as failing, quoted from the preserved first-pass copy at `evidence/regression-testing/p4-t6-first-pass-failure.md`, so this clause is the direct pass-after counterpart of that recorded fail-before. Per-test pass lines ARE printed on a green run (constraint 5 in "Shell constraints measured in this worktree"), so this clause is readable from the console output. + + **Do this before running the command above.** The first pass of this task is the fail-before evidence for P2-T4. Its artifact records `Total tests: 1312`, `Passed: 1304`, `Failed: 8`, the eight test names, and an executed counterfactual isolating the cause to P2-T1. When this task is re-run, its artifact is overwritten by that re-run, so the fail-before record must be preserved under a separate name first. That preserved copy is the artifact AC4's check-off cites as the fail-before half of the sixth owned file's repair. + + The preservation MUST be conditional on the destination's absence, and an unconditional copy is a defect rather than a stylistic choice. This task can run more than once — P4-T8's clause 1 restarts the Phase 4 loop from P4-T1 whenever a step rewrites a tracked file — and on any pass after the first, `evidence/qa-gates/p4-t6-quickfiler-tests.md` holds the pass-after result of `1312` of `1312`. An unconditional copy would then overwrite the preserved fail-before record with a passing run under a filename that asserts the opposite, and because AC4's check-off cites that file as the fail-before half of the repair, the corruption would silently invalidate AC4's evidence chain rather than fail any gate. As the first action of this task, run, from the worktree root: + + ```text + if [ -f docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p4-t6-first-pass-failure.md ]; then echo "FAIL_BEFORE_PRESERVATION: FOUND IN PLACE, LEFT UNTOUCHED"; else cp docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t6-quickfiler-tests.md docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p4-t6-first-pass-failure.md; echo "FAIL_BEFORE_PRESERVATION: CREATED FROM THE FIRST-PASS ARTIFACT"; fi + grep -n -F 'Failed: 8' docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/regression-testing/p4-t6-first-pass-failure.md + ``` + + The conditional is a plain POSIX `if [ -f ... ]` test in the same git-bash shell every other command block in this plan runs through; a PowerShell-hosted form is unavailable here (constraint 1 in "Shell constraints measured in this worktree"). `cp -n` is deliberately NOT used in its place: it exits 0 whether or not it copied, so the no-copy case would leave no observable at all, which is the specific failure the clause below is written to exclude. + + Both branches record an observable and neither is a silent no-op. The create branch prints `FAIL_BEFORE_PRESERVATION: CREATED FROM THE FIRST-PASS ARTIFACT`; the already-present branch prints `FAIL_BEFORE_PRESERVATION: FOUND IN PLACE, LEFT UNTOUCHED`, which records that the preserved fail-before record was found in place and left untouched. The second command then runs in EITHER branch and prints the `Failed: 8` line the preserved file carries, so the fail-before figure is verified against the file rather than assumed. Record the printed branch line and the `grep` output in this task's artifact under the heading `FAIL_BEFORE_PRESERVATION:`. If the `grep` prints nothing and exits 1, stop and report BLOCKED: a preserved file that does not carry `Failed: 8` is either a pass-after copied under the wrong name or a truncated file, and in both cases AC4's fail-before half is absent rather than merely unverified, so proceeding would let AC4 be marked on an evidence chain that does not close. + +- [x] [P4-T7] Compute and record the coverage delta and the changed-line coverage. Derive the added-line set as the line numbers of `+` lines produced by: + + ```text + git diff 87cb4df338322844abfa580abea14df77e738e5c -- UtilitiesCS/Threading/UiThread.cs + ``` + + This is the single-ref working-tree form, anchored to BASE, for the same reason stated in P3-T5: this plan's first commit is P5-T9 and the reconciliation merge recorded in the BASE re-anchor note changed nothing in this file relative to BASE, so a two-dot `87cb4df338322844abfa580abea14df77e738e5c..HEAD` span would return an empty diff at Phase 4, the added-line set would be empty, and clause (a) below could never be satisfied. `UtilitiesCS/Threading/UiThread.cs` is tracked at BASE, so the single-ref form's blindness to untracked files does not apply to it. + + Read `coverage/p4-t5.cobertura.xml`, locate the class node whose `filename` attribute ends in `Threading\UiThread.cs` or `Threading/UiThread.cs` (Cobertura emits the host path separator in that attribute value; accept either), and intersect its `` elements with the added-line set. + + **Redaction rule for this task's artifact.** `dotnet-coverage` writes the `filename` attribute as a full absolute host path — it begins with the drive letter and includes the user profile directory, so it contains a host account name. When recording the located class node in the evidence artifact, record ONLY the line-hit data: the `` values and their `hits` values. Do NOT record the `filename` attribute's absolute-path value verbatim. Identify the node in the artifact by the repository-relative path `UtilitiesCS/Threading/UiThread.cs` instead. The same rule applies to any other absolute path this task encounters while reading the Cobertura file. + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t7-coverage-delta.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, and `Output Summary:` recording, as concrete numbers: the baseline `lines-covered`, `lines-valid`, and `line-rate` from P0-T10; the post-change `lines-covered`, `lines-valid`, and `line-rate` from P4-T5; the signed difference `post-change lines-valid` minus `baseline lines-valid`; the added-line set; the intersected line numbers with their `hits` values; and the resulting changed-line coverage percentage. Both recorded `line-rate` figures MUST be labelled, verbatim, `raw unstripped dotnet-coverage line-rate for the UtilitiesCS.Test process; not the repository first-party figure CLAUDE.md's 80% refers to`. The word "single-assembly" is deliberately absent from that label: `dotnet-coverage collect` instruments the whole test host process and reports every first-party module loaded into it, not one assembly, so `UtilitiesCS` and `UtilitiesCS.Test` both contribute to `lines-valid` (which is also why P4-T7 clause (c) is a band rather than an equality). The label's purpose — marking the figure as not comparable to the repository-wide first-party percentage CLAUDE.md states — is unchanged. Acceptance: + + (a) The intersected set contains at least two line numbers. If it contains fewer, the coverage report did not resolve this file and the result is BLOCKED, not PASS. + + (b) Every intersected line has `hits` of 1 or more, giving 100% changed-line coverage, which satisfies the `>= 90%` new-code target from CLAUDE.md. + + (c) The denominator is comparable: the signed `lines-valid` difference is between 0 and 200 inclusive. Because P0-T10 and P4-T5 now run a flag-identical command, the only legitimate source of a `lines-valid` change between them is the source this plan adds — approximately six coverable lines in `UtilitiesCS/Threading/UiThread.cs` and approximately seventy-five in `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, both of which are inside the denominator because `coverage.config` excludes third-party module paths only (re-derived this pass). An exact-equality clause would be unsatisfiable for that reason and is deliberately not used. A difference outside the stated band indicates the collector's loaded-module set differed between the two runs — a mismatch of that kind has moved this denominator by tens of thousands of lines on an unchanged tree in this repository. If the difference falls outside the band, record `COVERAGE DENOMINATOR MISMATCH`, state explicitly that the repository-wide percentage comparison in clause (d) is VOID, and rest this gate on clauses (a) and (b) alone. + + (d) The post-change `line-rate` is greater than or equal to the baseline `line-rate` minus 0.005, the stated tolerance absorbing run-to-run nondeterminism in this suite. This clause is skipped and marked VOID when clause (c) recorded a denominator mismatch. + + Also record the post-change `line-rate` against the `>= 80%` repository figure from CLAUDE.md as an observation, not a gate. That observation is explicitly non-comparable to the policy figure: it is the raw, unstripped `dotnet-coverage` line rate for the `UtilitiesCS.Test` process, whereas CLAUDE.md's 80% refers to the repository's first-party testable denominator after third-party stripping. If the post-change figure is below that floor while the baseline figure was also below it, record `PRE-EXISTING FLOOR SHORTFALL` and do not treat it as caused by this change. + +- [x] [P4-T8] Confirm the loop closed in a single clean pass and re-audit file sizes after the formatter ran. Re-run the P2-T3 command, extended with the sixth owned path: + + ```text + wc -l UtilitiesCS/Threading/UiThread.cs UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" + ``` + + The first five operands are character-for-character the command P0-T13 and P2-T3 ran, for the reason stated in P0-T13: the before and after counts must come from one counting idiom or the comparison is incommensurable. The sixth operand is added by revision round 15 and is double-quoted because its directory is spelled `Helper Classes`. Its baseline for comparison is not P0-T13, which predates the scope widening and does not measure it, but the post-edit count P2-T4 records. Read the six named per-file rows and ignore the trailing `total` row. + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t8-loop-closure.md` with `Timestamp:`, `Command:` (the re-run command above, plus the string `see P4-T1..P4-T7` for the loop record), `EXIT_CODE:`, and `Output Summary:` listing each of P4-T1 through P4-T7 with its recorded exit code and artifact path, stating explicitly whether any step rewrote a tracked file, and quoting the six post-format per-file line counts. Acceptance: + + 1. The artifact lists all seven steps in order with their artifacts, and records that no step after P4-T1 rewrote a tracked file. If any did, the loop restarts from P4-T1 and this artifact records every pass, per clause 2. + 2. The artifact records EVERY Phase 4 pass explicitly, one entry per pass, in chronological order from the earliest to the latest. The number of entries is open-ended and this clause states no upper bound on it: clause 1 above can restart the loop from P4-T1 again, so a clause that demanded exactly two entries would become unsatisfiable on a third pass while enforcing nothing extra on the second. The FIRST entry MUST be the pass that ran on 2026-09-03 with P4-T1 through P4-T5 green and P4-T6 failing 8 of 1312, and the LAST entry MUST be the pass whose artifacts this task lists. Omitting the first pass is not acceptable: it is the fail-before evidence for P2-T4 and the reason this phase ran more than once, and an artifact recording only the final pass would misrepresent a multi-pass execution as a single clean one. + 3. The first five post-format line counts satisfy the same two clauses P2-T3 states, evaluated against the P0-T13 baseline counts. + 4. The sixth post-format line count is strictly less than 500 and is less than or equal to the post-edit count P2-T4 recorded, plus 2. The plan's intent is a count unchanged from P2-T4's; the plus-two tolerance exists solely because `csharpier` may re-wrap the retargeted `GetField(` call, which is a formatter decision this plan does not control. A count above that bound means the formatter or a later edit added lines this plan did not describe, and the task fails. + + The re-run exists because `csharpier format` in P4-T1 can change line counts, so a size audit taken only at P2-T3 and P2-T4 would describe a pre-format tree. + +### Phase 5 — Acceptance criteria, documentation, and handoff + +- [x] [P5-T1] Mark AC1 in `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md` (the bullet beginning "AC1: `UiThread.Dispatcher` throws a named `InvalidOperationException`"). Acceptance: that bullet reads `- [x]` and the marking is accompanied by a citation of `evidence/regression-testing/p1-t4-expect-fail.md` (recording `Failed: 1`) and `evidence/regression-testing/p3-t2-regression-green.md` (recording `Passed: 2`). + +- [x] [P5-T2] Mark AC2 in the same file (the bullet beginning "AC2: The `null!` null-forgiving suppression"). Acceptance: that bullet reads `- [x]` and cites `evidence/qa-gates/p2-t2-nullforgiving-removed.md` recording zero `null!` matches in `UtilitiesCS/Threading/UiThread.cs`, and `evidence/qa-gates/p4-t4-nullable-build.md` recording `0 Error(s)`. + +- [x] [P5-T3] Mark AC3 in the same file (the bullet beginning "AC3: UtilitiesCS/Threading/ProgressTrackerAsync.cs is left unmodified"). Acceptance: that bullet reads `- [x]` and cites `evidence/other/p3-t4-progresstrackerasync-unmodified.md`, which must contain the empty `--cached` name-status diff for that path, the empty porcelain status for that path, and the recorded verification paragraph. + +- [x] [P5-T4] Mark AC4 in the same file (the bullet beginning "AC4: No regression in"). AC4's enumerated no-regression FILE set was extended to five files in revision round 15; this task's citation list is extended to match. Two different counts appear in this task and they are not interchangeable: the no-regression FILE set holds five files, and the EVIDENCE set holds six artifacts, because the sixth owned file contributes both a fail-before and a pass-after artifact while each of the other five contributes at most one. Acceptance: that bullet reads `- [x]` and cites exactly the six artifacts listed in the AC4 row of the acceptance-criteria mapping table above — six is the count that row carries, re-derived in the revision round 16 pass against the table row itself, against the `AC-MAPPING: AC4` line at the end of this file, and against AC4's own evidence list in `spec.md` — namely: `evidence/qa-gates/p1-t5-donotparallelize.md` (recording that the change to `IdleAsyncQueue_Tests.cs` and `ProgressTrackerAsync_Tests.cs` is attribute-only and alters no assertion), `evidence/regression-testing/p3-t3-at-risk-tests.md` (five named tests executed, no failure outside the recorded `BASELINE_FAILURE_SET`), `evidence/regression-testing/p3-t6-quickfiler-wpfuidispatcher.md` (`Failed: 0`), `evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md` (recording that the change to `EmailMoveMonitorTests.cs` retargets one reflection lookup, alters no assertion, and leaves the `[TestMethod]` count at 8), and `evidence/regression-testing/p4-t6-first-pass-failure.md` together with `evidence/qa-gates/p4-t6-quickfiler-tests.md` (the fail-before 8 of 1312 and the pass-after 1312 of 1312 for that file). This bullet may not be marked while `evidence/qa-gates/p4-t6-quickfiler-tests.md` records a failure. + +- [x] [P5-T5] Mark AC5 in the same file (the bullet beginning "AC5: No retry, sleep, or timing tolerance"). Acceptance: that bullet reads `- [x]` and cites `evidence/qa-gates/p3-t5-no-timing-tokens.md` recording zero matching added lines in the anchored diff over `UtilitiesCS` and `UtilitiesCS.Test`, and `evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md` recording zero matching lines in the anchored diff over the sixth owned file. The two together are what make AC5's "anywhere in the diff" wording true of the whole diff: P3-T5's pathspec covers five of the six owned files and P2-T4's covers the sixth. + +- [x] [P5-T6] Mark AC6 in the same file (the bullet beginning "AC6: Full C# toolchain"). Acceptance: that bullet reads `- [x]` and cites exactly the seven artifacts listed in the AC6 row of the acceptance-criteria mapping table above: `evidence/qa-gates/p4-t1-format.md`, `evidence/qa-gates/p4-t2-format-check.md`, `evidence/qa-gates/p4-t3-analyzer-build.md`, `evidence/qa-gates/p4-t4-nullable-build.md`, `evidence/qa-gates/p4-t5-utilitiescs-tests.md`, `evidence/qa-gates/p4-t6-quickfiler-tests.md`, and `evidence/qa-gates/p4-t8-loop-closure.md`. `evidence/qa-gates/p4-t7-coverage-delta.md` is deliberately not cited here; it is AC7's evidence and is cited by P5-T7. + +- [x] [P5-T7] Mark AC7 in the same file (the bullet beginning "AC7: Repository-wide line coverage does not regress"). Acceptance: that bullet reads `- [x]` and cites exactly the two artifacts listed in the AC7 row of the acceptance-criteria mapping table above — `evidence/baseline/p0-t10-utilitiescs-tests-coverage.md` for the baseline figures and `evidence/qa-gates/p4-t7-coverage-delta.md` for the comparison — quoting the concrete baseline and post-change `line-rate` values, the signed `lines-valid` difference, and the concrete changed-line coverage percentage. If P4-T7 recorded `COVERAGE DENOMINATOR MISMATCH`, this bullet is marked `- [x]` only on the strength of the changed-line clauses and the check-off text must say so explicitly. + +- [x] [P5-T8] Mirror the issue update. Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/issue-updates/issue-584.2026-09-02T09-02.md` containing `Timestamp:`, the exact text intended for issue #584, and `PostedAs:` set to `comment`, `body`, or `unknown`. If posting is blocked (for example `gh` is unavailable), begin the file with a `POSTING BLOCKED` header and the reason. Acceptance: the artifact exists and carries a `PostedAs:` line or a `POSTING BLOCKED` header; the plan does not halt on an unavailable `gh`. + +- [x] [P5-T9] Commit the change. Run: + + ```text + git add -- UtilitiesCS/Threading/UiThread.cs UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584 + git commit -m "" -- UtilitiesCS/Threading/UiThread.cs UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584 + ``` + + where `` summarises the accessor contract change and names issue #584. Acceptance: `git log -1 --name-only` lists all six source paths — `UtilitiesCS/Threading/UiThread.cs`, `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`, `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`, `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs`, and `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` — and lists no path under .claude/, .codex/, .agents/, or config/. + + The sixth path is double-quoted in both spans because it contains a space. Unquoted, `git` would receive `QuickFiler.Test/Helper` and `Classes/EmailMoveMonitorTests.cs` as two pathspecs, neither of which matches anything, and the `git add` would fail with an unmatched-pathspec error rather than silently omitting the file — a loud failure, but one that quoting avoids entirely. + + The commit carries the same explicit pathspec as the `git add`, rather than being a bare `git commit` over the whole index. A bare commit would commit everything staged, and P3-T4 already ran `git add -A -- UtilitiesCS UtilitiesCS.Test`, which stages every modified or untracked path under those two directories rather than only this plan's own. The explicit-pathspec commit form is what actually bounds the committed footprint to the enumerated paths; the `git add` is retained ahead of it because a pathspec commit only accepts paths already known to git, so the evidence artifacts this plan creates under the feature folder must be staged first. P5-T10's porcelain span remains the backstop that reports anything left behind. + +- [x] [P5-T10] Verify the committed footprint. Run: + + ```text + git diff --name-status 87cb4df338322844abfa580abea14df77e738e5c..HEAD + git status --porcelain -- UtilitiesCS UtilitiesCS.Test "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584 + ``` + + The porcelain pathspec names the sixth owned file explicitly rather than the whole `QuickFiler.Test` directory. Scoping to the one file is deliberate: it is the only path this plan writes under that directory, and a directory-wide pathspec would let unrelated ambient state elsewhere in `QuickFiler.Test` make this gate unsatisfiable, which is the same failure mode the existing scoping of this span exists to avoid. + + Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/other/p5-t10-footprint.md` with `Timestamp:`, `Command:` (both), `EXIT_CODE:` (per command), `Output Summary:` quoting both outputs verbatim. Acceptance: the anchored name-status diff lists exactly these six source paths and no other source path — `UtilitiesCS/Threading/UiThread.cs`, `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`, `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`, `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs`, `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` — apart from paths under `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/`; it lists no path in the `BASELINE_FORMAT_DRIFT_SET` recorded in P0-T7, because P4-T1's formatter write scope was restricted to the six owned paths and therefore rewrote no unowned path at all; it lists no path under .claude/, .codex/, .agents/, config/blast-radius.json, or config/orchestration-routing.json; and the porcelain output lists `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/plan.2026-09-02T09-02.md` (modified by this plan's own check-off of P5-T9) and no path outside `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/`. P5-T11 commits both the plan file and this artifact together. + + The porcelain clause is worded that way because of the state this task actually runs in. `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584` is inside this command's pathspec and contains this plan file; P5-T9 commits that whole folder, and the check-off protocol in `acceptance-criteria-tracking` marks P5-T9 `[x]` in this plan file once P5-T9 completes, so by the time this task runs the plan file is modified relative to that commit. `evidence/other/p5-t10-footprint.md` does not yet exist when these two commands run, because this task writes it from their output. An acceptance demanding that the porcelain output list "only this artifact" is therefore unsatisfiable in both directions at once, and is replaced by the clause above. + + The `git status --porcelain` span is the companion the name-status diff needs so that a file created but not yet tracked cannot escape the check. Here the two-dot form is correct and not vacuous, because P5-T9 has already committed, so `HEAD` is no longer identical to BASE. + +- [x] [P5-T11] Commit the footprint artifact and confirm a clean tree. Run: + + ```text + git add -- docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584 + git commit -m "docs(584): record committed-footprint evidence" -- docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584 + git status --porcelain -- UtilitiesCS UtilitiesCS.Test "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584 + ``` + + Acceptance: the final porcelain command prints nothing. The status pathspec is scoped so that unrelated tracked state elsewhere in the worktree, including .claude/agent-memory/, cannot make this gate unsatisfiable or falsely satisfied. Revision round 15 added the sixth owned file to that pathspec by name rather than adding the whole `QuickFiler.Test` directory, for the same reason: naming the one file this plan writes keeps ambient state in that assembly out of the gate while still catching an uncommitted change to the file this plan owns there. The `git commit` carries the same explicit pathspec as the `git add`, for the reason stated in P5-T9: P3-T4 ran `git add -A -- UtilitiesCS UtilitiesCS.Test`, so a bare `git commit` here would commit whatever that left staged under those two directories, and P5-T10's name-status diff — the one span that would report it — has already run by this point. + +- [x] [P5-T12] Write the acceptance-criteria status summary. Write `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/other/p5-t12-ac-status-summary.md` with `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` listing AC1 through AC7 with, for each, its check state in `spec.md` and its evidence artifact path. Then run: + + ```text + git add -- docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584 + git commit -m "docs(584): record acceptance-criteria status summary" -- docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584 + git status --porcelain -- UtilitiesCS UtilitiesCS.Test "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584 + ``` + + Acceptance: all seven AC identifiers appear exactly once each in the artifact, every one is recorded as checked, every named artifact path exists on disk, and the final porcelain command prints nothing. The commit carries the same explicit pathspec as the `git add`, for the reason given in P5-T11. + +- [x] [P5-T13] Commit the plan's own final check-off state and confirm the tree is clean. Mark every remaining task in this plan file as `[x]`, including this task itself, then run: + + ```text + git add -- docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584 + git commit -m "docs(584): record final plan and acceptance-criteria check-off state" -- docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584 + git status --porcelain -- UtilitiesCS UtilitiesCS.Test "QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs" docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584 + ``` + + Acceptance: the final `git status --porcelain` command prints nothing. The commit carries the same explicit pathspec as the `git add`, matching P5-T9, P5-T11, and P5-T12; without it this commit would sweep anything still staged from P3-T4's `git add -A -- UtilitiesCS UtilitiesCS.Test`. + + This task exists because `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/plan.2026-09-02T09-02.md` is itself inside the pathspec of P5-T11's and P5-T12's porcelain checks, and its own check-off necessarily happens after those checks have already run. Without this task the plan terminates with its own plan file modified and uncommitted, which contradicts the repository expectation that work is not complete until the tree is clean. Marking this task `[x]` before its own commit is deliberate and is what makes the commit capture the plan's terminal state; the ordering is stated here so it is not read as a check-off ahead of evidence. + +--- + +## Planner Adversarial Self-Review + +SELF-REVIEW: RE-DERIVED THIS PASS + +Citations re-derived directly against the working tree in this pass: + +1. `UtilitiesCS/Threading/UiThread.cs` — read in full (163 lines). Line 1 is the nullable-enable directive; lines 135-139 are the `Dispatcher` property; line 140 is `private static Dispatcher _dispatcher = null!;` with the trailing comment. Confirms the defect and the exact replacement region. +2. `UtilitiesCS/Threading/UiThread.cs` — lines 113-125 (`UiSyncContext`) and 147-158 (`AutoScaleFactor`) re-derived as the lazy-initialising siblings, confirming the omission on `Dispatcher` is an inconsistency. +3. `UtilitiesCS/Threading/UiThread.cs` line 61 — `Dispatcher = _syncContextForm.UiDispatcher;` is the sole writer through the private setter; re-derived to confirm the setter's non-nullable parameter type is unaffected by the field retyping. +4. UtilitiesCS/Threading/SyncContextForm.cs line 30 — `public Dispatcher UiDispatcher { get; private set; } = null!;` re-derived, confirming the value assigned at `UiThread.cs:61` is statically non-null and introduces no `CS8604` at that assignment. +5. UtilitiesCS/Threading/ProgressTrackerAsync.cs — read in full (109 lines). Line 33 is `UiDispatcher = UiThread.Dispatcher;` and line 35 is `await UiDispatcher.InvokeAsync(`. AC3's "no edit required" conclusion is re-derived from the tree, not carried from the research document, and is confirmed. +6. UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs lines 57-67 — the existing `InvalidOperationException` precedent re-derived verbatim; lines 45-46 confirm the default fallback provider is `() => UtilitiesCS.UiThread.Dispatcher`. +7. `UtilitiesCS.Test/Threading/UiThread_Tests.cs` — read in full (104 lines). Namespace `UtilitiesCS.Test.Threading`; four using directives; one `[TestClass]` (`SynchronizationContextAwaiter_Tests`); no `System.Reflection` using. Establishes the 500-line headroom decision and the exact using-block edit. +8. UtilitiesCS.Test/UtilitiesCS.Test.csproj line 494 — `` re-derived (line number corrected in revision round 11 against the post-merge tree; it was 493 before the reconciliation merge), establishing that reusing the existing test file requires no project-file edit. +9. `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` lines 141-186 — `DispatcherField`, `ForceDispatcherNull`, and `RestoreDispatcher` re-derived, giving the exact reflection idiom (`typeof(UiThread).GetField("_dispatcher", BindingFlags.NonPublic | BindingFlags.Static)`) the new test mirrors. Lines 248-289 re-derived: the at-risk test asserts `NotThrow` and `callCount == 0` with no exception-type assertion, so the type change is invisible to it. +10. `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` lines 126-190 — re-derived: this test installs a real `Dispatcher.CurrentDispatcher` into `_dispatcher` before calling `InitializeAsync()` and restores in `finally`, so it exercises only the non-null path and is unaffected. +11. UtilitiesCS.Test/OutlookObjects/Folder/WpfDispatcherYieldTests.cs lines 117-142 — re-derived: `YieldAsync_WithoutDispatcher_RemainsStrict` injects two null-returning provider delegates and asserts the exception TYPE only (`ThrowAsync()`, no `WithMessage`). The real `UiThread.Dispatcher` property is never read by any of this class's tests. +12. UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderTreeServiceConcurrencyTests.cs line 55 — re-derived: `new WpfDispatcherYield()` uses the parameterless constructor, so its fallback provider is the real property. Sibling re-check outcome: the message text differs after the fix but the exception type does not, and this test asserts neither; P3-T3 verifies empirically rather than resting on this reading. +13. UtilitiesCS.Test/Properties/AssemblyInfo.cs line 18 — the assembly-level `Parallelize(` attribute re-derived, which is the justification for the do-not-parallelize attribute on the new class and on the three existing classes P1-T5 touches. +14. **Sibling finding not present in `spec.md` or the research trail:** QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs lines 26 and 64 construct `new WpfUiDispatcher()`, whose provider closes over `UiThread.Dispatcher` (UtilitiesCS/Threading/WpfUiDispatcher.cs lines 24-25 and 37, re-derived). Line 26's constructor only captures the lambda and never invokes it; line 64 runs inside a `UiThreadDispatcherFixture` transaction that installs a real dispatcher first. Neither is expected to change outcome, but the class is now baselined in P0-T11 and verified in P3-T6 rather than left unexamined. +15. .csharpierignore — re-derived: it excludes `**/evidence/**`, `*.cobertura.xml`, `*.trx`, `*.csproj`, `*.props`, `*.targets`. Evidence artifacts written by this plan therefore cannot fail the format gate. +16. .gitignore lines 39 and 144-145 — re-derived: `[Tt]est[Rr]esult*/` ignores the `TestResults/` subdirectories this plan writes, and `coverage/*` (except `coverage/.gitkeep`) ignores the Cobertura outputs. Neither enters the committed footprint asserted in P5-T10. See citation 30 for why both patterns require the forward-slash spelling to take effect. +17. `coverage.config` — re-derived: it excludes only third-party module paths (Deedle, FSharp, Castle.Core, FluentAssertions, Moq, Microsoft.Testing, MSTest). No first-party production path is excluded, so `UtilitiesCS/Threading/UiThread.cs` is in the coverage denominator and P4-T7's class-node lookup can resolve. +18. `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md` lines 249-272 — the AC1..AC7 bullets re-derived verbatim, giving the exact bullet-opening text each P5 check-off task must match. Line range corrected in the revision round 9 pass; see "Citations re-derived in the revision pass of 2026-09-02 (revision round 9, backtick-removal presentation fix)" below for the re-derivation, which supersedes this entry's round-1 "lines 234-257" reading — that reading predated the `## Write Set` section inserted at spec.md lines 77-86. +19. `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/issue.md` line 8 — the promotion-time merge base `5ebaaf105d8241f309f704d1ff90af2e32e5a6c1`, re-read in revision round 11 and found unchanged in that file. It is NO LONGER the anchor for any `git diff` in this plan: the reconciliation merge described in the BASE re-anchor note moved the merge base to `87cb4df338322844abfa580abea14df77e738e5c`, which was re-derived in round 11 from this worktree's git log (a `merge origin/main` producing `a2ef517b`, so `origin/main` is an ancestor of HEAD) together with the recorded `origin/main` tip, and which is the value every `git diff` in this plan now carries. `issue.md` is outside this plan's write set and is deliberately left unedited. +20. CLAUDE.md "C# Toolchain" section and .claude/rules/general-unit-test.md / .claude/rules/quality-tiers.md coverage sections — re-derived, producing the recorded 80/90 versus 85/75 conflict and the rank-1 resolution stated in "Threshold reconciliation" above. + +### Citations re-derived in the revision pass of 2026-09-02 (revision round 9, backtick-removal presentation fix) + +Every citation below was re-derived against the working tree in this revision pass by reading the +named file. The tree was at the then-current merge base `5ebaaf105d8241f309f704d1ff90af2e32e5a6c1` +with no commit made by this plan; that value was superseded in revision round 11 (see the BASE +re-anchor note above) and is recorded here as a historical observation, not as the current BASE. +This round changed no command line, no task ID, no write-target file, and no evidence path. It +changed only: the removal of backtick-wrapping around scope-exclusion, precedent, and +context-reference file-path mentions in `spec.md` and this plan (round 9 itself, applied before this +pass); one incomplete backtick-removal left behind by that round at spec.md line 168 (Defect 1, +corrected in this pass); the plan's own status and version metadata; and citation 18 above, which the +`## Write Set` section insertion had already made stale before round 9 and which round 9's edits did +not touch. The prose corrections are enumerated in "Sibling regions re-checked in the revision round +9 pass" below. + +58. `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md` lines 77-86 + — the `## Write Set` section re-derived this pass. It lists exactly five paths, in this order: + `UtilitiesCS/Threading/UiThread.cs`, `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, + `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`, + `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`, and + `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs`. This plan's own "Scope: files this plan's + diff writes" list was re-read alongside it and names the same five paths in + the same order. Superseded by revision round 15: both lists now carry a sixth path, + `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs`, and both were re-read against each + other again in that round (see the round-15 sibling-region section). This entry records the + round-9 observation of a superseded state and is retained for the audit trail. +59. `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md` lines + 249-272 — the then-current position of the `## Acceptance Criteria` block re-derived directly from + the file: the heading sat at line 249, the AC1-AC7 bullets ran through line 272, and line 274 was + `## Risks & Mitigations`. This was the corrected range that superseded citation 18's round-1 + "lines 234-257" reading, which predated the `## Write Set` section this pass re-derived as + citation 58. Superseded again by revision round 15, which amended AC4 and three other sections of + that file; the current range is re-derived as citation 79 below. +60. `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md` line 259 — + the AC3 bullet's opening text, "AC3: UtilitiesCS/Threading/ProgressTrackerAsync.cs is left + unmodified", re-read this pass and compared character-for-character against P5-T3's quotation of + the same opening text (above). The two match exactly; no correction to P5-T3 was required. + +### Citations re-derived in the revision pass of 2026-09-02 (preflight round 2) + +Every citation below was re-derived against the working tree in this revision pass. Prior-round +verification of the same files is not relied on, because the file set this plan writes changed in +this pass and a prior pass observed a superseded scope. + +21. `git grep -n '"_dispatcher"' -- UtilitiesCS.Test` returns exactly three lines: + `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs:144`, + `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs:422`, and + `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs:138`. These are the complete set of + files in the assembly that reflect over the process-global backing field, and every one of them + writes it via `SetValue`. This is the derivation behind P0-T13, P1-T5, and the expanded scope. +22. `[DoNotParallelize]` occurs in 18 files under `UtilitiesCS.Test`, re-derived this pass. None of + the three files in citation 21 is among them, so all three are in the parallel bucket at BASE. +23. UtilitiesCS.Test/Threading/CurrentStoreContextTests.cs lines 15-16 — `[TestClass]` then + `[DoNotParallelize]` on the following line, re-derived as the repository's prevailing two-line + idiom that P1-T5 follows for two of the three files. +24. `[TestClass` occurs exactly once in each of the four files P1-T5 and P1-T2 touch, at + `UtilitiesCS.Test/Threading/UiThread_Tests.cs:8`, + `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs:28`, + `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs:13`, and + `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs:14`. Re-derived this pass, which is what + makes P0-T13's third acceptance clause a real four-line expectation rather than a lower bound. +25. Line counts re-derived this pass: `UtilitiesCS/Threading/UiThread.cs` 163, + `UtilitiesCS.Test/Threading/UiThread_Tests.cs` 104, + `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` 347, + `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` 205, and + `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` 514. **Sibling finding surfaced by this + pass and not present in the prior round:** the last of these already exceeds the 500-line limit + in .claude/rules/general-code-change.md at BASE. A naive "all five files under 500" acceptance + would have been unsatisfiable. P1-T5 therefore uses the combined attribute list for that one + file, and P2-T3 and P4-T8 record the overage as pre-existing and gate on non-growth instead. +26. `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` line 12 declares `namespace + UtilitiesCS.Test`, not `UtilitiesCS.Test.Threading`. Re-derived this pass; this is why P3-T3's + added filter term is `FullyQualifiedName~UtilitiesCS.Test.ProgressTracker_Tests` and not a + `.Threading.`-qualified spelling that would have matched nothing. +27. `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` lines 411-432 — + `Initialize_WithCurrentDispatcherAndScreen_InitializesViewerAndUpdatesUi` is the + `[STATestMethod]` that writes `UiThread._dispatcher` at line 432 and restores it in a `finally`. + Re-derived this pass as the named test P3-T3 requires in its executed set. +28. `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` lines 150, 152, and 162 — the + `SetValue` write, the `InitializeAsync()` call that reads `UiThread.Dispatcher` downstream, and + the `Dispatcher.PushFrame(frame)` pump. Re-derived this pass; this is the concrete mechanism by + which a concurrent writer could either observe the post-fix `InvalidOperationException` or leave + the pumped frame without an exit, and it is what P1-T5 removes from the parallel bucket. +29. UtilitiesCS.Test/UtilitiesCS.Test.csproj lines 477, 479, 490, and 494 — `Compile Include` + entries for `Threading\ProgressTracker_Tests.cs`, `Threading\ProgressTrackerAsync_Tests.cs`, + `Threading\IdleAsyncQueue_Tests.cs`, and `Threading\UiThread_Tests.cs`. Line numbers corrected in + revision round 11 against the post-merge tree, where each entry sits one line lower than the + round-2 reading of 476, 478, 489, and 493; all four entries are still present, so this citation's + conclusion is unchanged. All four files are already wired and already tracked, so the expanded scope requires no + `.csproj` edit and the single-ref `git diff` form used in P3-T5 and P4-T7 is not blind to any + file this plan writes. +30. .gitignore line 39 `[Tt]est[Rr]esult*/` and line 144 `coverage/*` — re-derived this pass. + Both are directory-scoped patterns. A file written to the worktree root as + `TestResultsp3-t5-source.diff` or `coveragep0-t10.cobertura.xml`, which is what a POSIX shell + produces from an unquoted backslash-spelled path, matches neither. This is the derivation behind + the forward-slash rewrite of every path in this plan's command blocks. +31. `coverage.config` lines 12-22 — the `ModulePaths/Exclude` list names Deedle, FSharp, + Castle.Core, FluentAssertions, Moq, Microsoft.Testing, and MSTest only. Re-derived this pass. No + first-party assembly is excluded, so both `UtilitiesCS` and `UtilitiesCS.Test` contribute to + `lines-valid`. That is why P4-T7's denominator clause is a bounded band rather than an equality: + the production and test lines this plan adds legitimately move the denominator. +32. `UtilitiesCS/Threading/UiThread.cs` lines 135-140 re-read in this pass, unchanged from the prior + round: `get => _dispatcher;` at line 137 and + `private static Dispatcher _dispatcher = null!; // set in Initialize() before any access` at + line 140. The replacement text quoted in this plan still matches the tree it will replace. + +### Citations re-derived in the revision pass of 2026-09-02 (preflight round 3) + +Every citation below was re-derived against the working tree in this revision pass. The tree is +clean at the then-current merge base `5ebaaf105d8241f309f704d1ff90af2e32e5a6c1` with no commit made +by this plan, so these observations describe the state the plan's first task will actually run in. +That merge-base value was superseded in revision round 11 (see the BASE re-anchor note above) and is +recorded here as a historical observation; the reconciliation merge changed none of the files cited +below, so the observations themselves still hold. + +33. Physical line counts re-derived this pass by counting every line of each file: + `UtilitiesCS/Threading/UiThread.cs` 163, `UtilitiesCS.Test/Threading/UiThread_Tests.cs` 104, + `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` 347, + `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` 205, and + `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` 514. These are the five values P0-T13, + P2-T3, and P4-T8 assert, and they are the values `wc -l` reports. +34. Blank-line counts re-derived this pass: `UtilitiesCS.Test/Threading/UiThread_Tests.cs` has 17 + blank lines out of 104 and `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` has 92 out of + 514. `Get-Content | Measure-Object -Line` does not count blank lines, so it would have reported + 87 and 422 for those two files against a plan asserting 104 and 514. That is the derivation + behind the `wc -l` idiom now fixed in P0-T13, P2-T3, and P4-T8, and behind P0-T13's explicit + prohibition on substituting `Measure-Object -Line`. +35. global.json lines 6-10 — `"paths": [".dotnet-sdk", "$host$"]` with the error message `The + repo-local .NET SDK is missing. Run ./scripts/vscode/Install-RepoDotNetSdk.ps1 from the + repository root...`. Re-derived this pass. `.dotnet-sdk/` does NOT exist in this worktree, also + re-derived this pass, so every `dotnet` command fails until the bootstrap in P0-T5 runs. +36. `scripts/vscode/Install-RepoDotNetSdk.ps1` — re-derived this pass. Line 3 defaults `$Version` to + `8.0.205`, which is why P0-T5's acceptance reads "a version beginning `8.0.2`". This script is + NOT invoked by the plan; the citation records the two values P0-T5's POSIX bootstrap reproduces. +37. `C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe` — confirmed present at + that path this pass. This is what makes P0-T5 step 2 a real command rather than a documented + shape. +38. .github/workflows/ci.yml line 21-23 delegates format checking to + .github/workflows/_format-check.yml, whose line 41 runs `dotnet csharpier check .` after + `dotnet tool restore` on line 37. Re-derived this pass. This is the CI-parity command P4-T2 + runs repo-wide, and it is why restricting P4-T1's formatter WRITE scope to the five owned paths + does not narrow the repository-wide format gate. +39. `git grep -n '"_dispatcher"' -- UtilitiesCS.Test` re-derived again in this pass and unchanged: + `IdleAsyncQueue_Tests.cs:144`, `ProgressTracker_Tests.cs:422`, + `ProgressTrackerAsync_Tests.cs:138`. P0-T13's first acceptance clause and P1-T5's edit sites are + still correct after this round's edits. +40. `UtilitiesCS/Threading/UiThread.cs` lines 135-140 re-read again in this pass and unchanged: + `get => _dispatcher;` at line 137 and + `private static Dispatcher _dispatcher = null!; // set in Initialize() before any access` at + line 140. `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` line 14 is still the sole + `[TestClass]` in that file. + +### Citations re-derived in the revision pass of 2026-09-02 (revision round 5, applying preflight findings C1-C3 and N1-N2) + +Every citation below was re-derived against the working tree in this revision pass by reading the +named file. The tree was at the then-current merge base `5ebaaf105d8241f309f704d1ff90af2e32e5a6c1` +with no commit made by this plan, so these observations describe the state the plan's first task will +actually run in. That merge-base value was superseded in revision round 11 (see the BASE re-anchor +note above) and is recorded here as a historical observation. + +41. .gitignore line 350 is `.dotnet*/`. Re-derived this pass by searching the file for `dotnet`, + which returns exactly two lines: line 143, a comment about `dotnet-coverage` Cobertura output, + and line 350, the pattern itself. `.dotnet-sdk/` is therefore already ignored, so P0-T5 step 1's + bootstrap cannot appear in any porcelain or diff gate later in this plan. +42. `scripts/vscode/Install-RepoDotNetSdk.ps1` line 26 builds the download URL + `https://builds.dotnet.microsoft.com/dotnet/Sdk/$Version/dotnet-sdk-$Version-win-$Architecture.zip`, + line 3 defaults `$Version` to `8.0.205`, and lines 5-6 default `$Architecture` to `x64`. + Substituting those defaults gives + `https://builds.dotnet.microsoft.com/dotnet/Sdk/8.0.205/dotnet-sdk-8.0.205-win-x64.zip`, which is + character-for-character the URL P0-T5 step 1 passes to `curl`. Re-derived this pass by reading the + file in full. +43. `scripts/vscode/Install-RepoDotNetSdk.ps1` line 36 resolves the default install directory as + `Join-Path $PSScriptRoot '..\..\.dotnet-sdk'`, which from `scripts/vscode/` is `.dotnet-sdk` at + the worktree root — the same destination P0-T5 step 1's `unzip -d .dotnet-sdk` writes to. + Line 56 shows the script's own success marker is `.dotnet-sdk/sdk/8.0.205`, which the extracted + archive creates. Re-derived this pass. +44. global.json lines 2-11 re-derived this pass: `"version": "8.0.205"`, `"rollForward": + "latestFeature"`, `"paths": [".dotnet-sdk", "$host$"]`, and the `errorMessage` beginning `The + repo-local .NET SDK is missing`. This is why P0-T5's acceptance clause — post-bootstrap + `dotnet --version` beginning `8.0.2` and exiting 0 — is unchanged by replacing the PowerShell + bootstrap with the POSIX one: both install the same pinned version into the same directory. + +**Environment measurements applied in this pass, recorded as such and not as tree citations.** The +first three shell behaviours in "Shell constraints measured in this worktree" — the refusal of any +command named `pwsh`, the refusal of any command whose NAME is a quoted absolute path, and the +failure of bare `msbuild` against the success of `msbuild.exe` — were measured by running the +commands in this +worktree during preflight round 3. The fourth (MSYS path conversion of forward-slash switches) and +the missing-NuGet-restore failure recorded in P0-T5 step 4 were measured the same way during +preflight round 4. The fifth (a green `vstest.console.exe` run printing no `Failed:` and no +`Skipped:` line, and the TRX `` element carrying both counts) was measured the same +way during preflight round 5, across four green runs of 4783, 1312, 41, and 2 tests. The planner has +no shell in its tool surface and therefore did not +re-run them in this pass; they are carried as reported measurements with their verbatim outputs, and +they are labelled as measurements rather than as citations to the repository tree. The same applies +to the `vswhere.exe` PATH-prefix invocation in P0-T5 step 2 and the path it printed. Two consequences +of those measurements that this pass derived rather than measured are stated explicitly in the plan +so a reviewer can check them: that a POSIX shell splits `PATH` on `:` and so requires the +`/c/...` spelling for the `PATH=` prefix, and that `unzip` availability in this shell is unmeasured, +which is why P0-T5 step 1 carries a fail-closed `SDK_BOOTSTRAP: BLOCKED` clause covering a failure of +any of its four commands. + +### Citations re-derived in the revision pass of 2026-09-02 (revision round 6, applying preflight round 4 findings E1-E3 and N-1/N-2/N-3) + +Every citation below was re-derived against the working tree in this revision pass by reading or +searching the named file. The tree was at the then-current merge base +`5ebaaf105d8241f309f704d1ff90af2e32e5a6c1` with no commit made by this plan; that value was superseded +in revision round 11 (see the BASE re-anchor note above) and is recorded here as a historical +observation. The worktree now additionally contains gitignored `.dotnet-sdk/`, `packages/`, and +`Debug` build output left in place by the round-4 reviewer; none of them is a tracked file and none of +them changes any citation below. + +45. .github/workflows/_build-analyzers.yml — line 17 sets `SOLUTION_PATH: TaskMaster.sln`, line 45 + runs `nuget restore $env:SOLUTION_PATH` as the step named "Restore solution", and line 50 runs the + analyzer `msbuild` immediately after it. Read in full this pass. .github/workflows/_build-nullable.yml + line 45 and .github/workflows/_mstest-coverage.yml line 45 carry the identical restore step. + This is the CI-parity citation for the new P0-T5 step 4: every CI gate that builds this solution + restores NuGet packages first. +46. `packages.config` exists in 18 project directories across this solution, enumerated this pass: + `QuickFiler`, `QuickFiler.Test`, `SVGControl`, `SVGControl.Test`, `Tags`, `Tags.Test`, + `TaskMaster`, `TaskMaster.Test`, `TaskTree`, `TaskTree.Test`, `TaskVisualization`, + `TaskVisualization.Test`, `ToDoModel`, `ToDoModel.Test`, `UtilitiesCS`, `UtilitiesCS.Test`, + `VBFunctions`, and `VBFunctions.Test`. Every one of them is a restore target of P0-T5 step 4, and + the two that matter directly to this plan — `UtilitiesCS/packages.config` and + `UtilitiesCS.Test/packages.config` — are among them. +47. .gitignore line 191 is `**/[Pp]ackages/*` and line 193 is `!**/[Pp]ackages/build/`. Re-derived + this pass by reading lines 185-216. The restore output under `packages/` is therefore ignored, with + the single exception of a `packages/build/` directory, which does not exist in this worktree after + a completed restore (also re-derived this pass). P0-T5 step 4 states what to do if a fresh-worktree + restore produces one. +48. dotnet-tools.json exists at the worktree root and no `.config/` directory exists. Re-derived this + pass. This is why P0-T5 step 4's rationale names the root manifest rather than the conventional + `.config/` location. +49. `UtilitiesCS/Threading/UiThread.cs` lines 135-140 re-read again in this pass and still unchanged: + the property opens at line 135, `get => _dispatcher;` is line 137, `private set => _dispatcher = value;` + is line 138, and line 140 is + `private static Dispatcher _dispatcher = null!; // set in Initialize() before any access`. + The replacement text quoted in "Exact source text this plan will create" still matches the region it + replaces, and P0-T2's five BLOCKED-clause values are still the values the tree reports. +50. `.dotnet-sdk/dotnet.exe` is present in this worktree. Re-derived this pass. .gitignore line 350 + `.dotnet*/` ignores it (citation 41). Its presence is why P0-T5's `SDK_BOOTSTRAP:` field now accepts + a `NOT REQUIRED` value: in this worktree the first `dotnet --version` probe succeeds and the + four-command bootstrap never runs, so there is no post-bootstrap reading to record. In a fresh + worktree the bootstrap does run and the first form of the field applies. + +### Citations re-derived in the revision pass of 2026-09-02 (revision round 7, applying preflight round 5 finding F1) + +Every citation below was re-derived against the working tree in this revision pass by reading or +searching the named file. The tree was at the then-current merge base +`5ebaaf105d8241f309f704d1ff90af2e32e5a6c1` with no commit made by this plan; that value was superseded +in revision round 11 (see the BASE re-anchor note above) and is recorded here as a historical +observation. This round changed no command line, no task ID, no write target, and no evidence path; it +changed only how seven tasks source two numeric fields, plus one new constraint entry and its +redaction rule. (The `Skipped` half of that sourcing rule was itself wrong and was corrected in +revision round 8; see the round-8 section below.) + +51. QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs — searched this pass for `[TestMethod]` and + for public method declarations. The file declares exactly two `[TestMethod]` attributes, at lines + 23 and 48, whose methods are `public void Construction_YieldsAnIUiDispatcher()` on line 24 and + `public async Task Invoke_InvokeAsync_BeginInvoke_ExecuteDelegateOnDispatcherThread()` on line 50. + This is the derivation behind P3-T6's `Total tests` of 2 and behind the two names its acceptance + requires to appear as passing in the console output; both names are re-derived here rather than + carried forward from the round-1 citation, which recorded the two `new WpfUiDispatcher()` + construction sites (lines 26 and 64) and not the method declarations. +52. .gitignore lines 33-50 read this pass. Line 39 is `[Tt]est[Rr]esult*/` and is directory-scoped, + so `TestResults/p0-t10/`, `TestResults/p0-t11/`, `TestResults/p3-t2/`, `TestResults/p3-t3/`, + `TestResults/p3-t6/`, `TestResults/p4-t5/`, and `TestResults/p4-t6/` — and every `.trx` file + inside them — are ignored. Line 44 is the unrelated NUnit pattern `TestResult.xml`. Reading the + TRX files therefore adds nothing to P5-T10's name-status diff or to any porcelain gate in this + plan. +53. .csharpierignore read in full this pass (15 lines). Line 8 is `*.trx`, alongside + `**/evidence/**` on line 4, `*.cobertura.xml` on line 5, `*.coverage` on line 6, `*.coveragexml` + on line 7, and the project-file exclusions on lines 12-14. The TRX files this round's rule reads + are outside the format gate, so P4-T2's repo-wide `csharpier check .` cannot report them. + +### Citations re-derived in the revision pass of 2026-09-02 (revision round 8, applying preflight round 6 finding G1 and non-blocking findings O1-O4) + +Every citation below was re-derived against the working tree in this revision pass by reading the +named file. The tree was at the then-current merge base `5ebaaf105d8241f309f704d1ff90af2e32e5a6c1` +with no commit made by this plan; that value was superseded in revision round 11 (see the BASE +re-anchor note above) and is recorded here as a historical observation. +This round changed no command line, no task ID, no write-target file, and no evidence path. It +changed only the stated SOURCE of the `Skipped` field, the placement of the TRX-collision tie-break +rule, one redaction clause, the plan's own status and version metadata, and the prose corrections +enumerated in "Sibling regions re-checked in the revision round 8 pass" below. + +54. `UtilitiesCS/Threading/UiThread.cs` lines 130-145 re-read this pass and still unchanged: the + `Dispatcher` property opens at line 135, `get => _dispatcher;` is line 137, + `private set => _dispatcher = value;` is line 138, and line 140 is + `private static Dispatcher _dispatcher = null!; // set in Initialize() before any access`. The + replacement text in "Exact source text this plan will create" still matches the region it + replaces, and P0-T2's five BLOCKED-clause values are still the values the tree reports. +55. .csharpierignore read in full again this pass (15 lines). Line 8 is `*.trx`, line 4 is + `**/evidence/**`, line 5 is `*.cobertura.xml`. Every TRX this round's rewritten sourcing rule + reads remains outside P4-T2's repo-wide `csharpier check .`, including a second `.trx` left in a + results directory by a re-run, because the pattern is extension-scoped and not name-scoped. +56. .gitignore lines 33-48 re-read this pass. Line 39 is `[Tt]est[Rr]esult*/` and is + directory-scoped, so every file inside `TestResults//` is ignored however many `.trx` files + a re-run leaves there. Line 44 is the unrelated NUnit pattern `TestResult.xml`. The TRX selection + rule added this round therefore adds nothing to P5-T10's name-status diff or to any porcelain + gate in this plan. +57. QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs re-searched this pass for `[TestMethod]` + and for public method declarations: exactly two `[TestMethod]` attributes, at lines 23 and 48, + on `public void Construction_YieldsAnIUiDispatcher()` at line 24 and + `public async Task Invoke_InvokeAsync_BeginInvoke_ExecuteDelegateOnDispatcherThread()` at line + 50. P3-T6's `Total tests` of 2 and its two by-name clauses are unaffected by this round's edit to + that task, which changed only its TRX-sourcing and redaction prose. + +**Environment measurements applied in this pass, recorded as such and not as tree citations.** The +`` behaviour stated in constraint 5 — that `vstest.console.exe`'s TRX logger populates +only `total`, `executed`, `passed`, and `failed` and hard-codes every other attribute to `0`, so that +a run whose console printed `Skipped: 1` produced a TRX carrying `notExecuted="0"` while +`total` minus `executed` returned the correct `1` — was measured by the preflight round-6 reviewer, +who built a three-test probe assembly (one passing, one failing, one skipped) and ran it through this +plan's exact command shape. The related statement that each of the `Failed:` and `Skipped:` console +lines is printed only when its own counter is non-zero comes from the same probe, which printed both. +The statement that a default TRX filename embeds a timestamp and does not overwrite an existing file +is reported by the same reviewer. The planner has no shell in its tool surface and did not re-run any +of them in this pass; they are carried as reported measurements and are labelled as such rather than +as citations to the repository tree. + +### Citations re-derived in the revision pass of 2026-09-03 (revision round 11, BASE re-anchor after the orchestrator's origin/main reconciliation merge) + +Every citation below was re-derived in this pass against the tree as it stands AFTER the +reconciliation merge, by reading the named file directly. No value in this section is carried forward +from an earlier round: every earlier round observed the pre-merge tree, which is a superseded state. +This round changed no task, no acceptance clause other than the BASE SHA it names, no evidence path, +and no `.csproj` file. It re-anchored the four executable `git diff` commands in the plan (P3-T4, +P3-T5, P4-T7, P5-T10) and the two prose statements of the same command shape (P1-T1, P4-T7), corrected +four `.csproj` line-number citations, and qualified the historical merge-base sentences above. + +61. `.git` worktree log for `agent-a18cc3bc53f9c1d8a`, final entry — `merge origin/main: Merge made + by the 'ort' strategy`, moving HEAD from `98c4ef16` to `a2ef517b`. Re-derived this pass. Because + `origin/main` was merged into HEAD and no commit has been made on `origin/main` since, `origin/main` + is an ancestor of HEAD and the merge base with `origin/main` is `origin/main` itself. +62. Recorded `origin/main` tip — `87cb4df338322844abfa580abea14df77e738e5c`. Re-derived this pass. + Combined with citation 61, this is the derivation of the re-anchored BASE now stated in the header + and carried by every `git diff` in this plan. +63. UtilitiesCS.Test/UtilitiesCS.Test.csproj lines 477, 479, 490, and 494 — re-derived this pass by + reading lines 468-499 of the post-merge file. The four `Compile Include` entries are, in file + order, `Threading\ProgressTracker_Tests.cs` (477), `Threading\ProgressTrackerAsync_Tests.cs` + (479), `Threading\IdleAsyncQueue_Tests.cs` (490), and `Threading\UiThread_Tests.cs` (494). Each + sits exactly one line lower than the pre-merge reading. All four are present, so the "no `.csproj` + edit is required" conclusion in the Scope section, in P3-T5, and in citations 8 and 29 is + unchanged; only the four line numbers moved. +64. `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` — the file still ends at line 514 (line 512 + is `#endregion`, 513 closes the class, 514 closes the namespace). Re-derived this pass, which + confirms the `PRE-EXISTING FILE-SIZE OVERAGE: ... 514` value P0-T13 requires is still the value + the post-merge tree reports and that the overage is correctly attributed to the re-anchored BASE. +65. `UtilitiesCS/Threading/UiThread.cs` lines 133-141 re-read this pass against the post-merge tree + and unchanged: the property opens at line 135, `get => _dispatcher;` is line 137, + `private set => _dispatcher = value;` is line 138, and line 140 is + `private static Dispatcher _dispatcher = null!; // set in Initialize() before any access`. The + replacement text quoted in "Exact source text this plan will create" still matches the region it + replaces, and P0-T2's BLOCKED-clause values are still the values the post-merge tree reports. +66. `UtilitiesCS.Test/Threading/UiThread_Tests.cs` still ends at line 104. Re-read this pass against + the post-merge tree, confirming the 104-line baseline P0-T13 asserts and the 500-line headroom + argument in "Test-file placement decision" are both still correct after the merge. + +**Sibling regions re-checked in this pass.** Three sentences adjacent to the re-anchored spans rested +on a premise the merge invalidated — that HEAD is identical to BASE before P5-T9 commits. That is now +true only across the paths this plan reads. The narrower claim rests on two things: citations 63 +through 66 above, which re-read the post-merge tree directly and found every owned-file citation +either unchanged or shifted only in the `.csproj` line numbers already corrected; and a measured +report from `atomic-executor`, which ran `git diff --name-status 87cb4df338322844abfa580abea14df77e738e5c..HEAD` +in this worktree and observed exactly the four feature-folder documents and zero source paths. The +second of those is a reported measurement, not a planner citation to the tree: the planner has no +shell in its tool surface and could not re-run that command in this pass. It is labelled as such here +for the same reason the environment measurements elsewhere in this section are. The three sentences +(in P3-T4, P3-T5, and P4-T7) are now scoped to the pathspec each one actually governs, so each states +a claim that holds against the post-merge tree. The conclusion each supports — that the two-dot form +would be vacuous and the single-ref or `--cached` form is required — is unchanged. P5-T10's own note +that the two-dot form is correct there, because P5-T9 has already committed, was re-read and needed no +change. P5-T10's acceptance already carves out paths under the feature folder, which is what the four +already-committed feature-folder documents fall under, so the re-anchored diff satisfies it. + +### Citations re-derived in the revision pass of 2026-09-03 (revision round 15, scope widened to a sixth owned file after P4-T6 found a reflective consumer) + +Every citation below was re-derived in this pass against the tree as it stands AFTER the executor +completed P0-T1 through P4-T5, by reading or searching the named file directly. No value in this +section is carried forward from an earlier round, and no value in it is carried forward from the +executor's own report: the executor's account of the failure was directional, and every line number, +count, and file set it named was re-derived here independently. That distinction matters in this round +specifically, because the round exists to repair a gap created by an assertion nobody re-derived. + +67. `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` — read in full this pass. 314 lines. + Namespace `QuickFiler.Helper_Classes.Tests`. Lines 21-22 carry `[TestClass]` then + `[DoNotParallelize]`, so no attribute change is needed for this file and P1-T5's idiom is already + present. Lines 25-31 are the class's explanatory comment. Line 32 is + `private object _capturedDispatcher;`. Lines 33-37 are the + `private static readonly System.Reflection.PropertyInfo DispatcherProperty` declaration and its + `typeof(UiThread).GetProperty("Dispatcher", Public | Static)` initialiser. Line 44 is + `[TestInitialize]`, line 49 is `_capturedDispatcher = DispatcherProperty?.GetValue(null);`, line + 53 is `[TestCleanup]`, line 58 is `object current = DispatcherProperty?.GetValue(null);`, and + line 59 is `current.Should().BeSameAs(_capturedDispatcher);`. The file declares exactly eight + `[TestMethod]` attributes, at lines 87, 107, 134, 147, 176, 200, 234, and 266. It declares no + `using System.Reflection;` — its using block is lines 1-11 — which is why every reflection type it + names is fully qualified and why P2-T4 adds no directive. +68. The same file re-searched this pass for each of the seven tokens AC5's filter matches. None of + `Thread.Sleep`, `Task.Delay`, `SpinWait`, `Retry`, `retries`, `Timeout(`, or `PushFrame` occurs + anywhere in it. The token `Thread` DOES occur, in `Thread.CurrentThread.ManagedThreadId` at line + 273 and in the surrounding body of + `UnhookItem_InvokedFromThreadPoolThread_RunsComAccessOnMarshalTargetThread`, but `Thread.Sleep` + does not. This is the derivation that makes P2-T4's whole-diff token filter — stricter than + P3-T5's added-lines-only filter — satisfiable rather than unsatisfiable. +69. `git grep`-equivalent search of every tracked `.cs` file in the worktree for the literal + `"Dispatcher"` returns exactly five lines in five files, re-derived this pass: + `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` line 35 (the `GetProperty` name + operand), `QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs` line 14, + `UtilitiesCS/Threading/WpfUiDispatcher.cs` line 14, `UtilitiesCS/Threading/ThreadMonitor.cs` line + 25, and `UtilitiesCS/Threading/IUiDispatcher.cs` line 13. The last four are + `` XML documentation cross-references and none is a reflection site. This + is the derivation behind P0-T14's clauses 1 and 3 and behind the claim that exactly one reflective + property read exists in the repository. +70. Search of every tracked `.cs` file for `typeof(UiThread)` returns exactly seven lines in seven + files, re-derived this pass: `UtilitiesCS.Test/Threading/UiThread_Tests.cs` line 127, + `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` line 421, + `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` line 138, + `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` line 144, + `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` line 34, + `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` line 135, and + `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs` line 469. This is the derivation + behind P0-T14's clause 2. +71. **Sibling finding surfaced by citation 70 and not present in any prior round:** + `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs` lines 461-487 were read in full + this pass. `EnterUiContextAsync_WhenUiSyncContextPostsSynchronously_CompletesUsingDefaultAction` + reflects over `UiThread` but takes the field `"_uiSyncContext"` at line 471, not `_dispatcher`, + and restores it in a `finally` at line 485. It is therefore unaffected by P2-T1 and needs no + change. It is recorded here and in P0-T14's clause 2 so that a later reviewer can see it was + examined and excluded rather than missed — which is exactly the treatment + `EmailMoveMonitorTests.cs` did not receive in earlier rounds. +72. `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` line 135 — + `FieldInfo field = typeof(UiThread).GetField(` re-derived this pass. This fixture already uses the + FIELD route, so the tests that install a real dispatcher through it are unaffected by P2-T1's + guard, and P2-T4 moves `EmailMoveMonitorTests.cs` onto the same route its own assembly already + uses elsewhere. +73. `QuickFiler.Test/QuickFiler.Test.csproj` line 206 — + `` re-derived this pass. The sixth + owned file is already wired and already tracked, so P2-T4 requires no project-file edit and the + single-ref `git diff` form P2-T4 uses is not blind to it. +74. `UtilitiesCS/Threading/UiThread.cs` re-read this pass against the POST-P2-T1 tree, which is the + tree P2-T4 will run against and which no earlier round observed. The property now spans lines + 135-148: `get` opens at 137, the `if (_dispatcher is null)` guard is at 139, the + `throw new InvalidOperationException(` is at 141, the message string is at 142, + `return _dispatcher;` is at 145, `private set => _dispatcher = value;` is at 147, and the backing + field is `private static Dispatcher? _dispatcher;` at line 149. Both AC2 evidence values — zero + `null!` and the nullable field declaration — hold against the current tree. This is the citation + that establishes the mechanism P2-T4 works around: the getter throws before returning, so a + `PropertyInfo.GetValue(null)` over it propagates rather than returning null. +75. `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` lines 130-189 re-read this pass. The + `DispatcherField()` helper at lines 142-148 is + `typeof(UiThread).GetField("_dispatcher", BindingFlags.NonPublic | BindingFlags.Static)`, and + `ForceDispatcherNull()` and `RestoreDispatcher(object)` at lines 165-187 are the capture/restore + pair `spec.md`'s Test Strategy names as the repository idiom. P2-T4 mirrors this exact lookup, + differing only in spelling the flags fully qualified because its own file declares no + `using System.Reflection;` (citation 67). +76. `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t11-quickfiler-tests.md` + read in full this pass: `Total tests: 1312`, `Passed: 1312`, `Failed: 0`, `Skipped: 0`, TRX + `total` 1312 and `executed` 1312, and `BASELINE_FAILURE_SET: empty`. This is the concrete figure + P4-T6's restated acceptance now names, re-derived from the artifact rather than taken from the + delegation prompt that reported it. +77. `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t6-quickfiler-tests.md` + read in full this pass: `EXIT_CODE: 1`, `Total tests: 1312`, `Passed: 1304`, `Failed: 8`, and the + eight failing test names, which match one-for-one the eight `[TestMethod]` declarations citation + 67 enumerates. The artifact also records the executed counterfactual — BASE `UiThread.cs` restored + to the working tree gives 8 passed, the fixed file gives 8 failed, same filter and same worktree — + and states that the probe left no residue. The eight names in P4-T6's new clause 5 are quoted from + this artifact and cross-checked against citation 67's `[TestMethod]` count. +79. `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md` re-derived + this pass AFTER this round's amendments to it: `## Scope & Non-Goals` at line 56, `## Write Set` + at line 82 with its six path bullets at lines 86-91, `## Root Cause Analysis` at line 93, + `## Acceptance Criteria` at line 255 with the AC1-AC7 bullets running from line 257 to line 319, + and `## Risks & Mitigations` at line 321. The AC bullet openings each P5 check-off task quotes + were re-read against this state: AC1 at 257, AC2 at 267, AC3 at 275, AC4 at 284, AC5 at 312, AC6 + at 316, AC7 at 318. P5-T4's quoted fragment "AC4: No regression in" still matches AC4's opening + text character-for-character after this round's rewrite of that bullet, which was checked + explicitly rather than assumed, because this round is the one that rewrote it. AC4 is the only + bullet whose check state this round changed, from `- [x]` to `- [ ]`; AC1, AC2, AC3, and AC5 keep + the `- [x]` the executor left, and AC6 and AC7 remain `- [ ]`. +80. The nine test assemblies of this repository re-derived this pass by enumerating the + `*.Test.csproj` files one directory below the worktree root: `QuickFiler.Test`, `SVGControl.Test`, + `Tags.Test`, `TaskMaster.Test`, `TaskTree.Test`, `TaskVisualization.Test`, `ToDoModel.Test`, + `UtilitiesCS.Test`, and `VBFunctions.Test`. This was the pathspec list P0-T14's first two commands + carried when the round 15 pass observed them. Revision round 16 widened the second command's + pathspec to `'*.cs'`, so from that round onward the list above is carried by the first command + only; the citation is retained in its round-15 form above and corrected here rather than rewritten. + +**Sibling regions re-checked in the revision round 15 pass.** + +- **Every pathspec-scoped gate in the plan was enumerated before any edit, and each was decided + individually rather than swept.** The complete set is P0-T13 (`-- UtilitiesCS.Test`), P1-T5 + (`-- UtilitiesCS.Test` and a four-file list), P2-T2 (two spans on `UiThread.cs`), P2-T3 (a five-file + `wc -l`), P3-T4 (`git add -A -- UtilitiesCS UtilitiesCS.Test` plus three spans on + `ProgressTrackerAsync.cs`), P3-T5 (`git diff ... -- UtilitiesCS UtilitiesCS.Test`), P4-T1 (a + five-path formatter write scope and two unscoped porcelain spans), P4-T2 (a five-path exclusion + list), P4-T7 (`-- UtilitiesCS/Threading/UiThread.cs`), P4-T8 (a five-file `wc -l`), P5-T9 (a + five-path `git add` and `git commit`), P5-T10 (a five-path name-status expectation and a scoped + porcelain span), and P5-T11 through P5-T13 (three scoped porcelain spans). Of those, the ones whose + correctness depends on the owned-file set are P4-T1, P4-T2, P4-T8, P5-T9, P5-T10, P5-T11, P5-T12, + and P5-T13, and all eight were widened. The ones that are correct BECAUSE they are narrow — + P0-T13's and P1-T5's `UtilitiesCS.Test` censuses, P2-T2's and P4-T7's single-file spans, P3-T4's + single-file spans — were deliberately NOT widened, and the reason is recorded in the header note: a + new `"_dispatcher"` occurrence in `QuickFiler.Test` does not enter P1-T5's count because that + command's pathspec is `UtilitiesCS.Test`, so P1-T5's already-recorded four-line result stays true. +- **P3-T5 is the one narrow gate whose narrowness became a real gap, and it is closed by a task + rather than by a re-run.** AC5's wording is "anywhere in the diff", and after the widening the diff + includes a file P3-T5's pathspec does not reach. P3-T5 is complete and its artifact describes a + real result over the five files it covers, so it is not reopened; instead P2-T4 carries the + identical seven-token filter over the sixth file and both artifacts are cited by P5-T5 and by AC5's + row in the mapping table. The alternative — widening P3-T5's pathspec and re-running it — was + rejected because it would have required unchecking a Phase 3 task whose recorded result is still + accurate for its own scope. +- **The `Total tests` comparison in P4-T5 was re-checked and needs no change.** Its clause reads + "`Total tests` is greater than or equal to the baseline `Total tests` plus 2", the two being the + tests P1-T2 added to `UtilitiesCS.Test`. P2-T4 adds no test to any assembly and touches no file in + `UtilitiesCS.Test`, so that clause is unaffected by this round. P4-T7's coverage clauses read + `UtilitiesCS/Threading/UiThread.cs` line hits and root-element attributes from a + `UtilitiesCS.Test`-hosted run; a change confined to `QuickFiler.Test` moves neither, so P4-T7 is + unchanged apart from running in the second pass. +- **P3-T6 was re-checked and needs no change.** It runs + `QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs`, a different class in the same assembly as the + sixth owned file. P2-T4 edits neither that class nor anything it references, and P3-T6's recorded + `Failed: 0` therefore still describes the tree. Its two by-name clauses were re-read and are + untouched. +- **The eight failing test names were cross-checked against the file rather than copied.** P4-T6's new + clause 5 lists eight names taken from the first-pass artifact (citation 77); each was matched + against the eight `[TestMethod]` declarations read directly from the file (citation 67). The two + sets agree exactly, which is what establishes that "8 of 8" means every test in the class and not a + subset that happens to number eight. +- **The first-pass P4-T6 artifact would have been destroyed by the re-run, and that was caught in + this pass rather than after the fact.** P4-T6's artifact path is fixed, so a second pass overwrites + it — and that artifact is the only fail-before record for P2-T4. P4-T6's text now requires the + first-pass file to be copied to `evidence/regression-testing/p4-t6-first-pass-failure.md` before the + re-run, the copy is added to AC4's evidence set in both the mapping table and the `AC-MAPPING:` + block, and P5-T4 cites it. Without that copy, AC4's check-off would rest on a pass-after with no + surviving fail-before. +- **AC6's "single final pass" wording was read against the Phase 4 check state, which is what + produced the decision to clear P4-T1 through P4-T5.** Leaving them checked would have let AC6 be + marked on four artifacts that describe a pre-P2-T4 tree. The alternative of adding a second set of + Phase 4 task IDs was rejected because task IDs must be sequential in file order, so a re-run task + could not be placed where it must execute. +- **The write-target set was re-read after every edit in this round.** It is now six paths: + `UtilitiesCS/Threading/UiThread.cs`, `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, + `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`, + `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`, + `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs`, and + `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs`. + UtilitiesCS/Threading/ProgressTrackerAsync.cs remains outside it, as do + UtilitiesCS.Test/UtilitiesCS.Test.csproj and QuickFiler.Test/QuickFiler.Test.csproj. The + acceptance-criteria mapping table and the `AC-MAPPING:` block were re-read row by row against each + other after the AC4 and AC5 rows changed, and they agree. +- **The space in the sixth path was treated as a shell hazard in every span that names it.** Nine + spans name it: P4-T1's formatter invocation, P4-T8's `wc -l`, P5-T9's `git add` and `git commit`, + and the porcelain spans in P5-T10, P5-T11, P5-T12, and P5-T13, plus the seven operands inside + P2-T4. All are double-quoted. This was checked by re-reading each span after the edit rather than by + assuming the edit applied uniformly. + +### Citations re-derived in the revision pass of 2026-09-03 (revision round 16, applying preflight round 16 findings B1, B2, NB6, and NB7) + +Every citation below was re-derived in this pass against the tree as it stands after revision round 15 +and after this round's own edits to `spec.md`. No value in this section is carried forward from round +15's verification, including the values round 15 recorded correctly: that pass observed the tree +before this round edited `spec.md`, so its line numbers are evidence about a superseded state rather +than about the state this plan now asserts. + +81. This plan file's acceptance-criteria mapping table, the AC4 row — re-read this pass. It lists SIX + evidence artifacts: `evidence/qa-gates/p1-t5-donotparallelize.md`, + `evidence/regression-testing/p3-t3-at-risk-tests.md`, + `evidence/regression-testing/p3-t6-quickfiler-wpfuidispatcher.md`, + `evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md`, + `evidence/regression-testing/p4-t6-first-pass-failure.md`, and + `evidence/qa-gates/p4-t6-quickfiler-tests.md`. The `AC-MAPPING: AC4` line at the end of this file + was read against that row artifact-for-artifact and lists the same six in the same order. Six is + therefore the count P5-T4's acceptance sentence must state. The round-15 numeral "five" in that + sentence was a numeral error and not a disagreement between the table and the task, because the + task's own enumeration already named six paths. +82. `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md` AC4, lines + 286-313 — re-read this pass. Its evidence list names the same six artifacts as citation 81, and + its no-regression FILE set names five files: + `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`, + `UtilitiesCS.Test/OutlookObjects/Folder/WpfDispatcherYieldTests.cs`, + `UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderTreeServiceConcurrencyTests.cs`, + `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`, and + `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs`. The two counts are five FILES and six + ARTIFACTS. Both occurrences of a numeral in P5-T4 were checked against the quantity each names + before either was touched, and only the artifact count was changed; the file count of five is + correct and is left alone. +83. The same `spec.md`, structural anchors re-derived this pass AFTER this round's edits to it: + `## Scope & Non-Goals` at line 58, `## Write Set` at line 84 with its six path bullets at lines + 88-93, `## Root Cause Analysis` at line 95, `## Test Strategy` at line 224, + `## Acceptance Criteria` at line 257 with the AC1-AC7 bullets running from line 259 to line 332, + and `## Risks & Mitigations` at line 334. The AC bullet openings each P5 check-off task quotes are + AC1 at 259, AC2 at 269, AC3 at 277, AC4 at 286, AC5 at 314, AC6 at 329, and AC7 at 331. P5-T5's + quoted fragment "AC5: No retry, sleep, or timing tolerance" still matches line 314 + character-for-character after this round's edit to that bullet, which was checked explicitly rather + than assumed, because this round is the one that edited it. Two edits in this round moved these + numbers: the `- **Status:**` entry at lines 7-9, which grew from one line to three, and the + eleven-line AC5 amendment note at lines 318-328. AC5 is the only bullet whose check state this round changed, from `- [x]` to `- [ ]`; AC1, + AC2, and AC3 keep their `- [x]`, and AC4, AC6, and AC7 keep their `- [ ]`. +84. `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p3-t5-no-timing-tokens.md` + read this pass. Its recorded command is + `git diff 87cb4df338322844abfa580abea14df77e738e5c -- UtilitiesCS UtilitiesCS.Test`, its recorded + diff size is 5626 bytes, and it states "The diff covers exactly this plan's five owned files". + That pathspec is the whole of NB6's substance: the artifact is accurate for the five files it + measured and reaches none of the sixth, so AC5 could not stand checked on it alone once the write + set grew to six. +85. P5-T5's acceptance text re-read this pass, before any edit to it was considered. It already + requires the AC5 check-off to cite BOTH `evidence/qa-gates/p3-t5-no-timing-tokens.md` and + `evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md`, and it already states that the two + pathspecs together cover all six owned files. NB6's conditional instruction to make it do so is + therefore already satisfied and P5-T5 is left unedited. The AC5 row of the mapping table and the + `AC-MAPPING: AC5` line were read against it and both already name the same two artifacts. +86. `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t6-quickfiler-tests.md` + re-read this pass for the exact token P4-T6's preservation gate greps. The literal `Failed: 8` + occurs three times in it: on line 23 inside the verbatim console summary block, on line 30 in the + bulleted derivation of the TRX-sourced figure, and on line 105 in the counterfactual table row. + A `grep -n -F 'Failed: 8'` over a copy of that file therefore prints three lines and exits 0, so + the gate is satisfiable rather than unsatisfiable. The artifact + also records `EXIT_CODE: 1` and TRX `` values `total="1312" executed="1312" + passed="1304" failed="8"`. +87. The evidence tree under + `docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/` was + enumerated in full this pass. It holds thirty artifacts, ending at + `evidence/qa-gates/p4-t6-quickfiler-tests.md`, and + `evidence/regression-testing/p4-t6-first-pass-failure.md` is NOT among them. The conditional in + P4-T6 therefore takes its create branch on the next execution and its found-in-place branch on + every execution after that, which is the state both branches of the acceptance describe. +88. A `git grep`-equivalent content search over every `.cs` file in this worktree for the literal + `typeof(UiThread)`, re-run in this pass independently of round 15's citation 70, returns exactly + seven lines in seven files: `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` line 34, + `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` line 144, + `UtilitiesCS.Test/Threading/UiThread_Tests.cs` line 127, + `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` line 421, + `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` line 138, + `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` line 135, and + `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs` line 469. All seven are inside the + nine test assemblies P0-T14's previous pathspec named, and no production `.cs` file contains the + literal. This is the derivation behind NB7's conclusion that widening command 2's pathspec to + `'*.cs'` costs nothing, and behind clause 2's asserted count remaining seven after the widening. +89. The same search repeated for the literal `"Dispatcher"` over every `.cs` file in this worktree + returns exactly five lines in five files: + `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` line 35, + `QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs` line 14, + `UtilitiesCS/Threading/WpfUiDispatcher.cs` line 14, `UtilitiesCS/Threading/ThreadMonitor.cs` line + 25, and `UtilitiesCS/Threading/IUiDispatcher.cs` line 13. This agrees with round 15's citation 69 + and was re-derived rather than carried, because P0-T14's clauses 1 and 3 rest on it and this round + edits clause 3. The set is unchanged, so neither clause's file set moves. +90. P4-T8 clause 1 re-read this pass before clause 2 was rewritten. It reads "If any did, the loop + restarts from P4-T1 and this artifact records both passes", which is the restart mechanism clause + 2's open-ended count must accommodate and the reason a two-entry demand would be unsatisfiable on a + third pass. Its trailing sub-clause read "this artifact records both passes", which restates the + same two-pass count B2 removes from clause 2, so leaving it would have reinstated the defect one + clause earlier. It now reads "this artifact records every pass, per clause 2". That is the minimum + edit that makes the two clauses agree; clause 1's restart rule, its seven-step listing requirement, + and its rewrote-a-tracked-file condition are unchanged. +91. Constraint 1 in "Shell constraints measured in this worktree" re-read this pass, confirming that + no PowerShell-hosted conditional is available to P4-T6 and that a POSIX `if [ -f ... ]` test in + git-bash is the form consistent with every other command block in this plan. P3-T5's command block + was re-read as the in-plan precedent for a plain POSIX `grep` in the same shell. + +**Sibling regions re-checked in the revision round 16 pass.** + +- **Every occurrence of a numeral naming an AC4 quantity was enumerated before B1 was applied, rather + than the first one being corrected in isolation.** P5-T4 carries two: "extended to five files", + which names the no-regression FILE set and is correct, and "exactly the five artifacts", which names + the EVIDENCE set and was wrong. The mapping table's AC4 row, the `AC-MAPPING: AC4` line, and + `spec.md`'s AC4 evidence list were each counted independently and all three return six. Only the + artifact numeral was changed, and the sentence now states both quantities explicitly so the two + cannot be conflated again by a later reader. +- **P4-T6's clause 5 was re-read after the preservation clause was rewritten.** It quotes the eight + test names "from the preserved first-pass copy at + `evidence/regression-testing/p4-t6-first-pass-failure.md`". That wording is correct under both + branches of the new conditional, because both branches leave that file present and carrying the + first pass's result; only the create branch writes it. No edit to clause 5 is required and none was + made. +- **AC4's evidence set was re-checked for a dependency on the preservation order.** AC4 cites the + preserved fail-before and the pass-after as a pair, and the pass-after artifact is the same file the + copy reads from. The conditional is placed as the FIRST action of P4-T6, before the command that + overwrites that file, which is what keeps the pair well-defined; the acceptance now also verifies the + preserved file's `Failed:` figure in both branches, so a preserved file that is silently a pass-after + copy is reported as BLOCKED instead of being cited as a fail-before. +- **The three commands of P0-T14 were re-read as a set after command 2's pathspec changed.** The + census keeps its three-command structure: command 1 remains the nine-test-assembly `"Dispatcher"` + search whose clause 1 states a two-FILE set, command 2 is now the repository-wide `typeof(UiThread)` + search, and command 3 remains the repository-wide `"Dispatcher"` search. Every recorded result was + re-derived against what the widened command will actually print (citations 88 and 89) and none of the + three clauses' file sets moved. The artifact headings `REFLECTIVE_PROPERTY_NAME_HITS:`, + `REFLECTIVE_UITHREAD_TYPE_HITS:`, and `REPOSITORY_WIDE_PROPERTY_NAME_HITS:` and the per-hit + classification rule are unchanged and still describe the three commands in order. +- **The `spec.md` Risks & Mitigations paragraph that narrates P0-T14 was re-read against the widened + command.** It says P0-T14 "runs that census across all nine test assemblies and repository-wide + across `.cs` files", which remains true of the three-command set after the widening — the widening + moves one command from the first scope into the second, and both scopes are still exercised. No edit + to that paragraph is required and none was made. +- **`spec.md`'s AC5 criterion text was left byte-identical.** Only the checkbox and an appended + amendment note changed. The Evidence line under AC5 still names `p3-t5-no-timing-tokens.md` alone and + was deliberately not extended, because P5-T5 is the task that adds the second citation at check-off + time and pre-writing it would assert an artifact that does not yet exist. +- **`spec.md`'s `## Write Set`, `## Scope & Non-Goals`, and the Files/modules-to-change list under + `## Proposed Fix` were re-read after the AC5 edit.** Write Set and Scope both name six files and are + consistent with this plan's Scope section. The Files/modules-to-change list still names two files; + it is a pre-existing non-authoritative summary, it is finding NB5, and NB5 is excluded from this + delta, so it is recorded here as examined and deliberately left alone rather than swept. +- **Every restatement of P4-T8's two-pass requirement elsewhere in the plan was searched for before B2 + was applied, but that search was incomplete: it found three restatements and missed a fourth, which + survived revision round 16 stale and was corrected only in revision round 17. The claim originally + recorded here — that the count "was changed in one place and left stale in none" — was false when it + was written.** The four restatements are: + P4-T8 clause 2 itself, P4-T8 clause 1's trailing "records both passes" sub-clause, the + "Phase 4 first-pass marks cleared by this round" header note near the top of this file, and the + "Second pass, and why the first pass's marks were cleared" note that opens Phase 4, the last two of + which said the acceptance "requires the loop-closure artifact to record both passes". All four now state the + open-ended, chronologically ordered requirement. The header note's surrounding sentences — which + record why P4-T1 through P4-T5 were returned to `[ ]` — are unchanged. +- **No task's checkbox state was changed in the plan by this round.** P0-T14, P2-T4, P4-T6, P4-T8, and + every Phase 5 task were unchecked before this round and are unchecked after it; the completed tasks + this round's edits sit next to — P2-T3, P3-T5, P3-T6, P4-T5 — were not edited. The only checkbox + changed anywhere in this round is `spec.md`'s AC5, which NB6 requires. + +### Sibling regions re-checked in the revision round 9 pass + +- **The spec.md line-168 WpfDispatcherYield.cs backtick leftover was found and fixed in this + round.** Round 9's backtick-removal pass converted every other backtick-wrapped + scope-exclusion/precedent/context-reference path mention in `spec.md` and this plan to plain text, + but left the `WpfDispatcherYield.cs:64-66` mention inside spec.md's "Error handling and logging + updates" subsection (the same file already referenced in plain-text form at spec.md lines 100 and + 133) still backtick-wrapped. It is now corrected to the unbackticked, full-repository-relative-path + form `UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs:64-66`, matching the spelling already + established at those two lines. The surrounding code-identifier backticks on + `InvalidOperationException`, `Initialize()`, and `IdleAsyncQueue.OnApplicationIdle` in the same + passage were left untouched, because those are code-identifier citations rather than path mentions. +- **No other task in this plan quotes the AC3 or AC4 bullet text in full, so no further + quotation-consistency fix was needed.** P5-T1, P5-T2, and P5-T5 through P5-T8 were each re-read this + pass: every one quotes only a short opening fragment of its own AC bullet (for example P5-T4's "AC4: + No regression in"), never the AC3 or AC4 bullet in full. P5-T3's opening-fragment quotation of AC3 + was re-derived directly (citation 60 above) and P5-T4's opening-fragment quotation of AC4 ("AC4: No + regression in") was re-checked against spec.md's current AC4 bullet and still matches. +- **Constraint 5, the TRX selection rule, and the seven TRX-reading tasks (P0-T10, P0-T11, P3-T2, + P3-T3, P3-T6, P4-T5, P4-T6) were read and confirmed unaffected by round 9's backtick-removal + edits.** None of the seven task bodies, and neither the constraint-5 sourcing paragraph nor the TRX + selection rule that follows it, mentions WpfDispatcherYield.cs or any other + scope-exclusion/precedent/context-reference file path; each cites only its own vstest command, + `/ResultsDirectory` value, and the `notExecuted`/`Skipped` sourcing rule already re-derived in the + revision round 8 pass. No command line, task ID, write-target file, or evidence path in any of the + seven changed as a result of round 9. + +### Sibling regions re-checked in the revision round 10 pass (mechanical backtick-removal correction) + +- **P5-T9's and P5-T10's exclusion-assertion path mentions were read and corrected in this round.** + Both tasks were still backtick-wrapped after round 9 (confirmed against commit 12a11031), asserting + that the committed diff and the anchored name-status diff contain no path under .claude/, + .codex/, .agents/, or config/. On re-derivation this pass, that "no path under X" phrasing is + itself the exclusion case the delegation's backtick-removal instruction names explicitly, so those + spans are now plain text (.claude/, .codex/, .agents/, config/blast-radius.json, + config/orchestration-routing.json) rather than backtick-wrapped, matching every other exclusion + mention in this plan and in spec.md. + +### Sibling regions re-checked in the revision round 8 pass + +- **Every live occurrence of `notExecuted` in the plan was enumerated before any edit, and again + after.** The pre-edit set named the attribute as the `Skipped` source in eight places: constraint + 5's sourcing paragraph, constraint 5's redaction rule, P0-T10 (three: its sourcing paragraph, its + `where failed supplies ...` sentence, and its redaction rule), P0-T11, P4-T5, and P4-T6. All eight + were corrected. Two further occurrences sit inside the verbatim `` example blocks in + constraint 5 and in P0-T10; those quote tool output rather than instruct the executor, and each is + now immediately followed by the prohibition, so both were left as they stand. After the edits, + every surviving occurrence of the token is either inside one of those illustrative XML blocks, + inside an explicit prohibition on using the attribute, or + inside the measured-evidence sentence recording that the probe's TRX carried `notExecuted="0"`. No + occurrence anywhere in the plan now names it as a source for any recorded value. +- **The three tasks that read a `Failed` count but record no `Skipped` count were checked + individually rather than swept.** P3-T2, P3-T3, and P3-T6 read only `failed`, so the `total` minus + `executed` derivation does not apply to them; each now says so explicitly and forbids introducing + `notExecuted`, so a later reader cannot mistake the absence for an omission. The delta added no + `Skipped` field to any of the three, because adding one would have changed a gate rather than its + documentation. +- **The `Output Summary:` field lists and the acceptance clauses were re-read against each other for + all four tasks that record `Skipped`.** P0-T10, P0-T11, P4-T5, and P4-T6 now each list `total` and + `executed` in the field list AND require them in the acceptance clause, so the derived value is + auditable from the artifact rather than being an unverifiable single number. Recording the two + operands is what makes the `Skipped` figure falsifiable by a third party, which is the property + `notExecuted` lacked. +- **The tie-break rule's central form was checked against the redaction rule it now sits beside.** + The finding's suggested wording would have had each artifact record `TRX SELECTED: `. + That directly contradicts the redaction rule added in round 7, because the default TRX filename is + composed from the host account name and the machine name. The rule as written records the results + directory plus the selected file's last-modified timestamp instead, which identifies the selection + uniquely within that directory and discloses neither. This is a sibling-invalidation catch inside + this round's own delta. +- **The claim that per-task results directories make collisions impossible was corrected where it + appeared.** Constraint 5's sourcing paragraph and the round-7 changelog both stated that files + "cannot collide" because each task owns its directory. That is true across tasks and false within + one task across re-runs, which is exactly the case O1 raised. Both sentences now say the uniqueness + is per task and point at the selection rule for the re-run case. +- **The seven TRX-reading tasks were re-enumerated after the four per-task tie-break copies were + replaced.** All seven — P0-T10, P0-T11, P3-T2, P3-T3, P3-T6, P4-T5, P4-T6 — now carry an explicit + pointer to the central rule instead of a local copy of it, and the rule itself names all seven by + task ID. Three of those pointers (P3-T3, P4-T5, P4-T6) additionally name the specific in-plan text + that anticipates that task being re-run. The rule is stated + in exactly one place and referenced from seven, so it can no longer be present in some tasks and + silently absent from others. P1-T4, the eighth vstest task, reads no TRX and carries no pointer; + the reason is stated in the round-7 section. +- **The `Failed:`/`Skipped:` trigger statement was corrected in both places it appeared**, not only + in the one the finding named: constraint 5's measured-behaviour paragraph and the round-7 + changelog's P1-T4 rationale. Both now state the per-counter rule. P1-T4's own acceptance is + unchanged and remains satisfiable: its run has a non-zero failure count by construction, so the + `Failed:` line it reads is printed whatever the skip count is. +- **The "Shell constraints measured in this worktree" section header was re-read against its own new + contents.** Its opening sentence attributed the five constraints to preflight rounds 3, 4, and 5; + constraint 5 now also carries a round-6 measurement, so the sentence names rounds 3, 4, 5, and 6. + The count of five is unchanged, because round 6 corrected constraint 5 rather than adding a sixth. + The section now also states, before the numbered list, that the TRX selection rule at its end is a + plan rule rather than an environment measurement, so the section's self-description matches what it + contains. No existing constraint number moved, so every cross-reference elsewhere in the plan — + which names constraint 1, 2, 4, or 5 — remains valid; each was re-read to confirm it. +- **The plan's `Status:` and `Version:` metadata were updated to name this round and its findings**, + so the header does not report a round-7 plan while carrying round-8 text. +- **P4-T7 and P4-T8 were checked for a reason to change and required none.** P4-T7 reads Cobertura + attributes and class-node line hits, and P4-T8 reads exit codes, artifact paths, and `wc -l` rows. + Neither reads a TRX or a test count, so neither is affected by this round. P4-T7's own redaction + rule was re-read and is consistent with the extended TRX redaction rule; both now prohibit + recording an absolute host path from any artifact class this plan reads. +- **The write-target set was re-read after every edit in this round and is unchanged.** The five + paths are `UtilitiesCS/Threading/UiThread.cs`, `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, + `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`, + `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`, and + `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs`. + UtilitiesCS/Threading/ProgressTrackerAsync.cs remains outside the write-target list. No evidence + path and no command line changed, so the acceptance-criteria mapping table and the `AC-MAPPING:` + block below are unaffected and were re-read row by row against each other to confirm they still + agree. + +### Sibling regions re-checked in the revision round 7 pass + +- **Every task in the plan that records a `Failed` or a `Skipped` count was enumerated before any + edit was made, not only the five the finding named.** There are eight: P0-T10, P0-T11, P1-T4, + P3-T2, P3-T3, P3-T6, P4-T5, and P4-T6. Seven now carry the two-source rule. The eighth, P1-T4, is + deliberately unchanged, and the reason is stated rather than assumed: P1-T4 is the `[expect-fail]` + task, its acceptance requires `Failed: 1`, and the measured behaviour is that EACH of the `Failed:` + and `Skipped:` console lines is printed only when its OWN counter is non-zero — independently of + the other, as the round-6 three-test probe confirmed by printing both lines for a run that had one + failure and one skip. A run that satisfies P1-T4's + acceptance therefore has a non-zero failure count and prints the line P1-T4 reads, so applying the + TRX rule there would have changed + a gate that is already satisfiable. Constraint 5 states this explicitly so a later reader does not + read P1-T4 as an omission. +- **Every one of the seven changed tasks was re-read to confirm it already carries `/Logger:trx` and + a `/ResultsDirectory:` that no other task shares.** The seven directories are `TestResults/p0-t10`, + `p0-t11`, `p3-t2`, `p3-t3`, `p3-t6`, `p4-t5`, and `p4-t6`; P1-T4's `p1-t4` is the eighth and is + likewise unique. Because no two tasks write to the same results directory, a TRX cannot be + attributed to the wrong TASK, and the rule needs no new command, no `LogFileName` argument, and no + change to any existing switch. No command line in the plan was modified in this round. Uniqueness + across tasks does not, however, bound the number of `.trx` files inside one task's own directory + when that task is re-run; revision round 8 added the "TRX selection rule" after constraint 5 to + cover that case for all seven tasks. +- **The P0-T10 / P4-T5 and P0-T11 / P4-T6 identity preconditions were re-checked after this round's + edits.** This round edited only `Output Summary:` and acceptance prose, so both pairs still differ + only in `--output` and `/ResultsDirectory` (the first pair) and in `/ResultsDirectory` alone (the + second). P4-T7 clause (c) rests on the first of those and is unaffected. +- **Sibling invalidation caught and repaired inside this round's own delta.** The first draft of this + round required each affected artifact to "name the TRX file" the counts were read from. + `vstest.console.exe` composes the default TRX filename from the host account name and the machine + name, so that requirement would have written an account-name disclosure into committed evidence — + the same defect class P4-T7's existing redaction rule was added to prevent for the Cobertura + `filename` attribute. All four occurrences were rewritten to identify the file by its + repository-relative results directory only, and the rule is stated once centrally in constraint 5 + and bound on all seven tasks. The finding's specified treatment is unaffected: it named the + directory, not the filename, as the locator. +- **The "Shell constraints measured in this worktree" header sentence was corrected from "Four + constraints" to "Five", and every cross-reference to a numbered constraint elsewhere in the plan was + re-read.** The surviving references name constraint 1 (P0-T5 step 1 and P3-T5), constraint 2 (P0-T5 + step 4, P0-T10, and P0-T11), and constraint 4 (P0-T5 step 3, P0-T10, and P0-T11). The new entry was + appended as constraint 5, so no existing number moved and no reference was invalidated. +- **The tasks that record test counts but were NOT changed were checked for a reason to change + them.** P4-T7 records only Cobertura root-element attributes and class-node line hits, and P4-T8 + records exit codes, artifact paths, and `wc -l` rows; neither reads a test count, so neither is + touched. P3-T3's clause 1 (`Total tests` greater than zero) and P4-T5's clause comparing `Total + tests` to the baseline plus 2 both compare console-sourced values on each side, and P4-T6's clause + compares its console-sourced `Total tests` to P0-T11's console-sourced one, so no comparison in the + plan now spans two different sources. +- **The failing-test NAME sets were distinguished from the `Failed` COUNT throughout.** P0-T10's and + P0-T11's `BASELINE_FAILURE_SET:`, P3-T3's clause 3, and P4-T5's and P4-T6's subset clauses all + operate on test names taken from per-test output, which is printed on green and red runs alike. + Only the aggregate count moved to the TRX, so none of those mechanisms changed. +- **The write-target set was re-read after every edit in this round and is unchanged.** The five + paths are `UtilitiesCS/Threading/UiThread.cs`, `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, + `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`, + `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`, and + `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs`. + UtilitiesCS/Threading/ProgressTrackerAsync.cs remains outside the write-target list. No evidence + path changed, so the acceptance-criteria mapping table and the `AC-MAPPING:` block below are + unaffected and were re-read row by row against each other to confirm they still agree. + +### Sibling regions re-checked in the revision round 6 pass + +- **Every `msbuild.exe` occurrence in the whole plan was enumerated, not only the six the finding + named.** The plan contains seven: the six build command blocks (P0-T8, P0-T9, P1-T3, P3-T1, P4-T3, + P4-T4), which now all carry the `MSYS_NO_PATHCONV=1 ` prefix, and the P0-T5 step 3 `-version` probe, + which deliberately does not and must not. The two distinct build command lines were each replaced + across all their occurrences in one operation — the analyzer line appears four times (P0-T8, P1-T3, + P3-T1, P4-T3) and the nullable line twice (P0-T9, P4-T4) — so no occurrence could be missed through + differing surrounding whitespace. The switch sets after the prefix are byte-identical to their + pre-round-6 form: `/t:Rebuild` in all six, no `/p:Nullable=enable` anywhere, `/t:Build` nowhere. +- **Every `vstest.console.exe` and `dotnet-coverage` occurrence in the whole plan was enumerated.** + There are eight invocations: six leading `MSYS_NO_PATHCONV=1 PATH=":$PATH" vstest.console.exe` + (P0-T11, P1-T4, P3-T2, P3-T3, P3-T6, P4-T6) and two leading `MSYS_NO_PATHCONV=1 dotnet-coverage collect` + (P0-T10, P4-T5). All eight carry the prefix. The remaining textual occurrences of those names are in + prose or in the P0-T5 step 2 `-find` pattern, none of which is an executed command line. +- **The P0-T10 / P4-T5 identity precondition was re-checked operand by operand after the prefix was + added.** Both now begin `MSYS_NO_PATHCONV=1 dotnet-coverage collect`; both use the native directory + spelling in the quoted operand after `--`; neither carries a `PATH=` prefix; neither carries + `/EnableCodeCoverage`. They still differ only in `--output` and `/ResultsDirectory`, which is what + P4-T7 clause (c) rests on. The same check was repeated for the P0-T11 / P4-T6 pair, which still + differ only in `/ResultsDirectory`. Sibling-invalidation risk addressed here: a prefix added to one + side of either pair and not the other would have left one run executing the suite and the other + executing nothing, while both artifacts still recorded an exit code. +- **The acceptance clauses that read the recorded command line were re-checked.** P0-T9 and P4-T4 were + the only two, and both were reworded from "begins" to "contains". Both retain the two substantive + clauses verbatim. No other acceptance clause anywhere in the plan asserts a position within a + recorded command line. +- **P0-T5's step numbering was re-checked against every cross-reference in the plan.** Inserting the + NuGet restore as step 4 renumbered the former steps 4, 5, and 6 to 5, 6, and 7. Every surviving + cross-reference in the plan names step 1, step 2, or step 3 — none names a renumbered step — so no + reference was invalidated. The two references to step 4 both refer to the new restore step. +- **N-3 checked and no change required.** No task in this plan asserts an equality against a specific + analyzer warning count. P0-T8 records the observed baseline count as data; P3-T1 and P4-T3 each + require only that the observed count be less than or equal to that recorded baseline. A measured + baseline of 5 satisfies those clauses exactly as any other value would, so the plan carries no + number that the environment could falsify. +- **No `pwsh` was reintroduced.** The only occurrences of that token in the plan are constraint 1 of + "Shell constraints measured in this worktree", P0-T5 step 1's prohibition on running the PowerShell + bootstrap script, and P3-T5's rationale for using a `grep` pipeline. None is a command. The three new + or rewritten command lines this round adds — `nuget.exe restore TaskMaster.sln` and the fourteen + prefixed invocations — introduce no shell host. +- **The write-target set was re-read after every edit in this round and is unchanged.** This round + touched command prefixes, one new toolchain step, two acceptance wordings, one evidence-redaction + rule, and prose. It added no file to the diff and removed none. + UtilitiesCS/Threading/ProgressTrackerAsync.cs remains outside the write-target list, and the + restore step writes only into gitignored `packages/`. +- **P4-T7's new redaction rule was checked against P4-T7's own acceptance clauses.** Clauses (a) and + (b) read the intersected line numbers and their `hits` values, which the rule explicitly preserves; + clauses (c) and (d) read root-element attributes, which carry no path. The rule therefore removes + the account-name disclosure without removing anything the gate reads. It was also checked against + P5-T7, which cites the artifact and quotes coverage figures rather than paths. +- **The new "Worktree state assumed by Phase 0" section was checked against every Phase 0 task.** It + states that the authoring worktree's prebuilt state must not be treated as licence to skip a task, + which is consistent with P0-T5's unconditional restore steps and with its conditional SDK bootstrap. + No task's acceptance was weakened to accommodate the prebuilt state. + +### Sibling regions re-checked in the revision round 5 pass + +- Every one of the eight `vstest.console.exe` invocations in the plan was re-read after the C2 + rewrite. Six of them (P0-T11, P1-T4, P3-T2, P3-T3, P3-T6, P4-T6) lead with + `PATH=":$PATH" vstest.console.exe`, and two (P0-T10, P4-T5) carry + `-- "\vstest.console.exe"` as an argument to `dotnet-coverage`. The + pre-C2 single placeholder that stood for a full executable path was removed everywhere; every + vstest invocation in the plan now substitutes one of the two directory placeholders defined in + P0-T5 step 2, and no command in the plan names a full `vstest.console.exe` path in the + command-NAME position. +- P0-T10 and P4-T5 were compared operand by operand after the rewrite. They remain identical apart + from `--output` and `/ResultsDirectory`, which is the precondition P4-T7 clause (c) rests on. Both + use the native directory spelling; neither carries a `PATH=` prefix; neither carries + `/EnableCodeCoverage`. +- P0-T11 and P4-T6 were compared the same way and remain identical apart from `/ResultsDirectory`. + Both now carry the `PATH=` prefix, so P4-T6's "flag set identical to P0-T11's" clause still holds. +- Sibling invalidation caught in this pass: P0-T10's forward-slash rationale asserted that the P0-T5 + `-find` pattern was "the only backslashes left anywhere in this plan's commands". The C2 rewrite + added two more, inside the double-quoted `dotnet-coverage` operands. The sentence was corrected to + name all three sites and to state that double quoting is what preserves them in each. +- Every `msbuild` invocation was re-read after the C3 rewrite. All six command blocks (P0-T8, P0-T9, + P1-T3, P3-T1, P4-T3, P4-T4) and the P0-T5 step 3 probe now spell `msbuild.exe`. The switch sets are + byte-identical to their pre-C3 form: `/t:Rebuild` in all six, `/p:EnableNETAnalyzers=true + /p:EnforceCodeStyleInBuild=true` in the four analyzer builds, `/p:TreatWarningsAsErrors=true` in + the two type-check builds, and no `/p:Nullable=enable` anywhere. The quoted-command-line acceptance + clauses in P0-T9 and P4-T4 were updated to expect the `msbuild.exe` spelling; both retain their two + substantive clauses unchanged, and both now state that the spelling clause is not the substantive + one. +- P0-T5's step 3 justification previously rested on a prior round's review note that bare `msbuild` + resolved. That claim is now known to be wrong in this worktree and was replaced with the measured + exit-127 and exit-0 readings. +- P0-T5's acceptance clause previously required "the resolved vstest path is a non-empty existing + file path". After C2 the recorded artefact value is a directory, so the clause was rewritten to + require both recorded directory values and the existence of `vstest.console.exe` inside them. +- P3-T5's rationale named a PowerShell-hosted filter as the superseded alternative and cited the + round-2 refusal of a `-Command` payload specifically. Because the refusal is now known to apply to + every argument shape, the paragraph was rewritten so it does not imply that some other PowerShell + invocation shape would have been available. +- Phase 0's new introductory paragraph (finding N1) was checked against every porcelain gate in the + plan. P4-T1's two porcelain spans are deliberately unscoped but are compared before-against-after, + so pre-existing .claude/agent-memory/** entries appear in both and cancel. P5-T10, P5-T11, + P5-T12, and P5-T13 are pathspec-scoped to `UtilitiesCS`, `UtilitiesCS.Test`, and the feature + folder. P3-T4's porcelain span is scoped to a single file. No gate is affected by the dirty + .claude/ state, which is what the paragraph asserts. +- P5-T11, P5-T12, and P5-T13 (finding N2) were rewritten to carry the feature-folder pathspec on + `git commit` as well as on `git add`, matching P5-T9. P5-T12 previously described its commit only + in prose; it now has an explicit command block, so all four commit tasks in Phase 5 are stated in + the same form and none of them can sweep residue left staged by P3-T4's `git add -A -- UtilitiesCS + UtilitiesCS.Test`. +- The five write targets were re-read against the "Scope" section after all of this round's edits. + The set is unchanged: this round touched only command spellings, commit pathspecs, and prose. + UtilitiesCS/Threading/ProgressTrackerAsync.cs remains outside the write-target list. + +### Sibling regions re-checked in the preflight round 3 pass and found consistent + +- Every command block in the plan was re-read for a surviving PowerShell-hosted payload. The three + line-count blocks are `wc -l` and P3-T5's filter is a `grep -E` pipeline. One conditional + PowerShell-script bootstrap remained in P0-T5 after this round and was removed in the round-5 pass + recorded below. +- Every occurrence of the string `Measure-Object` was re-read. The one remaining occurrence is + P0-T13's explicit prohibition on using it, which is prose about the idiom rather than a command. +- P0-T10's forward-slash paragraph was corrected in this pass to say every command block runs + through a POSIX shell, naming the places where backslashes are deliberately retained and protected + by double quoting. +- P3-T5's two closing rationale paragraphs named `Select-String` as the consumer of the diff file. + Both were corrected to name the `grep` pipeline that replaced it, so the stated vacuous-pass + mechanisms still describe the command the task actually runs. +- P5-T10's rationale asserted that P4-T1 "restored every unowned path the formatter rewrote". After + the NB1 change P4-T1 restores nothing, because it rewrites nothing outside the owned five. The + clause was corrected to state the new mechanism. This is the sibling-invalidation case: the NB1 + edit was in Phase 4 and the invalidated sentence was in Phase 5. +- The acceptance-criteria mapping table was re-read row by row against the `AC-MAPPING:` block. NB3 + named AC3's Implementation cell. Two further disagreements of the same class were found in this + pass and corrected: AC4's cell used a semicolon where `AC-MAPPING:` uses a comma, and AC5's cell + listed `P2-T1, P1-T2, P1-T5` where `AC-MAPPING:` lists `P1-T2, P1-T5, P2-T1`. All seven rows now + match their `AC-MAPPING:` line character for character in the Implementation column. +- P1-T2's acceptance already forbade `Thread.Sleep`, `Task.Delay`, `Thread.CurrentThread.Join`, + `SpinWait`, and `Dispatcher.PushFrame` in the new CODE. The NB2 change extends the prohibition to + the new DOC COMMENT and adds the two tokens the code clause did not carry, `Retry`/`retries` and + `Timeout(`, so the P1-T2 constraint is now a superset of what P3-T5 searches for. The verbatim + test code quoted in P1-T2 was re-read against all seven tokens and contains none of them. +- P0-T7's note that "P4-T2 and P5-T10 are both written to be satisfiable against a non-empty drift + set" was re-checked against the rewritten P4-T2 acceptance and still holds: P4-T2 now requires the + reported set to be a subset of `BASELINE_FORMAT_DRIFT_SET`, which a non-empty baseline satisfies. +- P5-T9's `git add` was retained ahead of its new pathspec commit deliberately. A pathspec commit + accepts only paths already known to git, and this plan creates new untracked evidence files under + the feature folder, so removing the `git add` would make the commit fail on exactly those files. + +### Sibling regions re-checked in the preflight round 2 pass and found consistent + +- `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` lines 134-188 — the `DispatcherField` / + `ForceDispatcherNull` / `RestoreDispatcher` helpers sit far from line 28, so P1-T5's attribute + insertion cannot disturb them and the new test's mirrored idiom stays valid. +- `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` lines 1-16 — the file's `using` block + already imports `Microsoft.VisualStudio.TestTools.UnitTesting`, so `DoNotParallelize` resolves + without a new `using`, and the attribute-only edit adds no directive to a 514-line file. +- The acceptance-criteria mapping table and the `AC-MAPPING:` block below were re-derived from the + task list in this pass rather than edited independently, which is what removes the AC2 and AC7 + disagreements the prior round reported. +- Every remaining `git diff` span in the plan was re-read in this pass. The two-dot form now appears + only in P5-T10, which runs after the P5-T9 commit and is therefore not vacuous; every span that + runs before that commit uses either the single-ref working-tree form or `--cached`. + +--- + +PLANNER-INTERNAL-REVIEW: PASS +CITATION-TO-TREE: PASS +AC-TRACEABILITY: PASS +SCOPE-BOUNDARY: PASS +CITATION: UtilitiesCS/Threading/UiThread.cs | lines 135-140, Dispatcher property and `null!` backing field +CITATION: UtilitiesCS/Threading/UiThread.cs | line 1, nullable-enable directive +CITATION: UtilitiesCS/Threading/UiThread.cs | line 61, `Dispatcher = _syncContextForm.UiDispatcher;` +CITATION: UtilitiesCS/Threading/UiThread.cs | lines 113-125 and 147-158, lazy-init sibling properties +CITATION: UtilitiesCS/Threading/ProgressTrackerAsync.cs | line 33, `UiDispatcher = UiThread.Dispatcher;` +CITATION: UtilitiesCS/Threading/ProgressTrackerAsync.cs | line 35, first dereference `await UiDispatcher.InvokeAsync(` +CITATION: UtilitiesCS/Threading/SyncContextForm.cs | line 30, `public Dispatcher UiDispatcher { get; private set; } = null!;` +CITATION: UtilitiesCS/Threading/WpfUiDispatcher.cs | lines 24-25 and 37, parameterless ctor provider over UiThread.Dispatcher +CITATION: UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs | lines 57-67, existing InvalidOperationException precedent +CITATION: UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs | lines 45-46, default fallback provider +CITATION: UtilitiesCS.Test/Threading/UiThread_Tests.cs | 104 lines, namespace UtilitiesCS.Test.Threading, single TestClass +CITATION: UtilitiesCS.Test/UtilitiesCS.Test.csproj | line 494, Compile Include for Threading\UiThread_Tests.cs +CITATION: UtilitiesCS.Test/UtilitiesCS.Test.csproj | lines 477, 479, 490, Compile Include for ProgressTracker_Tests.cs, ProgressTrackerAsync_Tests.cs, IdleAsyncQueue_Tests.cs +CITATION: UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs | lines 141-186, DispatcherField/ForceDispatcherNull/RestoreDispatcher +CITATION: UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs | lines 248-289, AddEntry_UseUiThreadTrue_DequeuesEntryAndSuppressesDispatcherException +CITATION: UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs | line 28, sole [TestClass] and P1-T5 insertion point; line 144, "_dispatcher" reflection write; 347 total lines +CITATION: UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs | lines 126-190, InitializeAsync_WithCurrentDispatcher_InitializesAndReturnsTracker +CITATION: UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs | line 13, sole [TestClass] and P1-T5 insertion point; lines 138, 150, 152, 162, reflection write then InitializeAsync then Dispatcher.PushFrame; 205 total lines +CITATION: UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs | line 12, namespace UtilitiesCS.Test; line 14, sole [TestClass] and P1-T5 edit site; line 422, "_dispatcher" reflection write; lines 411-432, Initialize_WithCurrentDispatcherAndScreen_InitializesViewerAndUpdatesUi; 514 total lines, pre-existing file-size overage +CITATION: UtilitiesCS.Test/Threading/CurrentStoreContextTests.cs | lines 15-16, prevailing [TestClass] then [DoNotParallelize] two-line idiom +CITATION: UtilitiesCS.Test/OutlookObjects/Folder/WpfDispatcherYieldTests.cs | lines 117-142, YieldAsync_WithoutDispatcher_RemainsStrict +CITATION: UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderTreeServiceConcurrencyTests.cs | line 55, new WpfDispatcherYield() +CITATION: UtilitiesCS.Test/Properties/AssemblyInfo.cs | line 18, assembly Parallelize attribute +CITATION: QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs | lines 26 and 64, new WpfUiDispatcher() +CITATION: QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs | lines 23-24 and 48-50, the file's only two [TestMethod] declarations, Construction_YieldsAnIUiDispatcher and Invoke_InvokeAsync_BeginInvoke_ExecuteDelegateOnDispatcherThread — the basis for P3-T6's Total tests of 2 and its two by-name clauses +CITATION: .csharpierignore | lines 4-14, evidence and project-file exclusions, including line 8 `*.trx`, which keeps the TRX files P0-T10, P0-T11, P3-T2, P3-T3, P3-T6, P4-T5, and P4-T6 read out of P4-T2's repo-wide format check +CITATION: .gitignore | line 39 `[Tt]est[Rr]esult*/` and lines 144-145 `coverage/*`, both directory-scoped, which is why backslash-stripped root-level paths would escape them and why every `TestResults//` TRX this plan reads is untracked +CITATION: coverage.config | lines 12-22, third-party-only module excludes; no first-party assembly excluded, so added production and test lines legitimately move lines-valid +CITATION: docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md | lines 259-332, AC1-AC7, with the ## Acceptance Criteria heading at line 257 and ## Risks & Mitigations at line 334; the AC bullet openings are AC1 at 259, AC2 at 269, AC3 at 277, AC4 at 286, AC5 at 314, AC6 at 329, AC7 at 331 (re-derived in revision round 16 after that round's AC5 amendment and its two-line Status edit; supersedes the round-15 "lines 257-319" reading, the round-9 "lines 249-272" reading, and citation 18's round-1 "lines 234-257" reading) +CITATION: docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md | ## Write Set at line 84 with six path bullets at lines 88-93, the sixth being QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs — re-derived in revision round 16 and agreeing with this plan's own Scope list +CITATION: docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md | line 314, AC5 now reads `- [ ]` and carries a revision round 16 amendment note at lines 318-328 recording why it was unchecked and that P5-T5 re-checks it once evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md exists; the criterion text on line 314 is unchanged +CITATION: docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md | lines 286-313, AC4's five-file no-regression set and its SIX-artifact evidence list (p1-t5-donotparallelize.md, p3-t3-at-risk-tests.md, p3-t6-quickfiler-wpfuidispatcher.md, p2-t4-emailmovemonitor-reflection-target.md, p4-t6-first-pass-failure.md, p4-t6-quickfiler-tests.md) — the derivation behind P5-T4's corrected artifact numeral +CITATION: docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p3-t5-no-timing-tokens.md | recorded Command line `git diff 87cb4df338322844abfa580abea14df77e738e5c -- UtilitiesCS UtilitiesCS.Test`, 5626-byte diff, "The diff covers exactly this plan's five owned files" — the pathspec limitation NB6's AC5 amendment records +CITATION: docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/issue.md | line 8, promotion-time merge base SHA 5ebaaf105d8241f309f704d1ff90af2e32e5a6c1, superseded by BASE 87cb4df338322844abfa580abea14df77e738e5c after the reconciliation merge; issue.md is outside this plan's write set and is not edited +CITATION: .git worktree log for agent-a18cc3bc53f9c1d8a | final entry `merge origin/main: Merge made by the 'ort' strategy` producing a2ef517b, and recorded origin/main tip 87cb4df338322844abfa580abea14df77e738e5c — the derivation of the re-anchored BASE +CITATION: global.json | lines 6-10, sdk paths [".dotnet-sdk", "$host$"] and the missing-repo-local-SDK error message; `.dotnet-sdk/` absent from this worktree +CITATION: scripts/vscode/Install-RepoDotNetSdk.ps1 | line 3 default version 8.0.205; line 26 download URL https://builds.dotnet.microsoft.com/dotnet/Sdk/$Version/dotnet-sdk-$Version-win-$Architecture.zip; line 36 install dir Join-Path $PSScriptRoot '..\..\.dotnet-sdk' — the three values P0-T5's POSIX bootstrap reproduces without running the script +CITATION: .gitignore | line 350 `.dotnet*/`, which already ignores the `.dotnet-sdk/` directory P0-T5's bootstrap creates +CITATION: .gitignore | line 191 `**/[Pp]ackages/*` and line 193 `!**/[Pp]ackages/build/`, which ignore the NuGet restore output P0-T5 step 4 creates, with the single named exception +CITATION: .github/workflows/_build-analyzers.yml | line 17 `SOLUTION_PATH: TaskMaster.sln`, line 45 `nuget restore $env:SOLUTION_PATH`, line 50 the analyzer msbuild that follows it — the CI-parity basis for P0-T5 step 4 +CITATION: .github/workflows/_build-nullable.yml | line 45, `nuget restore $env:SOLUTION_PATH` preceding the nullable build +CITATION: .github/workflows/_mstest-coverage.yml | line 45, `nuget restore $env:SOLUTION_PATH` preceding the coverage run +CITATION: UtilitiesCS/packages.config | one of 18 packages.config files in this solution, which is why a missing NuGet restore fails the first build with CS0246 and missing-.targets errors +CITATION: UtilitiesCS.Test/packages.config | the test-assembly half of the same packages.config-based restore requirement +CITATION: dotnet-tools.json | repository-root local tool manifest pinning CSharpier 1.2.6; no `.config/` directory exists, so `dotnet tool restore` reads this file and performs no NuGet package restore +CITATION: .github/workflows/ci.yml | lines 21-23, format-check delegated to _format-check.yml +CITATION: .github/workflows/_format-check.yml | line 37 `dotnet tool restore`, line 41 `dotnet csharpier check .` repo-wide, the CI-parity command P4-T2 runs +CITATION: UtilitiesCS.Test/Threading/UiThread_Tests.cs | 17 blank lines of 104 total, and UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs 92 blank of 514, the measurement that disqualifies Measure-Object -Line as the counting idiom +CITATION: QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs | 314 total lines; namespace QuickFiler.Helper_Classes.Tests; lines 21-22 [TestClass] then [DoNotParallelize]; line 32 private object _capturedDispatcher; lines 33-37 the PropertyInfo DispatcherProperty declaration over GetProperty("Dispatcher", Public | Static); line 49 and line 58 the two DispatcherProperty?.GetValue(null) call sites; line 59 current.Should().BeSameAs(_capturedDispatcher); eight [TestMethod] declarations at lines 87, 107, 134, 147, 176, 200, 234, 266; no using System.Reflection directive — the sixth owned file and P2-T4's edit sites +CITATION: QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs | none of Thread.Sleep, Task.Delay, SpinWait, Retry, retries, Timeout(, PushFrame occurs anywhere in the file, while Thread.CurrentThread does occur at line 273 — the derivation that makes P2-T4's whole-diff token filter satisfiable +CITATION: QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs | line 135, FieldInfo field = typeof(UiThread).GetField( — the same-assembly precedent for the field route P2-T4 adopts +CITATION: QuickFiler.Test/QuickFiler.Test.csproj | line 206, Compile Include for Helper Classes\EmailMoveMonitorTests.cs — the sixth owned file is already wired and already tracked, so P2-T4 needs no project-file edit +CITATION: UtilitiesCS/Threading/UiThread.cs | post-P2-T1 state re-derived in round 15: property lines 135-148, null guard at 139, throw at 141, return at 145, private setter at 147, and private static Dispatcher? _dispatcher; at line 149 — the mechanism a reflective property read now propagates +CITATION: UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs | lines 142-148 DispatcherField() over GetField("_dispatcher", NonPublic | Static), lines 165-187 ForceDispatcherNull/RestoreDispatcher — the repository idiom P2-T4 mirrors +CITATION: UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs | line 469 typeof(UiThread) and line 471 the field name "_uiSyncContext" — a reflective UiThread consumer examined and excluded, unaffected by P2-T1 +CITATION: UtilitiesCS/Threading/WpfUiDispatcher.cs | line 14, and UtilitiesCS/Threading/ThreadMonitor.cs line 25, and UtilitiesCS/Threading/IUiDispatcher.cs line 13, and QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs line 14 — the four documentation references that make up the non-reflection remainder of the repository-wide "Dispatcher" literal census +CITATION: docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/baseline/p0-t11-quickfiler-tests.md | Total tests 1312, Passed 1312, Failed 0, Skipped 0, BASELINE_FAILURE_SET empty — the figure P4-T6's restated acceptance names +CITATION: docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence/qa-gates/p4-t6-quickfiler-tests.md | EXIT_CODE 1, Total tests 1312, Passed 1304, Failed 8, the eight failing EmailMoveMonitorTests names, and the executed BASE-versus-fixed counterfactual — the fail-before record for P2-T4 +CITATION: QuickFiler.Test/QuickFiler.Test.csproj, SVGControl.Test/SVGControl.Test.csproj, Tags.Test/Tags.Test.csproj, TaskMaster.Test/TaskMaster.Test.csproj, TaskTree.Test/TaskTree.Test.csproj, TaskVisualization.Test/TaskVisualization.Test.csproj, ToDoModel.Test/ToDoModel.Test.csproj, UtilitiesCS.Test/UtilitiesCS.Test.csproj, VBFunctions.Test/VBFunctions.Test.csproj | the nine test assemblies of this repository, the pathspec list P0-T14's FIRST command carries (its second and third commands carry the repository-wide `'*.cs'` pathspec instead, the second having been widened to it in revision round 16) +AC-INVENTORY: AC1, AC2, AC3, AC4, AC5, AC6, AC7 +AC-MAPPING: AC1 | IMPLEMENTATION: P2-T1 | TESTS: P1-T2, P1-T4, P3-T2 | EVIDENCE: evidence/regression-testing/p1-t4-expect-fail.md, evidence/regression-testing/p3-t2-regression-green.md +AC-MAPPING: AC2 | IMPLEMENTATION: P2-T1 | TESTS: P2-T2, P4-T4 | EVIDENCE: evidence/qa-gates/p2-t2-nullforgiving-removed.md, evidence/qa-gates/p4-t4-nullable-build.md +AC-MAPPING: AC3 | IMPLEMENTATION: P0-T3 (verification, no edit) | TESTS: P3-T4 | EVIDENCE: evidence/other/p3-t4-progresstrackerasync-unmodified.md +AC-MAPPING: AC4 | IMPLEMENTATION: P1-T5 (attribute-only, no assertion changed), P2-T4 (reflection-target-only, no assertion changed) | TESTS: P3-T3, P3-T6, P4-T6 | EVIDENCE: evidence/qa-gates/p1-t5-donotparallelize.md, evidence/regression-testing/p3-t3-at-risk-tests.md, evidence/regression-testing/p3-t6-quickfiler-wpfuidispatcher.md, evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md, evidence/regression-testing/p4-t6-first-pass-failure.md, evidence/qa-gates/p4-t6-quickfiler-tests.md +AC-MAPPING: AC5 | IMPLEMENTATION: P1-T2, P1-T5, P2-T1, P2-T4 | TESTS: P3-T5 | EVIDENCE: evidence/qa-gates/p3-t5-no-timing-tokens.md, evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md +AC-MAPPING: AC6 | IMPLEMENTATION: P4-T1, P4-T3, P4-T4 | TESTS: P4-T5, P4-T6 | EVIDENCE: evidence/qa-gates/p4-t1-format.md, evidence/qa-gates/p4-t2-format-check.md, evidence/qa-gates/p4-t3-analyzer-build.md, evidence/qa-gates/p4-t4-nullable-build.md, evidence/qa-gates/p4-t5-utilitiescs-tests.md, evidence/qa-gates/p4-t6-quickfiler-tests.md, evidence/qa-gates/p4-t8-loop-closure.md +AC-MAPPING: AC7 | IMPLEMENTATION: P2-T1 | TESTS: P4-T5, P4-T7 | EVIDENCE: evidence/baseline/p0-t10-utilitiescs-tests-coverage.md, evidence/qa-gates/p4-t7-coverage-delta.md +UNRESOLVED-GAPS: NONE + +DIRECTIVE: PREFLIGHT VALIDATION ONLY diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/policy-audit.2026-09-04T04-05.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/policy-audit.2026-09-04T04-05.md new file mode 100644 index 000000000..84fe6bceb --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/policy-audit.2026-09-04T04-05.md @@ -0,0 +1,436 @@ +# Policy Audit — uithread-dispatcher-null-race-progresstrackerasync (#584) + +- **Component:** `UtilitiesCS.Threading.UiThread.Dispatcher` accessor contract +- **Date:** 2026-09-04 +- **Timestamp:** 2026-09-04T04-05 +- **Work Mode:** `full-bug` (from `issue.md` line 3) — AC source is `spec.md` only +- **Base / merge-base:** `87cb4df338322844abfa580abea14df77e738e5c` +- **Branch:** `bug/uithread-dispatcher-null-race-progresstrackerasync-584` +- **Files under test (6, complete branch diff):** + 1. `UtilitiesCS/Threading/UiThread.cs` + 2. `UtilitiesCS.Test/Threading/UiThread_Tests.cs` + 3. `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` + 4. `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` + 5. `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` + 6. `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` + +**BLOCKING FINDINGS COUNT (this artifact): 0** + +--- + +## Executive Summary + +The change replaces a silent-null return on the public static `UiThread.Dispatcher` getter with an +explicit `InvalidOperationException`, removes the `null!` null-forgiving suppression, and redeclares +the backing field `Dispatcher?` so nullable flow analysis verifies the guard. Four MSTest classes +that mutate the process-global `UiThread._dispatcher` static are moved out of the parallel bucket +with `[DoNotParallelize]`, and one reflective consumer in `QuickFiler.Test` is retargeted from the +now-throwing property to the backing field. + +Verdict: **COMPLIANT** with documented non-blocking exceptions. All seven acceptance criteria are +supported by their cited evidence; several were independently re-derived by this review against the +worktree rather than accepted on the artifact's word. The full seven-stage toolchain is green in a +single final pass. Two non-blocking `FAIL` rows are recorded — repo-wide coverage below the uniform +floors, and the absence of the canonical coverage artifact path — both with dispositions explaining +why neither is a defect in this change. + +### Method deviations recorded for this review + +- **No shell commands were executed.** The delegating prompt prohibited the Bash tool on the basis + of a prior four-hour unattended hang (this session's allow-list does not match `git -C ` + forms, and the session cwd is a different checkout than the worktree under review). All verification + was performed with file-read and content-search tooling against absolute worktree paths. Checks + requiring command execution are marked UNVERIFIED with a reason; none of them is load-bearing for a + verdict. +- **Policy-audit template MCP asset unavailable.** `mcp__drm-copilot__resolve_policy_audit_template_asset` + is not exposed to this agent. Per the established fallback, this artifact is hand-authored while + preserving all thirteen canonical major headings from + `.claude/skills/policy-audit-template-usage/SKILL.md`. It is not marked BLOCKED. + `mcp__drm-copilot__validate_orchestration_artifacts` was likewise unavailable; step 6 of that skill + is UNVERIFIED. + +## Rejected Scope Narrowing + +None. The delegating prompt supplied the complete `87cb4df3..HEAD` name-status diff and directed a +full-branch audit. No instruction attempted to narrow scope to a plan, task, phase, or file subset, +and no language with changed files was marked out of scope. The prompt's guidance on point 1 +("do not raise a blocking finding that rests on comparing" the raw and first-party coverage +denominators) was assessed on its merits and found factually correct; it is a statement about +denominator comparability, not a scope narrowing. The coverage row is still recorded as `FAIL` +below, with a non-blocking disposition, rather than suppressed. + +## Evidence Location Compliance + +No violations. The branch diff contains six source files and no path under `artifacts/baselines/`, +`artifacts/qa/`, `artifacts/evidence/`, or `artifacts/coverage/`. A directory listing of +`artifacts/**` in the worktree returns only pre-existing `pr_body_*`, `pr_context.*`, and +`orchestration/orchestrator-state.json` entries, none of which is an evidence artifact of this +feature. All 34 evidence artifacts for this feature are under the canonical +`docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/evidence//` tree +(`baseline`, `regression-testing`, `qa-gates`, `other`, `issue-updates`). + +`validate_evidence_locations.py --root .` was not executed (no-shell constraint). The scan above was +performed by directory enumeration instead and is complete for the four prohibited prefixes. + +--- + +## 1. General Unit Test Policy Compliance + +| # | Requirement | Verdict | Evidence | +|---|---|---|---| +| 1.1 | Independence — order-independent | PASS | Both new tests in `UiThread_Tests.cs:133-177` capture the prior `_dispatcher` value and restore it in a `finally` block. `[DoNotParallelize]` on all four mutating classes (`p1-t5-donotparallelize.md`) removes concurrent interleaving, which capture/restore alone cannot address. | +| 1.2 | Isolation — one unit per test | PASS | Each new test exercises exactly one state of the `Dispatcher` getter (null field, populated field). | +| 1.3 | Fast execution | PASS | `p3-t2-regression-green.md`: 99 ms and 24 ms. Full `UtilitiesCS.Test` run 4787 tests; full `QuickFiler.Test` run 1312 tests, both green. | +| 1.4 | Determinism — no timing constructs | PASS | Independently verified by reading all 179 lines of `UiThread_Tests.cs`: no `Thread.Sleep`, `Task.Delay`, `SpinWait`, retry, `Timeout(`, or `PushFrame`. Corroborated by `p3-t5-no-timing-tokens.md` (94 added lines filtered, exit 1) and `p2-t4-emailmovemonitor-reflection-target.md` (2570-byte diff, exit 1). | +| 1.5 | Readability / documented intent | PASS | The new class carries a 15-line XML summary at `UiThread_Tests.cs:106-120` explaining why the backing field is the only viable seam. Test names state condition and expectation. | +| 1.6 | Arrange–Act–Assert | PASS | Both new tests are explicitly commented `// Arrange`, `// Act`, `// Assert`. | +| 1.7 | Clear failure messages | PARTIAL (non-blocking) | `UiThread_Tests.cs:138` asserts `field.Should().NotBeNull()`; the sibling test at `:164` omits that guard, so a future rename of `_dispatcher` surfaces as an `NullReferenceException` on `field.GetValue` rather than a named assertion failure. See finding F6. | +| 1.8 | No external dependencies | PASS | Reflection over an in-process static only. No network, DB, or external process. | +| 1.9 | No temporary files | PASS | No file I/O in any added test line. | +| 1.10 | Scenario completeness | PASS | Positive (populated field returns the same instance) and negative (null field throws, message asserted) are both covered. The state space of this accessor has exactly these two states. | +| 1.11 | Test file location mirrors source | PASS | `UtilitiesCS/Threading/UiThread.cs` -> `UtilitiesCS.Test/Threading/UiThread_Tests.cs`. | +| 1.12 | Coverage — line >= 85%, branch >= 75% | **FAIL (non-blocking)** | See section 5. Repo-wide raw figures 70.736% line / 46.79% branch. | +| 1.13 | No regression on changed lines | PASS | `p4-t7-coverage-delta.md`: 8 of 8 coverable added lines have `hits >= 1`. Changed-line coverage 100.0%. | +| 1.14 | Coverage Exclusion Policy — no production file excluded | PASS | The diff adds no `exclude` entry to `coverage.config` or any other configuration. No `[ExcludeFromCodeCoverage]` attribute is added. | +| 1.15 | Determinism infrastructure (no banned APIs in tests) | PASS | Verified by direct read. The pre-existing `new Thread(...)`/`Join()` in `EmailMoveMonitorTests.cs:281-291` is untouched by this change and is a marshal-target simulation, not a wall-clock wait. | + +## 2. General Code Change Policy Compliance + +| # | Requirement | Verdict | Evidence | +|---|---|---|---| +| 2.1 | Simplicity first | PASS | The production change is one null guard plus a field-type change. No indirection introduced. | +| 2.2 | Reusability | PASS | The guard is placed at the single accessor rather than duplicated across the ~40 reading call sites — the explicit rationale in `spec.md` "Data flow and validation changes". | +| 2.3 | Extensibility / no gratuitous API break | PASS with call-out | `UiThread.Dispatcher`'s public type is unchanged (`Dispatcher`, non-nullable). The behavioural change (throw where null was returned) is a public-API behaviour break and is called out explicitly in `spec.md` "Backward-compatibility expectations", satisfying §7.2's requirement that a necessary break be stated clearly. Blast radius established — see section 8, finding B1. | +| 2.4 | Separation of concerns | PASS | Pure guard logic; no I/O added. | +| 2.5 | Error handling — fail fast and explicitly | PASS | `InvalidOperationException` with a message naming both `UiThread.Init()` and `UiThread.Initialize()`, matching the precedent at `UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs:62-66`. No silent swallow. | +| 2.6 | Logging | PASS (N/A) | No logging added or removed; existing catch sites already log generically. | +| 2.7 | Contracts / invariants enforced at access | PASS | The invariant previously asserted only in a trailing prose comment (`// set in Initialize() before any access`) is now enforced in code, and that comment is correctly removed. | +| 2.8 | Module cohesion | PASS | All six changed files remain single-purpose. | +| 2.9 | File size limit — 500 lines | **PARTIAL (non-blocking)** | `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` is 514 lines. See finding F3. The other five files are 172, 179, 348, 206, and 320 lines. | +| 2.10 | Naming | PASS | `DispatcherField`, `_dispatcher`, `UiThread_Dispatcher_Tests` follow repo convention. | +| 2.11 | Comment *why*, not *what* | PASS | The six added comment lines at `EmailMoveMonitorTests.cs:33-37` explain precisely why the field is read instead of the property (`PropertyInfo.GetValue` would surface the guard as `TargetInvocationException` from setup/teardown). This is a model instance of the rule. | +| 2.12 | Dependencies — no new packages | PASS | No `using` directive and no package reference added; `DoNotParallelize` resolves from the existing `Microsoft.VisualStudio.TestTools.UnitTesting` import in all four files. | +| 2.13 | I/O boundaries | PASS (N/A) | No I/O in the change. | +| 2.14 | Toolchain loop, in order, single clean pass | PASS | `p4-t8-loop-closure.md` records two Phase-4 passes chronologically. Pass 1 failed at P4-T6 (8 of 1312); P2-T4 then rewrote a tracked file, so every step was re-run in order. Pass 2 is green end to end with no tracked-file rewrite after P4-T1. This is the correct restart-from-step-1 behaviour, not a shortcut. | +| 2.15 | Bugfix workflow — failing regression test first | PASS | `p1-t4-expect-fail.md` records a genuine RED: `Failed: 1` with the verbatim FluentAssertions message "Expected a `` to be thrown, but no exception was thrown", at `UiThread_Tests.cs:150`, against a tree that `p1-t3-build-before-fix.md` had just built with `0 Error(s)`. The sibling positive test passed in the same run, proving the harness works and the red is attributable to the defect. This is a provable assertion-level RED-first, not a compile-red. | +| 2.16 | Minimal targeted fix, no opportunistic refactor | PASS | Production diff is confined to one property and one field declaration. `ProgressTrackerAsync.cs` was verified unmodified (`p3-t4-progresstrackerasync-unmodified.md`: empty `--cached` name-status and empty porcelain for that path). | +| 2.17 | Deeper design problems opened as issues, not widened scope | PARTIAL (non-blocking) | The `IUiDispatcher` seam conversion is deferred on the GitHub issue thread. The second follow-up (synchronizing `ProgressTrackerAsync_Tests.cs`'s reflective static mutation) exists only as feature-folder prose. See finding F5. | + +## 3. Language-Specific Code Change Policy Compliance (C#) + +| # | Requirement | Verdict | Evidence | +|---|---|---|---| +| 3.1 | CSharpier format, pinned via `dotnet tool run` | PASS | `p4-t1-format.md` `EXIT_CODE: 0`, `Formatted 6 files`, byte-identical before/after unscoped porcelain. `p4-t2-format-check.md` `EXIT_CODE: 0`, `Checked 1576 files`, empty reported set, run over `.` (full repo, CI parity). | +| 3.2 | Analyzer build, `/t:Rebuild`, analyzers + code style enforced | PASS | `p4-t3-analyzer-build.md` `EXIT_CODE: 0`, `0 Warning(s)`, `0 Error(s)`. | +| 3.3 | Nullable / type-check build, `/t:Rebuild`, `TreatWarningsAsErrors` | PASS | `p4-t4-nullable-build.md` `EXIT_CODE: 0`, `0 Warning(s)`, `0 Error(s)`. Command verified to use `/t:Rebuild` and to contain no `Nullable=enable` substring, matching CLAUDE.md and `ci.yml` character-for-character. This gate is non-vacuous here: `UiThread.cs:1` carries `#nullable enable` (verified directly), so a getter returning `Dispatcher?` as `Dispatcher` without narrowing would raise `CS8603` and fail the build. | +| 3.4 | Null-safety by default | PASS | The `null!` suppression is removed. Independently verified: `UiThread.cs:149` reads `private static Dispatcher? _dispatcher;` and no `null!` remains in that file. | +| 3.5 | Strong contracts / explicit public API | PASS | Getter contract is now total: returns a non-null `Dispatcher` or throws. | +| 3.6 | MSTest / Moq / FluentAssertions | PASS | New tests use `[TestClass]`, `[TestMethod]`, and FluentAssertions (`Should().Throw().WithMessage(...)`, `Should().BeSameAs(...)`). No xUnit or NUnit introduced. | +| 3.7 | Prefer `internal`, minimal public surface | PASS (N/A) | No new public member. | +| 3.8 | XML docs where contract is non-obvious | PASS | The new test class carries a full ``. The production getter's contract is self-evident from the guard and its message; no XML doc regression. | +| 3.9 | Suppressions narrow and documented | PASS | The change *removes* a suppression and adds none. | + +## 4. Language-Specific Unit Test Policy Compliance (C#) + +| # | Requirement | Verdict | Evidence | +|---|---|---|---| +| 4.1 | MSTest framework | PASS | `Microsoft.VisualStudio.TestTools.UnitTesting` throughout. | +| 4.2 | FluentAssertions preferred | PASS | All assertions in the added code are FluentAssertions. | +| 4.3 | Moq for mocking | PASS (N/A) | The unit under test is a static accessor; reflection over the backing field is the correct and pre-established seam (the identical idiom exists at `IdleAsyncQueue_Tests.cs:144`, `ProgressTracker_Tests.cs:421`, `ProgressTrackerAsync_Tests.cs:138`, `QfcItemController.UiThreadDispatcherFixture.cs:136`). | +| 4.4 | `vstest.console.exe` with coverage | PASS | `p4-t5-utilitiescs-tests.md` runs `dotnet-coverage collect --output-format cobertura -- vstest.console.exe ...`. Cobertura written to `coverage/p4-t5.cobertura.xml`, confirmed present on disk. | +| 4.5 | Assertion count / test count preserved on modified fixtures | PASS | `p2-t4-emailmovemonitor-reflection-target.md`: `[TestMethod]` count exactly 8 (unchanged); zero added or removed lines carry `.Should()`. Independently corroborated by reading all 320 lines of that file — the eight test bodies are intact and the sole assertion in the touched region, `current.Should().BeSameAs(_capturedDispatcher);` at line 65, is unaltered. | + +## 5. Test Coverage Detail + +### 5.1 Coverage artifact resolution + +| Language | Changed files in branch diff | Canonical artifact | Present | Substitute used | +|---|---|---|---|---| +| C# | 6 | `artifacts/csharp/coverage.xml` | **No** | `coverage/p4-t5.cobertura.xml` (post-change) and `coverage/p0-t10.cobertura.xml` (baseline), both confirmed present on disk; figures transcribed into `p4-t5-utilitiescs-tests.md` and `p0-t10-utilitiescs-tests-coverage.md` | +| PowerShell | 0 | `artifacts/pester/powershell-coverage.xml` | No | not required | +| Python | 0 | `artifacts/python/lcov.info` | No | not required | +| TypeScript | 0 | `coverage/lcov.info` | No | not required | + +### 5.2 Coverage verdicts + +- **C# coverage: FAIL** — non-blocking disposition (see below). +- **PowerShell coverage: PASS** — not applicable; zero PowerShell files in the branch diff, so no coverage obligation is triggered. +- **Python coverage: PASS** — not applicable; zero Python files in the branch diff. +- **TypeScript coverage: PASS** — not applicable; zero TypeScript files in the branch diff. + +C# detail: + +| Metric | Baseline (P0-T10) | Post-change (P4-T5) | Floor | Row verdict | +|---|---|---|---|---| +| Repo-wide line rate | 0.7073317347831605 | 0.7073603942281368 | >= 0.85 | FAIL | +| Repo-wide branch rate | not recorded at baseline | 0.46792920353982304 | >= 0.75 | FAIL | +| `lines-covered` | 105901 | 105935 | — | +34 | +| `lines-valid` | 149719 | 149761 | — | +42 | +| Changed-line coverage (new code) | — | 100.0% (8 of 8) | >= 90% | PASS | +| Regression on changed lines | — | none | none | PASS | +| Line-rate regression | — | +0.0000286594 | none | PASS | + +**Disposition — non-blocking.** Three independent reasons, each verified: + +1. **Denominator non-comparability.** Both figures are raw, unstripped `dotnet-coverage` line rates + for the entire `UtilitiesCS.Test` host process, including third-party and interop assemblies. + CLAUDE.md's 80% and `quality-tiers.md`'s 85%/75% govern the repository's first-party testable + denominator after stripping. The two are different denominators and a direct comparison is not + valid evidence of a policy breach. This is a recurring, previously-confirmed property of raw + all-assembly merges in this repository. +2. **Pre-existing.** The baseline figure at BASE `87cb4df3` is 0.70733, already below the floor. The + shortfall predates the branch and is not caused by it. `p4-t7-coverage-delta.md` records this + explicitly as `PRE-EXISTING FLOOR SHORTFALL`. +3. **Direction of travel.** The change moves the figure upward by +0.0000287 and achieves 100% + coverage on the eight coverable added production lines, including all three lines of the new + `throw` statement (`hits = 1`), proving the new failure branch is executed rather than merely + compiled. + +No remediation-inputs artifact is emitted on this row. The correct disposition is procedural +(the canonical stripped first-party figure is produced by the PR CI run), not a code defect in this +change. + +### 5.3 Per-file coverage of new/modified code + +| File | Classification | Coverage evidence | +|---|---|---| +| `UtilitiesCS/Threading/UiThread.cs` | modified (production) | 8 of 8 coverable added lines (138-143, 145, 146) at `hits >= 1`. Added lines 137, 144, 149 carry no `` element (accessor header, closing brace, field declaration) and are correctly excluded from the denominator as non-sequence-points. | +| `UtilitiesCS.Test/Threading/UiThread_Tests.cs` | modified (test) | Test code; excluded from the production denominator by policy. | +| `IdleAsyncQueue_Tests.cs`, `ProgressTrackerAsync_Tests.cs`, `ProgressTracker_Tests.cs` | modified (test, attribute-only) | Attribute-only; no executable line added. | +| `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` | modified (test) | Test code; six comment lines and one reflection lookup retargeted. | + +## 6. Test Execution Metrics + +| Run | Artifact | Total | Passed | Failed | Skipped | Exit | +|---|---|---|---|---|---|---| +| Baseline `UtilitiesCS.Test` | `p0-t10-utilitiescs-tests-coverage.md` | 4785 | 4785 | 0 | 0 | 0 | +| RED (pre-fix, 2 new tests) | `p1-t4-expect-fail.md` | 2 | 1 | **1** | — | 1 (expected) | +| GREEN (post-fix, 2 new tests) | `p3-t2-regression-green.md` | 2 | 2 | 0 | — | 0 | +| At-risk subset | `p3-t3-at-risk-tests.md` | 41 | 41 | 0 | 0 | 0 | +| RED (pass 1, `QuickFiler.Test`) | `p4-t6-first-pass-failure.md` | 1312 | 1304 | **8** | — | 1 | +| Final `UtilitiesCS.Test` | `p4-t5-utilitiescs-tests.md` | 4787 | 4787 | 0 | 0 | 0 | +| Final `QuickFiler.Test` | `p4-t6-quickfiler-tests.md` | 1312 | 1312 | 0 | 0 | 0 | + +Test-count delta 4785 -> 4787 equals exactly the two added tests. `QuickFiler.Test` count is +unchanged at 1312, consistent with the attribute/reflection-only nature of that file's change. +Both final runs report an empty `FAILING_TEST_SET` and a `BASELINE_FAILURE_SET` that was already +empty, so no pre-existing failure is being masked. + +The two at-risk "dispatcher unavailable" tests — +`AddEntry_UseUiThreadTrue_DequeuesEntryAndSuppressesDispatcherException` and +`YieldAsync_WithoutDispatcher_RemainsStrict` — both still pass against the throwing accessor, +confirming their existing handling absorbs `InvalidOperationException` as it previously absorbed +`NullReferenceException`. This is the empirical half of the blast-radius argument. + +## 7. Code Quality Checks + +| Check | Command | Result | Artifact | +|---|---|---|---| +| Format (apply) | `dotnet tool run csharpier format .` | exit 0, `Formatted 6 files` | `p4-t1-format.md` | +| Format (verify) | `dotnet tool run csharpier check .` | exit 0, `Checked 1576 files`, empty set | `p4-t2-format-check.md` | +| Analyze | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` | exit 0, 0 Warning, 0 Error | `p4-t3-analyzer-build.md` | +| Type-check | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` | exit 0, 0 Warning, 0 Error | `p4-t4-nullable-build.md` | +| Test + coverage | `dotnet-coverage collect ... -- vstest.console.exe ...` | exit 0 | `p4-t5-*.md`, `p4-t6-*.md` | +| Architecture-boundary tests | — | UNVERIFIED | No dependency-cruiser / NetArchTest gate is configured for this solution; none exists to run. Not a gap introduced by this change. | +| Contract / schema checks | — | PASS (N/A) | No published contract or schema is touched. | +| Integration tests | — | PASS (N/A) | `TestCategory!=LiveOutlook` filter applied per repo standard; no integration surface changed. | + +All toolchain results above are transcribed from committed evidence artifacts. This review did not +re-execute them (no-shell constraint) and did not need to: the evidence records exit codes, verbatim +summary blocks, and TRX `` values, and the head-state facts they assert (field declaration, +`null!` absence, attribute placement, assertion counts, line counts) were independently re-derived +against the worktree and all matched. + +## 8. Gaps and Exceptions + +### B1 — Public-API behaviour break: blast radius assessment + +The prompt asked whether the blast radius was actually established. It was, and this review +independently reproduced and then *extended* the census. + +Three distinct routes can read `UiThread.Dispatcher`: + +1. **Qualified member expression** (`UiThread.Dispatcher`). Enumerated by the plan's repo-wide + `git grep -n "UiThread.Dispatcher\b"` and recorded in `spec.md` Root Cause Analysis. Every hit was + checked against its nearest test coverage; each either sits inside a broad `catch (Exception)` or + already fails unhandled with `NullReferenceException` and will now fail unhandled with + `InvalidOperationException` — a strictly clearer failure of the same unhandled-ness, not a new one. +2. **Reflective property read.** This is the route the original census missed, and the miss + *materialised* as a real regression (8 of 1312 in `EmailMoveMonitorTests`) rather than being + theorised. `p0-t14-reflective-dispatcher-census.md` then ran the missing census across all nine + test assemblies and repository-wide over `.cs`. **Independently reproduced by this review:** a + repository-wide search for the string literal `"Dispatcher"` in `*.cs` now returns exactly four + hits, all XML `` documentation cross-references + (`WpfUiDispatcher.cs:14`, `WpfUiDispatcherTests.cs:14`, `ThreadMonitor.cs:25`, + `IUiDispatcher.cs:13`). A search for `GetProperty(` with a `"Dispatcher"` operand returns **zero** + hits. The single reflective property read is gone. A search for `"_dispatcher"` returns six hits, + all reflective *field* reads, matching the census exactly plus the new test helper. +3. **`using static` import of `UiThread`.** The census did **not** enumerate this route. This review + closed that gap: a repository-wide search for `using static .*UiThread` returns zero hits, so no + file can read `Dispatcher` as an unqualified identifier. The census's conclusion therefore holds + even accounting for the route it did not itself consider. + +**Verdict on B1: the census supports the claim.** No production file reads `UiThread.Dispatcher` +reflectively, no production file depends on a silent-null outcome distinct from a generic-exception +outcome, and the one file that did depend on the old behaviour was found, repaired, and proven green +with a recorded fail-before/pass-after pair naming all eight test methods on both sides. + +### F1 — Repo-wide C# coverage below the uniform floors — **FAIL, non-blocking** + +Detailed in section 5.2. Line 70.736% vs 85%; branch 46.79% vs 75%. Raw unstripped process-wide +denominator, pre-existing at BASE, improved by this change, 100% changed-line coverage. + +### F2 — Canonical C# coverage artifact absent — **FAIL, non-blocking** + +`artifacts/csharp/coverage.xml` does not exist in the worktree. The substance of the requirement is +met by `coverage/p4-t5.cobertura.xml` and `coverage/p0-t10.cobertura.xml`, both present on disk, both +with their root-element figures transcribed into committed evidence, and the post-change document was +parsed per-class to produce the changed-line intersection in `p4-t7-coverage-delta.md`. Recorded as +FAIL per the absence rule; disposition non-blocking because the evidence the rule exists to obtain is +demonstrably present in an equivalent, verifiable form. + +### F3 — `ProgressTracker_Tests.cs` exceeds the 500-line limit — **PARTIAL, non-blocking** + +- File: `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs`, 514 lines. +- Rule: `.claude/rules/general-code-change.md` "File Size Limit" — 500 lines, test code not excepted. +- Evidence: `p0-t13-parallel-bucket-census.md` (baseline 514), `p2-t3-file-size.md` (head 514), + `p4-t8-loop-closure.md` (post-format 514). +- **Disposition:** the overage is pre-existing at BASE at exactly 514 lines and the branch delta is + **zero** — the change did not cross the cap and did not deepen the breach. Under the established + crossing-versus-pre-existing severity split, a crossing is blocking and a pre-existing overage that + is merely extended is non-blocking; this case is weaker still, since nothing was added. Recorded + prominently rather than passed silently. Recommendation: extract a cohesive group of test methods + into a `partial class` in a sibling file as a separate follow-up, with no test weakening. + +### F4 — PR context artifacts are stale and describe a different feature — **PARTIAL, non-blocking** + +`artifacts/pr_context.summary.txt` reports `Head SHA: e5dcbffd6a51e9f92869c390e85d179400657cd5`, +`Head ref (resolved): bug/invoke-mstestwithcoverage-threshold-before-setcontent-565`, and +`Merge base: 87233f867ad60c0a5c0d19b09cc121ae536d7ba1`. None of these belongs to this feature: the +branch is `bug/uithread-dispatcher-null-race-progresstrackerasync-584` and the merge base is +`87cb4df338322844abfa580abea14df77e738e5c`. Its appendix pointer additionally names a different +worktree. These files are tracked and were carried in from `main` by another cohort item; the six-file +branch diff does not touch them. + +Regeneration was not possible in this session (no-shell constraint, and the PR-context MCP tool is +not exposed to this agent). Scope was therefore derived from the two legitimate authoritative +sources that remained: the resolved base branch `87cb4df3` and the orchestrator-supplied, directly +verified `git diff --name-status 87cb4df3..HEAD` name-status listing, cross-checked against the +plan's declared write set, `p3-t4-progresstrackerasync-unmodified.md`'s staged footprint, and direct +on-disk reads of all six files. The audit scope is the full branch diff; this deviation narrows +nothing. + +### F5 — Deferred follow-up recorded only as feature-folder prose — **Minor, non-blocking** + +`spec.md` "Rollout & Follow-up" item 1 (add synchronization or an `IUiDispatcher` seam around +`UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`'s unsynchronized reflective mutation of +`UiThread._dispatcher`, mirroring the fixture-level fix applied for #493 in `QuickFiler.Test`) exists +only in this feature folder, which is removed on merge. Item 2 (the `IUiDispatcher` seam conversion) +is already durable on the GitHub issue thread. Recommendation: promote item 1 to a GitHub issue +before merge so the residual survives. + +### F8 — `[DoNotParallelize]` census scope — **Low, non-blocking, closed by this review** + +`p1-t5-donotparallelize.md` establishes that "zero writers of that field remain in the parallel +bucket", but its census is over the string literal `"_dispatcher"`, i.e. reflective writers only. It +does not enumerate the *production* writer path: the `UiThread.UiSyncContext` and +`UiThread.AutoScaleFactor` getters call `Init()` when their own backing field is null, and `Init()` +runs `Initialize()`, which assigns `Dispatcher`. A parallel-bucket test that reached either property +could therefore write `_dispatcher` concurrently. + +This review verified the gap is closed in fact: + +- No file in `UtilitiesCS.Test` reads `UiThread.Init(`, `UiThread.UiSyncContext`, or + `UiThread.AutoScaleFactor` directly. The only matches are documentation comments and the two new + `UiThread.Dispatcher` reads in the new tests. +- The two production readers of `UiThread.UiSyncContext` are `ThreadMonitor.cs:143` and + `FolderPredictor.cs:178`. `ThreadMonitorTests` already carries `[DoNotParallelize]` + (`ThreadMonitorTests.cs:18`). The test that drives `FolderPredictor.cs:178`, + `FolderPredictorTests.EnterUiContextAsync_WhenUiSyncContextPostsSynchronously_CompletesUsingDefaultAction`, + sets `_uiSyncContext` reflectively at `FolderPredictorTests.cs:479` before the call, so the lazy + `Init()` branch is never taken. +- The two `UiThread.AutoScaleFactor` readers are WinForms viewer paint paths not exercised in + `UtilitiesCS.Test`. + +The isolation guarantee therefore holds, but it holds partly by coincidence of an unrelated test's +arrangement rather than by construction. Informational; no action required in this change. + +### UNVERIFIED items + +| Item | Reason | +|---|---| +| `validate_evidence_locations.py --root .` execution | No-shell constraint. Substituted by a complete directory enumeration of the four prohibited `artifacts/` prefixes, which found nothing. | +| `mcp__drm-copilot__validate_orchestration_artifacts` on this artifact | MCP tool not exposed to this agent. Canonical headings preserved manually. | +| Architecture-boundary test stage | No such gate is configured for this solution. | +| Independent re-execution of csharpier / msbuild / vstest | No-shell constraint. All head-state *facts* those gates assert were independently re-derived by direct file read and matched. | + +## 9. Summary of Changes + +| File | Nature | Lines (base -> head) | +|---|---|---| +| `UtilitiesCS/Threading/UiThread.cs` | Production fix: null guard in the `Dispatcher` getter; `null!` removed; backing field -> `Dispatcher?` | 163 -> 172 | +| `UtilitiesCS.Test/Threading/UiThread_Tests.cs` | New `UiThread_Dispatcher_Tests` class: two deterministic regression tests + `DispatcherField()` helper | 104 -> 179 | +| `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` | Attribute-only: `[DoNotParallelize]` | 347 -> 348 | +| `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` | Attribute-only: `[DoNotParallelize]` | 205 -> 206 | +| `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` | Attribute-only: `[TestClass, DoNotParallelize]` (combined form, no line added) | 514 -> 514 | +| `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` | Reflective snapshot retargeted property -> field; six explanatory comment lines | 314 -> 320 | + +Six files. No production file other than `UiThread.cs`. No path under `.claude/`, `.codex/`, +`.agents/`, or `config/`. + +## 10. Compliance Verdict + +**COMPLIANT — with documented non-blocking exceptions.** + +**BLOCKING FINDINGS: 0** + +| ID | Severity | Blocking | Summary | +|---|---|---|---| +| F1 | FAIL | No | Repo-wide C# line 70.736% / branch 46.79% below 85%/75% floors (raw unstripped denominator; pre-existing; improved by this change) | +| F2 | FAIL | No | Canonical `artifacts/csharp/coverage.xml` absent; equivalent Cobertura documents present and parsed | +| F3 | PARTIAL | No | `ProgressTracker_Tests.cs` 514 lines > 500; pre-existing at BASE, branch delta 0 | +| F4 | PARTIAL | No | `artifacts/pr_context.*` stale, describe issue #565; scope derived from verified git range instead | +| F5 | Minor | No | Deferred follow-up recorded only as feature-folder prose; promote to a GitHub issue | +| F8 | Low | No | `[DoNotParallelize]` census covered reflective writers only; indirect production writer path verified closed by this review | + +No remediation-inputs artifact is produced: no finding is remediation-required. + +--- + +## Appendix A: Test Inventory + +New tests (2), `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, class `UiThread_Dispatcher_Tests` +(`[TestClass]`, `[DoNotParallelize]`): + +| Test | Scenario | Assertion | +|---|---|---| +| `Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize` (`:134`) | Negative / error-handling. `_dispatcher` forced null via reflection, restored in `finally`. | `act.Should().Throw().WithMessage("*UiThread.Initialize()*")` | +| `Dispatcher_WhenBackingFieldIsPopulated_ReturnsThatSameInstance` (`:161`) | Positive. `_dispatcher` set to a known instance, restored in `finally`. | `UiThread.Dispatcher.Should().BeSameAs(expected)` | + +Helper: `DispatcherField()` (`:125`) — `typeof(UiThread).GetField("_dispatcher", NonPublic | Static)`. + +Modified test classes (attribute-only, assertions untouched): `IdleAsyncQueue_Tests`, +`ProgressTrackerAsync_Tests`, `ProgressTracker_Tests`. + +Modified test class (reflection target only, assertions untouched): `EmailMoveMonitorTests` — +8 `[TestMethod]` before and after, all 8 named as passing in `p4-t6-quickfiler-tests.md`. + +## Appendix B: Toolchain Commands Reference + +```text +1. dotnet tool run csharpier format . + dotnet tool run csharpier check . +2. msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" \ + /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +3. msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" \ + /p:TreatWarningsAsErrors=true +4. dotnet-coverage collect --output coverage/p4-t5.cobertura.xml --output-format cobertura \ + --settings coverage.config -- vstest.console.exe UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll \ + /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /Logger:trx \ + /ResultsDirectory:TestResults/p4-t5 /TestCaseFilter:TestCategory!=LiveOutlook + vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /InIsolation /Logger:trx \ + /ResultsDirectory:TestResults/p4-t6 /TestCaseFilter:TestCategory!=LiveOutlook +``` + +Verified: `/t:Rebuild` used in both msbuild steps (not the vacuous warm `/t:Build`), and no +`/p:Nullable=enable` is present, matching CLAUDE.md and `.github/workflows/ci.yml`. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/research/defect-scoping.2026-09-02T09-02.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/research/defect-scoping.2026-09-02T09-02.md new file mode 100644 index 000000000..bdef14a10 --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/research/defect-scoping.2026-09-02T09-02.md @@ -0,0 +1,189 @@ +# Defect scoping research — #584 uithread-dispatcher-null-race-progresstrackerasync + +Timestamp: 2026-09-02T09-02 +Verified against: `origin/main` at commit `5ebaaf105d8241f309f704d1ff90af2e32e5a6c1` + +## Why this research was needed + +The GitHub issue body's `Environment`, `Steps to Reproduce`, `Expected Behavior`, and +`Actual Behavior` sections are empty (`(not provided in potential file)`). The issue's own comment +thread explains why: `mcp__drm-copilot__potential_to_issue` extracts only the `## Summary` section +from a potential document; the fuller analysis was posted as an issue comment instead. This +research establishes the defect directly against current `origin/main` rather than trusting the +issue body alone. + +## Step 1 — Confirm the defect exists on `origin/main` + +Command: `git show origin/main:UtilitiesCS/Threading/UiThread.cs` + +Result (lines 135-140 of the file as committed): + +```csharp +public static Dispatcher Dispatcher +{ + get => _dispatcher; + private set => _dispatcher = value; +} +private static Dispatcher _dispatcher = null!; // set in Initialize() before any access +``` + +Confirmed: no null guard, no lazy initialisation. Contrast with the same file's `UiSyncContext` +(lines ~122-131) and `AutoScaleFactor` (lines ~148-158), both of which check +`if (_field is null) { Init(); }` before returning. `Dispatcher` is the only one of the three +UI-thread-state properties in this class without that pattern — direct evidence the omission is an +inconsistency rather than a deliberate design choice. + +Command: `git show origin/main:UtilitiesCS/Threading/ProgressTrackerAsync.cs` + +Result (lines 31-35): + +```csharp +public async Task InitializeAsync() +{ + UiDispatcher = UiThread.Dispatcher; + + await UiDispatcher.InvokeAsync(() => +``` + +Confirmed: `UiDispatcher` (a `Dispatcher`-typed field on `ProgressTrackerAsync`, unrelated to +`UiThread`'s own dispatcher field) is assigned directly from `UiThread.Dispatcher` and dereferenced +on the following line with no guard. If `UiThread.Dispatcher` is `null`, `UiDispatcher.InvokeAsync` +throws `NullReferenceException` here — matching the issue body's reported stack location +(`ProgressTrackerAsync.cs:35`). + +## Step 2 — Enumerate every production consumer of `UiThread.Dispatcher` + +Command: `git grep -n "UiThread.Dispatcher\b" -- '*.cs'` (repo root, excluding `obj/`) + +Full result set (production, non-test files only): + +- `UtilitiesCS/HelperClasses/ThemeHelpers/Theme.cs:441` — commented out, not live code. +- `UtilitiesCS/HelperClasses/ThemeHelpers/ThemeControlGroup.cs:218,222` — unguarded direct calls. +- `UtilitiesCS/HelperClasses/ToolTips/QfcTipsDetails.cs:254,277` — unguarded direct calls. +- `UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs:46,57` — already guarded; see Step 3. +- `UtilitiesCS/Threading/IdleActionQueue.cs:78` — unguarded, but the caller + (`OnApplicationIdle`-style dispatch) wraps in `catch (Exception ex)`; see Step 4. +- `UtilitiesCS/Threading/IdleAsyncQueue.cs:72` — unguarded, wrapped in `catch (Exception ex)`; + see Step 4 (this is the file with the pre-existing `ForceDispatcherNull` test helper the new + regression test's technique is modeled on). +- `UtilitiesCS/Threading/ProgressTracker.cs:33,39` — unguarded direct calls (synchronous sibling of + `ProgressTrackerAsync`; same shape, not named as a fix site by the assignment, not touched here). +- `UtilitiesCS/Threading/ProgressTrackerAsync.cs:33,39` — the reported fix site; see Step 1. +- `UtilitiesCS/Threading/ProgressTrackerPane.cs:13,16` — unguarded direct calls. +- `UtilitiesCS/Threading/WpfUiDispatcher.cs:25` — default constructor argument + `: this(() => UiThread.Dispatcher)`; the lambda is only invoked when the seam's default provider + is used, and the seam already exists specifically to let callers substitute a test double instead. +- `QuickFiler/Controllers/EfcHomeController.cs:297`, `EfcItemController.cs:998,1007`, + `KeyboardHandler.cs:362,370,401`, `QfcCollectionController.cs` (8 call sites), + `QfcFormController.Actions.cs:255`, `QfcFormController.EventHandlers.cs:197,237,242`, + `QfcHomeController.cs:360`, `QfcQueue.cs:474,482,490`, + `Helper Classes/ConversationResolver.Loading.cs:150,320`, + `Helper Classes/EfcViewerQueue.cs:20,67`, `Helper Classes/EmailMoveMonitor.cs:40`, + `Helper Classes/ItemViewerQueue.cs:21,27,88,90` — all unguarded direct calls; these are live + production UI-thread marshalling call sites reachable only from a running VSTO/Outlook host where + `UiThread.Init()` has already run during add-in startup (per `TaskMaster/ThisAddIn.cs:190,227`), + not from a headless unit test host. +- `TaskMaster/AppGlobals/ApplicationGlobals.cs:293`, + `TaskMaster/AppGlobals/AppOlObjects.FolderTreeService.cs:344`, + `TaskMaster/Ribbon/RibbonViewer.EngineCommands.cs:71,114`, `TaskMaster/ThisAddIn.cs:227` — + same category as above; production host-startup code. + +Conclusion: none of the unguarded production call sites require a code change for this fix. Every +one of them today either (a) is inside VSTO/Outlook host code that only executes after +`UiThread.Init()` has run at add-in startup, so it is not exposed to the pre-`Init()` null state at +all, or (b) is inside `IdleAsyncQueue`/`IdleActionQueue`'s dispatch loop, which already wraps the +call in a broad `catch (Exception ex)` (see Step 4). + +## Step 3 — Existing repository precedent for the fix shape + +`UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs:57-66`: + +```csharp +// UiThread.Dispatcher is set-once state populated by UiThread.Init() and is null +// outside a live host, so that null state is surfaced as InvalidOperationException to +// preserve the strict contract callers relied on. +Dispatcher? dispatcher = + _currentThreadDispatcherProvider() ?? _fallbackDispatcherProvider(); +if (dispatcher is null) +{ + throw new InvalidOperationException( + "The UI dispatcher has not been captured. Call UiThread.Init() before yielding folder tree work." + ); +} +``` + +This file's `_fallbackDispatcherProvider` field is typed `Func` and its production +default is `() => UtilitiesCS.UiThread.Dispatcher` — i.e., this call site already treats +`UiThread.Dispatcher`'s return value as potentially null at runtime, despite the property's +non-nullable compile-time signature. This is the strongest available evidence that the correct fix +shape for #584 is to push this same `InvalidOperationException` contract into `UiThread.Dispatcher` +itself, rather than requiring every consumer to duplicate the guard. + +Archived issue #508 (`docs/features/archive/2026-08-08-wpf-dispatcher-yield-test-order-dependent-508/issue.md`) +independently confirms this precedent: it fixed `WpfDispatcherYieldTests`'s dependence on ambient +`UiThread.Dispatcher` state via an injectable seam, explicitly preserved the +`InvalidOperationException` contract as a stated acceptance criterion (AC2), and recorded "any +broader refactor of `UiThread` static state" as out of that issue's scope — i.e., the maintainer +already flagged the `UiThread.Dispatcher` accessor itself as a known, deliberately deferred fix +site, which #584 is. + +## Step 4 — Verify no existing test depends on a silent-null (non-throwing) `UiThread.Dispatcher` + +`UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`, +`AddEntry_UseUiThreadTrue_DequeuesEntryAndSuppressesDispatcherException` (read in full): + +- Forces `UiThread._dispatcher` to `null` via reflection (its own `ForceDispatcherNull` helper). +- Asserts `actDelegate.Should().NotThrow(...)` around `InvokeOnIdle()`, whose target method + (`IdleAsyncQueue.OnApplicationIdle`) wraps the `UiThread.Dispatcher.InvokeAsync(...)` call in + `catch (Exception ex)`. +- Asserts `callCount.Should().Be(0, "action must not run when the UiThread Dispatcher is + unavailable")`. +- Does **not** assert on the concrete exception type. The doc-comment on the test explicitly says + "the `NullReferenceException` is caught by the internal try/catch," but the assertion itself is + type-agnostic (`NotThrow()` on the wrapping delegate, not `Should().Throw()` + on the inner call). Converting the thrown type to `InvalidOperationException` at the source + changes nothing this test can observe. + +`UtilitiesCS.Test/OutlookObjects/Folder/WpfDispatcherYieldTests.cs` (read in full, 4 test methods): + +- All four tests construct `WpfDispatcherYield` with explicit injected `Func` provider + delegates (`CountingDispatcherProvider`). None of them exercises the parameterless constructor + `WpfDispatcherYield()`, so none of them ever calls the real `UiThread.Dispatcher` property. The + fix under #584 cannot affect these tests' outcomes. + +`UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderTreeServiceConcurrencyTests.cs`, +`GetSnapshotAsync_WorkerOriginatedColdBuild_UsesCapturedStaDispatcher` (read in full): + +- Uses `new WpfDispatcherYield()` (the parameterless constructor, so its fallback provider is the + real `UiThread.Dispatcher`), on a worker thread with no thread-affinitized dispatcher. +- If `UiThread.Dispatcher` is null at the time this test runs (test-order-dependent; see archived + #508's analysis of the same ambient-state hazard), `WpfDispatcherYield.YieldAsync` already throws + `InvalidOperationException` today (`WpfDispatcherYield.cs`'s own existing guard), before and after + this fix — the fix only moves the throw one call frame earlier, from `WpfDispatcherYield.cs`'s own + `if (dispatcher is null)` check to `UiThread.Dispatcher`'s getter. The test asserts no exception + type or message text from this path, so its pass/fail outcome is unchanged by this fix in either + direction (both before and after, the test's outcome depends only on whether `UiThread.Init()` ran + earlier in the process — a pre-existing, documented, and explicitly out-of-scope ambient-state + dependency per #508's own "Dependencies / Risks" section, not introduced or worsened by #584's + fix). + +`UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`, +`InitializeAsync_WithCurrentDispatcher_InitializesAndReturnsTracker` (read in full): sets +`UiThread._dispatcher` via reflection to a real, valid `Dispatcher.CurrentDispatcher` instance +before calling `InitializeAsync()`, restoring the prior value in `finally`. Never exercises the +null path within its own assertions; the fix does not change this test's outcome. This test's +own reflection-based, unsynchronized mutation of the shared static is itself the most likely +mechanism behind the originally observed flake (a concurrently running test thread reading +`UiThread.Dispatcher` while this test's `finally` block is mid-restore to a prior `null` value) — +recorded as a follow-up in `spec.md`'s "Rollout & Follow-up," not fixed by this change. + +## Conclusion / scope determination + +The defect is confirmed present and structural on `origin/main`. The minimal, targeted fix is +confined to `UtilitiesCS/Threading/UiThread.cs`'s `Dispatcher` accessor plus one new deterministic +test file/class in `UtilitiesCS.Test/Threading/`. `UtilitiesCS/Threading/ProgressTrackerAsync.cs` +does not require a code change: the fix relocates the failure point to `UiThread.Dispatcher`'s own +getter, which is invoked from `ProgressTrackerAsync.cs:33` before any dereference happens, so the +same line already receives a self-diagnosing exception without modification. This conclusion is +recorded in `spec.md` AC3 as a plan-time hypothesis to be re-verified, not assumed, during +implementation. diff --git a/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md new file mode 100644 index 000000000..03230178c --- /dev/null +++ b/docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/spec.md @@ -0,0 +1,400 @@ +# uithread-dispatcher-null-race-progresstrackerasync (Spec) + +- **Issue:** #584 +- **Parent (optional):** none +- **Owner:** drmoisan +- **Last Updated:** 2026-09-03 +- **Status:** Draft (amended in plan revision round 15: write set and AC4 extended to a sixth file; + amended in plan revision round 16: AC5 returned to unchecked pending the sixth file's token-filter + artifact; amended in plan revision round 17 (preflight round 17 non-blocking findings N1-N4 + applied), of which finding N4 is the only one touching this file: AC5's Evidence line now states the + diff's added-line figure as the artifact records it) +- **Version:** 0.5 + +## Context + +- `UtilitiesCS.Threading.UiThread.Dispatcher` is a static property backed by a `null!`-initialised + field with no lazy initialisation and no guard. `ProgressTrackerAsync.InitializeAsync()` + dereferences the property's return value on the very next statement with no null check. When the + static is read before `UiThread.Initialize()` has completed, the property silently returns `null` + and the consumer throws an unattributed `NullReferenceException`. +- Observed environment(s): full-suite MSTest run under + `[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.ClassLevel)]` in `UtilitiesCS.Test`. + Observed once during the post-change QC run for issue #449; did not reproduce in isolation or in + two subsequent clean full-suite runs (0 of 1 isolated, 1 of 3 full-suite, per the issue body). +- Customer impact and severity: no end-user impact has been assessed; the observed failure is in the + test suite, but the defect (unguarded static null return) is in production code + (`UtilitiesCS/Threading/UiThread.cs`, and UtilitiesCS/Threading/ProgressTrackerAsync.cs, which was + verified to need no code change), not test + scaffolding. Whether a real Outlook/VSTO startup path can read `Dispatcher` before `Init()` + completes has not been determined by this research; the fix removes the hazard regardless of + whether that path is reachable in production, per the issue's own item 2/3 recommendation. + Test-suite impact: a non-deterministic failure erodes trust in the full-suite gate (Core + Principles 3 Fast Execution / 4 Determinism in the rule file .claude/rules/general-unit-test.md), + because it cannot be distinguished from a real + regression without rerunning. +- First observed date and version(s) impacted: observed 2026-08-22 (per the potential document + filename date) during QC for issue #449; present on origin/main at + `5ebaaf105d8241f309f704d1ff90af2e32e5a6c1` (verified 2026-09-02). + +## Repro & Evidence + +- Steps to reproduce (with data/flags/inputs): no reliable timing-based repro exists (1 of 3 + full-suite runs; 0 of 1 isolated runs, per the issue body). A deterministic structural repro is + used instead (see Test Strategy): force `UiThread`'s private `_dispatcher` backing field to `null` + via reflection (mirroring the existing pattern in + `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`'s `ForceDispatcherNull`/`RestoreDispatcher` + helpers) and assert on `UiThread.Dispatcher`'s accessor contract directly. +- Expected vs actual behavior: expected — a clear, explicit exception naming the missing + `Initialize()` call. Actual (pre-fix) — the accessor returns `null` silently; the first + dereference downstream (`ProgressTrackerAsync.InitializeAsync()`, or any of ~40 other call sites + across `UtilitiesCS`, `QuickFiler`, and `TaskMaster` that read `UiThread.Dispatcher` without a + guard) throws an unattributed `NullReferenceException`. +- Logs/screenshots/error snippets: `NullReferenceException` at + UtilitiesCS/Threading/ProgressTrackerAsync.cs, line 35, on an STA thread, 793 ms into the failing run + versus 191 ms in isolation (per the issue body). +- Frequency / determinism: intermittent, timing-dependent on production code paths (not to be + stabilised with a sleep/retry/timing tolerance per the assignment's binding scope constraint and + the rule file .claude/rules/general-unit-test.md). + +## Scope & Non-Goals + +- In scope: + - `UtilitiesCS/Threading/UiThread.cs` — the `Dispatcher` accessor's null contract. + - A new deterministic regression test in `UtilitiesCS.Test/Threading/UiThread_Tests.cs`. + - `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` — the one reflective consumer of the + `Dispatcher` PROPERTY in the repository. Its `[TestInitialize]`/`[TestCleanup]` snapshot is + retargeted from the public property to the private `_dispatcher` backing field, matching the + idiom the four other reflective consumers already use. Added to scope on 2026-09-03 after the + full `QuickFiler.Test` run failed 8 of 1312 on this class; see Root Cause Analysis. +- Out of scope / non-goals: + - UtilitiesCS/Threading/ProgressTrackerAsync.cs — verified not to require a change (see Root + Cause Analysis). Named as a fix site to verify in the assignment, not assumed to need an edit. + - The injectable-seam conversion replacing ~62 remaining direct reads of `UiThread.Dispatcher` + across ~29 production files with the existing `IUiDispatcher` seam. Already identified and + explicitly deferred on the issue's own comment thread as a multi-phase, multi-assembly refactor + with no bounded blast radius. + - Adding synchronization around `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`'s + existing reflection-based, unsynchronized mutation of the shared static + `UiThread._dispatcher` (a `#493`-shaped test-isolation concern, but in a different test + assembly than `#493` covered). Recorded as a candidate follow-up in Rollout & Follow-up below. +- Explicitly excluded systems, integrations, or datasets: the Claude runtime tree at .claude/**, the + Codex mirror tree at .codex/**, the dot-agents tree at .agents/**, and the two published files + config/blast-radius.json and config/orchestration-routing.json (published from upstream + drm-copilot with zero templating; edits here are silently overwritten). + +## Write Set + +Every file this plan's diff creates, modifies, or deletes: + +- `UtilitiesCS/Threading/UiThread.cs` +- `UtilitiesCS.Test/Threading/UiThread_Tests.cs` +- `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` +- `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs` +- `UtilitiesCS.Test/Threading/ProgressTracker_Tests.cs` +- `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` + +## Root Cause Analysis + +- Confirmed root cause: `UtilitiesCS/Threading/UiThread.cs:135-140` declares + `public static Dispatcher Dispatcher { get => _dispatcher; private set => _dispatcher = value; }` + backed by `private static Dispatcher _dispatcher = null!;`. The getter returns the field + unconditionally with no null guard and no lazy initialisation, unlike the two other lazy-init + properties in the same class (`UiSyncContext`, `AutoScaleFactor`), which call `Init()` on demand + when their backing field is still `null`. The `null!` null-forgiving operator suppresses exactly + the nullable-flow diagnostic (`CS86xx`) that would otherwise flag the hazard at every call site. +- Signals/evidence supporting it: verified directly against origin/main at + `5ebaaf105d8241f309f704d1ff90af2e32e5a6c1` on 2026-09-02 by reading + `UtilitiesCS/Threading/UiThread.cs:1-163` and UtilitiesCS/Threading/ProgressTrackerAsync.cs, lines + 1-104, in full. The existing repository idiom for the same hazard already exists in + UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs, lines 57-66, which explicitly comments: " + `UiThread.Dispatcher` is set-once state populated by `UiThread.Init()` and is null outside a live + host, so that null state is surfaced as `InvalidOperationException` to preserve the strict + contract callers relied on." That file already treats `UiThread.Dispatcher`'s return value as + potentially null despite its non-nullable compile-time type, confirming the type signature + currently misrepresents the real contract. +- Affected components/modules: `UtilitiesCS/Threading/UiThread.cs` (the accessor); consumers reading + `UiThread.Dispatcher` without a guard include + UtilitiesCS/Threading/ProgressTrackerAsync.cs, UtilitiesCS/Threading/ProgressTracker.cs, + UtilitiesCS/Threading/ProgressTrackerPane.cs, UtilitiesCS/Threading/IdleActionQueue.cs, + UtilitiesCS/Threading/IdleAsyncQueue.cs, files under UtilitiesCS/HelperClasses/ThemeHelpers/, + UtilitiesCS/HelperClasses/ToolTips/QfcTipsDetails.cs, several files under + QuickFiler/Controllers/, + and TaskMaster/Ribbon/RibbonViewer.EngineCommands.cs, files under TaskMaster/AppGlobals/, + TaskMaster/ThisAddIn.cs. None of these require code changes for this fix: they already either + (a) run inside a broad `catch (Exception ex)` (for example + `IdleAsyncQueue.OnApplicationIdle`, verified against `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`'s + `AddEntry_UseUiThreadTrue_DequeuesEntryAndSuppressesDispatcherException`, which asserts only that + no exception escapes and does not assert on the concrete exception type), or (b) currently fail + with an unhandled `NullReferenceException` when the static is null and will instead fail with an + unhandled `InvalidOperationException` of the same unhandled-ness after this fix — a strictly + clearer failure, not a new one. + +## Proposed Fix + +### Design summary (what changes where) + +Change `UiThread.Dispatcher`'s getter in `UtilitiesCS/Threading/UiThread.cs` to fail fast with an +explicit `InvalidOperationException` naming the missing `Initialize()` call, instead of silently +returning `null`. Change the backing field's declared type from `Dispatcher` (suppressed via +`null!`) to `Dispatcher?` and remove the null-forgiving operator, so the nullable analyser can once +again reason correctly about the field. This mirrors the existing `InvalidOperationException` +contract already established for the same hazard in +UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs. + +### Boundaries and invariants to preserve + +- `UiThread.Dispatcher`'s public type remains `Dispatcher` (non-nullable): callers keep receiving a + guaranteed non-null value or an exception, never `null`, so no existing call site's null-handling + assumptions change. +- `UiThread.Init()` / `UiThread.Initialize()` remain untouched: the `ThreadSafeSingleShotGuard`-based + one-time initialisation semantics are out of scope for this fix. +- No retry, sleep, or timing tolerance is introduced anywhere in production or test code. + +### Dependencies or blocked work + +None. The fix is self-contained to `UiThread.cs`'s `Dispatcher` accessor. + +### Implementation strategy (what changes, not sequencing) + +#### Files/modules to change + +- `UtilitiesCS/Threading/UiThread.cs` +- `UtilitiesCS.Test/Threading/UiThread_Tests.cs` (new regression test class) + +#### Functions/classes/CLI commands impacted + +- `UtilitiesCS.UiThread.Dispatcher` (property getter and backing field only). + +#### Data flow and validation changes + +- The `Dispatcher` getter now validates its own backing field before returning, at the single + source, instead of relying on each of the ~40 call sites (or none) to guard independently. + +#### Error handling and logging updates + +- The new failure is a named `InvalidOperationException` with a message stating the missing + `Initialize()` call, matching the message shape already used in + UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs:64-66. No new logging is added; existing catch sites (for example + `IdleAsyncQueue.OnApplicationIdle`) already log caught exceptions generically. + +#### Rollback/feature-flag considerations (if applicable) + +None; this is a narrow accessor-contract change with no configuration surface. + +### Technical specifications (interfaces/contracts) + +#### Inputs/outputs and formats + +- `UiThread.Dispatcher` getter: no inputs; returns `System.Windows.Threading.Dispatcher` (never + `null`) or throws `InvalidOperationException`. + +#### Required configuration keys and defaults + +None. + +#### Backward-compatibility expectations + +- Every existing call site that reads `UiThread.Dispatcher` and is not otherwise guarded already has + undefined/crashing behavior when the static is unset (`NullReferenceException`). This fix replaces + that crash with a named, self-diagnosing `InvalidOperationException` at the same call site, + verified compatible with every currently-passing test that touches this path (see Root Cause + Analysis). + +#### Performance constraints (latency/throughput/memory) + +None; the change adds a single null check to an existing property getter. + +## Assumptions, Constraints, Dependencies + +- Assumptions (environment, data, access): the fix does not require determining whether a real + Outlook/VSTO startup path can read `Dispatcher` before `Init()` completes (issue "Proposed + direction" item 1); it removes the silent-null hazard regardless of reachability. +- Constraints (budget, performance, compatibility): 1-3 production files, per the assignment's + small-path budget; no edits to the Claude runtime tree at .claude/**, the Codex mirror tree at + .codex/**, the dot-agents tree at .agents/**, or the two published files + config/blast-radius.json and config/orchestration-routing.json. +- External dependencies (services, libraries, releases): none. + +## Data / API / Config Impact + +- User-facing or API changes: none (internal static utility). +- Data or migration considerations: none. +- Logging/telemetry updates (if any): none. +- Compatibility notes (CLI flags, config schemas, versioning): none. + +## Test Strategy + +- Regression tests to add or update: a new deterministic test in + `UtilitiesCS.Test/Threading/UiThread_Tests.cs` that uses reflection to force the private + `_dispatcher` backing field to `null` (its pre-`Initialize()` state), asserts that + `UiThread.Dispatcher` throws `InvalidOperationException` naming the missing `Initialize()` call, + and restores the prior field value in a `finally` block so the shared static is not left + corrupted for other tests in the assembly (mirroring the existing capture/restore pattern in + `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`). +- Unit tests for the fixed behavior and boundaries: the new test covers both the null case (throws) + and, if a repository-approved seam allows arranging it deterministically, the non-null case + (returns the same instance stored in the field) without touching WPF message-pump machinery. +- Edge cases and negative scenarios: verified the fix does not change the outcome of + `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`'s + `AddEntry_UseUiThreadTrue_DequeuesEntryAndSuppressesDispatcherException` (broad `catch (Exception + ex)`, no type assertion) and UtilitiesCS.Test/OutlookObjects/Folder/WpfDispatcherYieldTests.cs's + `YieldAsync_WithoutDispatcher_RemainsStrict` and + `OutlookFolderTreeServiceConcurrencyTests.GetSnapshotAsync_WorkerOriginatedColdBuild_UsesCapturedStaDispatcher` + (neither asserts on `UiThread.Dispatcher`'s exception message text; `WpfDispatcherYieldTests`'s + four tests supply explicit injected provider delegates and never call the real + `UiThread.Dispatcher` property at all). +- Error handling and logging verification: the new test asserts the exception type and that it + names the missing `Initialize()` call. +- Coverage impact and targets for changed lines/modules: the new getter branch is new code and must + meet the repository's `>= 90%` new-code coverage target; the change is small enough that the + single new test should reach 100% of the new lines. +- Toolchain commands to run (format → lint → type-check → test): per CLAUDE.md C# Toolchain -- + `dotnet tool run csharpier format .` (verify with `check .`), then + `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true`, + then `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true`, + then `vstest.console.exe /EnableCodeCoverage`. +- Manual validation steps (if required): none; the deterministic test supersedes manual repro. + +## Acceptance Criteria + +- [x] AC1: `UiThread.Dispatcher` throws a named `InvalidOperationException` (not a bare + `NullReferenceException`) when read before `UiThread.Initialize()` has populated its backing + field, verified by a deterministic regression test in + `UtilitiesCS.Test/Threading/UiThread_Tests.cs` that does not rely on timing, sleeps, retries, + or full-suite execution order. + Evidence: `evidence/regression-testing/p1-t4-expect-fail.md` (`Failed: 1` against the unfixed + accessor, with the verbatim message "Expected a <System.InvalidOperationException> to be + thrown, but no exception was thrown.") and + `evidence/regression-testing/p3-t2-regression-green.md` (`Passed: 2` against the fixed + accessor). +- [x] AC2: The `null!` null-forgiving suppression on `UiThread`'s `_dispatcher` backing field is + removed; the field's declared type becomes nullable (`Dispatcher?`) so the nullable analyser + can verify the getter's guard. + Evidence: `evidence/qa-gates/p2-t2-nullforgiving-removed.md` (zero `null!` matches in + `UtilitiesCS/Threading/UiThread.cs`, and `private static Dispatcher? _dispatcher;` present on + line 149) and `evidence/qa-gates/p4-t4-nullable-build.md` (`0 Error(s)` under + `/p:TreatWarningsAsErrors=true`, so the compiler confirms the guard narrows the field and no + `CS8603` is raised). +- [x] AC3: UtilitiesCS/Threading/ProgressTrackerAsync.cs is left unmodified unless the + implementation phase discovers a concrete reason a change there is required; if left + unmodified, the plan records the verification that the fix in `UiThread.cs` alone converts the + downstream failure from an unattributed `NullReferenceException` to a self-diagnosing + `InvalidOperationException` raised at the `UiDispatcher = UiThread.Dispatcher;` line. + Evidence: `evidence/other/p3-t4-progresstrackerasync-unmodified.md`, which records the empty + `--cached` name-status diff for that path against BASE, the empty porcelain status for that + path, the single `git grep` hit at line 33, and the verification paragraph explaining why the + fix in `UiThread.cs` alone converts the consumer's failure mode. +- [x] AC4: No regression in `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`, + UtilitiesCS.Test/OutlookObjects/Folder/WpfDispatcherYieldTests.cs, + UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderTreeServiceConcurrencyTests.cs, + `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`, or + `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` (all pass, unmodified assertions). + The last of those five is modified by this change, and "unmodified assertions" is the binding + constraint on how: the file's `[TestInitialize]`/`[TestCleanup]` snapshot is retargeted from the + public `Dispatcher` property to the private `_dispatcher` backing field, and no assertion, no + test method, and no mock setup in it is added, removed, or altered. + Evidence: `evidence/qa-gates/p1-t5-donotparallelize.md` (the change to + `IdleAsyncQueue_Tests.cs` and `ProgressTrackerAsync_Tests.cs` is attribute-only and alters no + assertion), `evidence/regression-testing/p3-t3-at-risk-tests.md` (41 tests executed, all five + named at-risk tests present and passing, no failure outside the empty `BASELINE_FAILURE_SET`), + `evidence/regression-testing/p3-t6-quickfiler-wpfuidispatcher.md` (`Failed: 0`), + `evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md` (the change to + `EmailMoveMonitorTests.cs` retargets one reflection lookup, alters no assertion, and leaves the + `[TestMethod]` count at 8), `evidence/regression-testing/p4-t6-first-pass-failure.md` (the + fail-before: 1304 of 1312, with all 8 of that class's tests failing), and + `evidence/qa-gates/p4-t6-quickfiler-tests.md` (the pass-after: 1312 of 1312). + Amendment note (2026-09-03, plan revision round 15): this criterion previously named four files + and carried a scope note recording an open regression in a fifth. That regression was in + `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs`, found by P4-T6 and attributed to the + `UiThread.cs` fix by an executed counterfactual. The file is now named in this criterion and + repaired by plan task P2-T4, and this criterion is returned to unchecked until the pass-after + evidence exists. Leaving it out was not tenable: this change now modifies that file, so a + no-regression criterion that did not name it would leave the repair with no acceptance + criterion binding it, and the previous scope note would have remained literally false once the + repair landed. +- [x] AC5: No retry, sleep, or timing tolerance is introduced anywhere in the diff. + Evidence: `evidence/qa-gates/p3-t5-no-timing-tokens.md`, recording zero matching added lines in + the BASE-anchored diff (5626 bytes, 94 lines beginning with `+` including the five `+++` file + headers, across the five owned files; the + case-insensitive seven-token filter printed nothing and exited 1); and + `evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md`, recording zero matching lines + in the BASE-anchored diff over the sixth owned file + `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` (2570 bytes; the identical + case-insensitive seven-token filter, applied to the whole diff rather than only its added + lines, printed nothing and exited 1). The two together make "anywhere in the diff" true of the + whole diff: P3-T5's pathspec `UtilitiesCS UtilitiesCS.Test` covers five of the six owned files + and P2-T4's covers the sixth. + Amendment note (2026-09-03, plan revision round 16): this criterion is returned to unchecked in + this round. It had been checked on the strength of + `evidence/qa-gates/p3-t5-no-timing-tokens.md` alone, and that artifact's diff pathspec is + `UtilitiesCS UtilitiesCS.Test`, which reaches five of the six owned files. Plan revision round 15 + widened the write set to a sixth file, `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs`, + which that pathspec does not reach, so this criterion's "anywhere in the diff" wording was not + yet evidenced across the whole diff at the time it stood checked. The criterion text is unchanged + and its scope is not narrowed. The evidence chain closes without widening that pathspec: plan + task P2-T4 carries the identical case-insensitive seven-token filter over the sixth file and + writes `evidence/qa-gates/p2-t4-emailmovemonitor-reflection-target.md`, and plan task P5-T5 + re-checks this criterion citing both artifacts once that second artifact exists. +- [x] AC6: Full C# toolchain (csharpier -> analyzer msbuild -> nullable msbuild -> vstest with + coverage) passes in order in a single final pass, with per-step evidence artifacts recorded. + Evidence: `evidence/qa-gates/p4-t1-format.md` (`EXIT_CODE: 0`, `Formatted 6 files`, identical + before-and-after unscoped porcelain), `evidence/qa-gates/p4-t2-format-check.md` + (`EXIT_CODE: 0`, `Checked 1576 files`, empty reported set), + `evidence/qa-gates/p4-t3-analyzer-build.md` (`EXIT_CODE: 0`, `0 Warning(s)`, `0 Error(s)`), + `evidence/qa-gates/p4-t4-nullable-build.md` (`EXIT_CODE: 0`, `0 Error(s)`), + `evidence/qa-gates/p4-t5-utilitiescs-tests.md` (`EXIT_CODE: 0`, 4787 of 4787 passed), + `evidence/qa-gates/p4-t6-quickfiler-tests.md` (`EXIT_CODE: 0`, 1312 of 1312 passed), and + `evidence/qa-gates/p4-t8-loop-closure.md` (all seven steps listed in order, both Phase 4 passes + recorded chronologically, no step after P4-T1 rewrote a tracked file, so the second pass is a + single clean pass). `evidence/qa-gates/p4-t7-coverage-delta.md` is deliberately not cited here; + it is AC7's evidence. +- [x] AC7: Repository-wide line coverage does not regress relative to the recorded baseline, and + coverage on the changed lines meets the `>= 90%` new-code target. + Evidence: `evidence/baseline/p0-t10-utilitiescs-tests-coverage.md` for the baseline figures and + `evidence/qa-gates/p4-t7-coverage-delta.md` for the comparison. Baseline `line-rate` + 0.7073317347831605; post-change `line-rate` 0.7073603942281368, an increase of +0.0000286594, + so no regression against the baseline and the 0.005 tolerance is not consumed. Signed + `lines-valid` difference: 149761 - 149719 = +42, inside the 0 to 200 comparability band, so no + `COVERAGE DENOMINATOR MISMATCH` was recorded and the repository-wide comparison stands rather + than being VOID. Changed-line coverage: 100.0% (8 of 8 coverable added lines in + `UtilitiesCS/Threading/UiThread.cs` — lines 138, 139, 140, 141, 142, 143, 145, and 146 — each + with `hits` of 1 or more), which meets the `>= 90%` new-code target. Both `line-rate` figures + are raw unstripped dotnet-coverage line-rates for the UtilitiesCS.Test process and are not the + repository first-party figure CLAUDE.md's 80% refers to. + +## Risks & Mitigations + +- Technical or operational risks: a call site not covered by this research relies on + `UiThread.Dispatcher` silently returning `null` and would now observe an + `InvalidOperationException` instead. Mitigation: the repo-wide grep enumerated in Root Cause + Analysis is the complete set of production reads of `UiThread.Dispatcher` spelled as the qualified + member expression (`git grep -n "UiThread.Dispatcher\b"`), and each was checked against its nearest + test coverage; none depends on a silent-null outcome distinct from a generic-exception outcome. +- Census limitation recorded on 2026-09-03, after it materialised as a real regression: that grep + matches only the literal text `UiThread.Dispatcher`, so it cannot match a REFLECTIVE read, which + never spells the qualified expression at all. One such read existed — + `typeof(UiThread).GetProperty("Dispatcher", ...)` in + `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` — and it failed 8 of that class's 8 tests + when the guarded getter shipped, because `PropertyInfo.GetValue` propagates an exception a throwing + getter raises. The complementary census the plan DID run, `git grep -F '"_dispatcher"'`, covers + reflective reads of the private FIELD and correctly found three files; no equivalent census was run + for the property NAME. Plan task P0-T14 now runs that census across all nine test assemblies and + repository-wide across `.cs` files, and records its full output. Its re-derived result is that + exactly one reflective property read exists in the repository, in the file above, and that every + other occurrence of the literal `"Dispatcher"` is an XML `` documentation + cross-reference. No production file reads `UiThread.Dispatcher` reflectively. +- Mitigations and rollbacks: the change is a single accessor; reverting is a one-file revert with no + migration or data considerations. + +## Rollout & Follow-up + +- Release/rollout steps: standard PR review and merge; no feature flag or staged rollout needed. +- Post-fix monitoring or clean-up tasks (candidate follow-ups, not part of this fix): + 1. Add synchronization (or an injectable seam, per the already-partially-adopted `IUiDispatcher`) + around `UtilitiesCS.Test/Threading/ProgressTrackerAsync_Tests.cs`'s reflection-based mutation of + `UiThread._dispatcher`, mirroring the fixture-level fix `#493` applied in `QuickFiler.Test`. + 2. The broader injectable-seam conversion of the ~62 remaining direct `UiThread.Dispatcher` reads + across ~29 production files, already flagged as out of scope on the issue's own comment thread. +- Links: issue #584; related #493 (test-side isolation on the same static, already resolved); + related #508 (archived; established the `InvalidOperationException` contract this fix reuses).