diff --git a/.claude/agent-memory/atomic-executor/MEMORY.md b/.claude/agent-memory/atomic-executor/MEMORY.md index f964f4efb..9dee9a7ec 100644 --- a/.claude/agent-memory/atomic-executor/MEMORY.md +++ b/.claude/agent-memory/atomic-executor/MEMORY.md @@ -37,7 +37,7 @@ - [Start-Process -ArgumentList strips quoting](project_startprocess_arglist_array_strips_quoting.md) · [Relative paths in pwsh hit the wrong worktree](project_relative_path_in_pwsh_dotnet_io_hits_wrong_worktree.md) - [QuickFiler.Test coverage hang + build flags](project_quickfiler_test_coverage_hang_and_build_flags.md) — testhost can hang - [Dot-sourcing Invoke-MSTestWithCoverage clobbers $CoverageOutput](project_dotsourcing_invoke_mstest_clobbers_coverageoutput_param.md) — param-block default wins; Cobertura lands at coverage\coverage.cobertura.xml, exit 0 -- [vstest TestCaseFilter needs `|` not OR](project_vstest_testcasefilter_or_operator_and_env_setup.md) · [Test file name != partial class name](project_test_file_name_vs_partial_class_name.md) +- [vstest TestCaseFilter: `|` not OR, and `&` binds tighter](project_vstest_testcasefilter_or_operator_and_env_setup.md) · [Test file name != partial class name](project_test_file_name_vs_partial_class_name.md) - [Analyzer HintPath skew breaks all four gates](project_analyzer_hintpath_skew_breaks_all_four_gates.md) · [Analyzer version skew on fresh worktree](project_analyzer_version_skew_fresh_worktree.md) — CS0006 - [SecurityCodeScan incompatible with Roslyn 5.6](project_securitycodescan_roslyn56_incompat.md) · [Missing VSTO runtime breaks baseline gates](project_missing_vsto_runtime_breaks_baseline_gates.md) — HISTORICAL - [New sln member surfaces MSB3277](project_new_sln_member_surfaces_msb3277_pin_divergence.md) · [Legacy csproj: no transitive compile refs](project_legacy_csproj_no_transitive_compile_refs.md) — CS0012 @@ -79,7 +79,7 @@ - [C# canonical coverage artifact conversion](project_csharp_canonical_coverage_artifact_conversion.md) · [Cobertura runsettings `` override](project_cobertura_runsettings_attributes_override.md) - [Package rollup must use the repo helper](project_cobertura_package_rollup_must_use_repo_helper.md) — a hand-written class-direct node count never equals the root attributes - [Processed Cobertura filenames use backslashes](project_processed_cobertura_filenames_use_backslash.md) — forward-slash match returns zero rows; gate unevaluable -- [Cobertura hits vs MS-coverage partial](project_changed_line_coverage_cobertura_vs_mscoverage_partial.md) · [QFC #227 coverage tooling](project_qfc227_coverage_tooling.md) +- [Cobertura hits vs MS-coverage partial; non-executable changed lines have no hits](project_changed_line_coverage_cobertura_vs_mscoverage_partial.md) · [QFC #227 coverage tooling](project_qfc227_coverage_tooling.md) - [#398 test-split gate gotchas](project_398_test_split_gate_gotchas.md) · [ExcludeFromCodeCoverage on partial = CS0579](project_excludefromcodecoverage_partial_class_cs0579.md) - Closed one-offs: [#400](project_400_completeopenasync_unreachable_recovery_catch.md), [Swordfish](project_swordfish_removal_epic_incidental_coverage_sideeffect.md), [#298](project_taskvis_scocollection_and_livebridge_exemptions.md), [#328](project_328_rebuild_threading_olobjectsproxy_conflict.md) diff --git a/.claude/agent-memory/atomic-executor/project_changed_line_coverage_cobertura_vs_mscoverage_partial.md b/.claude/agent-memory/atomic-executor/project_changed_line_coverage_cobertura_vs_mscoverage_partial.md index 7311d846b..b2be7f909 100644 --- a/.claude/agent-memory/atomic-executor/project_changed_line_coverage_cobertura_vs_mscoverage_partial.md +++ b/.claude/agent-memory/atomic-executor/project_changed_line_coverage_cobertura_vs_mscoverage_partial.md @@ -11,4 +11,6 @@ When proving a specific new/changed line meets the CLAUDE.md/csharp.md >= 90% ne **How to apply:** For a precise per-line changed-line coverage proof, convert the same `.coverage` file with `dotnet-coverage merge -f cobertura` (global tool, not `dotnet tool run`) instead of (or in addition to) `Microsoft.CodeCoverage.Console.exe`. Cobertura's `` reports `hits=1` (covered) for a line executed at least once, regardless of whether all its branches were taken — so the null-guard assignment line correctly shows as covered. Locate the exact `` block for the changed production file (grep for the class-open/`` line-number bracket, since large solutions produce a huge single XML with duplicate line numbers across many classes/modules), then check hits for the specific line numbers touched by the diff. Report BOTH figures when writing coverage-delta evidence: the MS-coverage-XML aggregate (for the class-level baseline-vs-post-change comparison) and the Cobertura per-line hit data (for the changed-line-specific >= 90% claim), explaining the partial-vs-hit distinction so a reviewer does not mistake "partially covered" for "under-tested." +**Non-executable changed lines have no `hits` value at all.** Cobertura emits a `` element only for a line that carries IL. XML doc comments, blank lines, `using` directives, braces, enum members and interface method declarations therefore appear in a `git diff --unified=0` changed-line set but in no branch of the per-line map. A changed-line-coverage gate worded "every changed line is recorded with a `hits` value" is over-demanding for those lines, and an executor that fills them in as `hits = 0` inflates the uncovered-line count that the coverage argument rests on. Give such lines their own marker (`hits=non-executable`) and exclude them from both the `hits = 0` count and the regression count — the same shape as the `baseline=none` marker used for a hunk whose added and removed counts are unequal. The extreme case is an interface-only or enum-only file: every changed line in it can be non-executable, so the file yields no coverage datum even when the coverage document does contain a class element for it. + See also [[project_coverage_firstparty_denominator_method]] and [[project_qfc227_coverage_tooling]] for other coverage-tooling conventions in this repo. diff --git a/.claude/agent-memory/atomic-executor/project_vstest_testcasefilter_or_operator_and_env_setup.md b/.claude/agent-memory/atomic-executor/project_vstest_testcasefilter_or_operator_and_env_setup.md index 2e1a4d5ef..c55f1b1e3 100644 --- a/.claude/agent-memory/atomic-executor/project_vstest_testcasefilter_or_operator_and_env_setup.md +++ b/.claude/agent-memory/atomic-executor/project_vstest_testcasefilter_or_operator_and_env_setup.md @@ -1,6 +1,6 @@ --- name: vstest-testcasefilter-or-operator-and-env-setup -description: vstest.console.exe 18.7.0 rejects literal "OR" in /TestCaseFilter (needs "|"); fresh worktree needs repo-local SDK install + NuGet restore before any MSBuild/vstest command works +description: vstest.console.exe 18.7.0 rejects literal "OR" in /TestCaseFilter (needs "|"), and "&" binds tighter than "|" so a category clause silently applies to only the first alternative; fresh worktree needs repo-local SDK install + NuGet restore before any MSBuild/vstest command works metadata: type: project --- @@ -13,6 +13,8 @@ Two environment/tooling facts discovered during issue #244 execution that cost s 1. **`/TestCaseFilter` boolean operator.** This repo's vstest.console.exe (18.7.0, under `C:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\CommonExtensions\Microsoft\TestWindow\`) does NOT accept the literal keyword `OR` between two `FullyQualifiedName~X` clauses — it prints `Incorrect format for TestCaseFilter Error: Invalid Condition` and then reports "No test matches" even for tests that exist. The correct operator is the pipe character: `FullyQualifiedName~A|FullyQualifiedName~B`. Verified by probing two known-existing test names: `OR` matched 0/2, `|` matched 2/2 and ran both. If a plan's task text literally specifies `OR`, substitute `|` when executing and document the substitution in evidence (same test-name targets, only the boolean-operator token differs) rather than treating the plan text as broken. +1b. **`&` binds tighter than `|` in a `/TestCaseFilter` expression, and there is no way to say otherwise in one flag.** A filter written `TestCategory!=LiveOutlook&FullyQualifiedName~A|FullyQualifiedName~B` parses as `(TestCategory!=LiveOutlook & FullyQualifiedName~A) | (FullyQualifiedName~B)`, so the category exclusion applies to the first alternative only and every `LiveOutlook` test in class B is silently selected. The filter grammar accepts parentheses in principle, but they are hostile to quote through PowerShell into a native exe, so the practical remedies are: (a) drop the category clause when the targeted classes provably declare no test in that category, and record the check (`Select-String -SimpleMatch 'TestCategory'` over those files returning 0) as the justification; or (b) run one invocation per alternative. A plan that combines a category clause with two or more `FullyQualifiedName` alternatives in a single filter has a defect even though the command exits 0 and runs tests — the wrong *set* of tests ran, which no exit code reveals. Preflight should read every multi-clause `/TestCaseFilter` for this shape. + 2. **Fresh-worktree bootstrap order.** A brand-new git worktree of this repo has neither `.dotnet-sdk/` (global.json pins SDK 8.0.205 via a path-based `dotnet` shim that errors "repo-local .NET SDK is missing" until installed) nor `packages/` (legacy `packages.config` NuGet packages are not checked in). Before any `dotnet tool run csharpier ...`, `MSBuild`/`Invoke-VSBuild.ps1`, or `vstest.console.exe` command will succeed, run in this order: - `pwsh -NoProfile -ExecutionPolicy Bypass -File ./scripts/vscode/Install-RepoDotNetSdk.ps1` (must be `pwsh` 7, not Windows PowerShell 5.1 — see [[project_repo_sdk_and_nullable_rebuild]]). - `dotnet tool restore` — `Install-RepoDotNetSdk.ps1` does NOT do this. The manifest is at repo-root `dotnet-tools.json` (the legacy location, not `.config/dotnet-tools.json`; the SDK probes both) and pins csharpier `1.2.6`. Without it every `dotnet tool run csharpier check/format .` step fails. diff --git a/.claude/agent-memory/atomic-planner/MEMORY.md b/.claude/agent-memory/atomic-planner/MEMORY.md index 3541bdc58..ed9d1fd1e 100644 --- a/.claude/agent-memory/atomic-planner/MEMORY.md +++ b/.claude/agent-memory/atomic-planner/MEMORY.md @@ -38,6 +38,7 @@ - [#731](project_731_lifecycle_disposal_plan_seams.md) — Defects468Tests 498/500 forces a partial continuation; QfcQueue.cs already 505; spec said ONE comment line not three; R3 = all 4 defects in gate wiring; R4 = worktree not bootstrapped (no `.dotnet-sdk/`, no `packages/`), `DebugType=full` leaks host paths, 1:1 line mapping unsatisfiable for a 1-to-20 hunk; R5 = case-insensitive token absence collides with a retained method name, `.//line` double-counts on two axes, unconditional AC17 check-off vs two degraded branches; R6 = document state is DERIVED from exit code (`:236`/`:341` throw before the `:343` write), an exclusion keyed on the regression's own definition is vacuous, `line-rate` is a fraction not a percentage, unanchored filename suffix also selects `IQfcDatamodel.cs` - [#781](project_781_excludefromcodecoverage_guard_plan_seams.md) — `[ExcludeFromCodeCoverage]` on the partial class makes changed-line coverage unmeasurable; `CaptureCurrent` voids a null-ambient success test; guard swap voids its own sibling comments; R1: `"Any CPU"` fails a project-file build, `...HEAD` can't see an uncommitted plan, no node axis reproduces Cobertura root counts - [#735 R1](project_735_evidence_content_sanitization_seams.md) — name-only sanitization gate can't fail; TRX `runUser=`/`computerName=` leak in content; csproj "between" clause self-contradictory +- [#791](project_791_hc_deadline_cancel_teardown_plan_seams.md) — `QfcDatamodel` is `[ExcludeFromCodeCoverage]`; 7 gate tests break not the 4 the spec names; `IFilerFormController` forbids an optional param; R1: no shell var survives between tasks, `Glob`/`Grep` hide gitignored `packages/`, 500-line cap excludes `*.csproj` (524) ## Plan-structure traps diff --git a/.claude/agent-memory/atomic-planner/project_791_hc_deadline_cancel_teardown_plan_seams.md b/.claude/agent-memory/atomic-planner/project_791_hc_deadline_cancel_teardown_plan_seams.md new file mode 100644 index 000000000..69742a20a --- /dev/null +++ b/.claude/agent-memory/atomic-planner/project_791_hc_deadline_cancel_teardown_plan_seams.md @@ -0,0 +1,170 @@ +--- +name: project-791-hc-deadline-cancel-teardown-plan-seams +description: "#791 QuickFiler High Confidence deadline + Cancel teardown planning seams — QfcDatamodel is [ExcludeFromCodeCoverage] so two Write Set files are unmeasurable; the retargeting surface is 7 gate tests not the 4 the spec names; IFilerFormController forbids an optional ActionCancelAsync parameter; QfcHomeController.cs has only 31 lines of headroom" +metadata: + type: project +--- + +Seams re-derived while authoring the issue #791 plan in worktree `TaskMaster-wt/2026-09-06T09-59` at `7c8ac9ae`. + +**Why:** each of these makes a plausible-looking acceptance condition unsatisfiable, vacuous, or uncompilable, and none is +visible from the spec or the research artifact. + +**How to apply:** re-check each before planning further work in `QuickFiler/Controllers`. + +1. **`QuickFiler/Controllers/QfcDatamodel.cs:25` carries `[ExcludeFromCodeCoverage]` on the partial class.** It applies to the + whole type, so `QfcDatamodel.QueueProcessing.cs` (`public partial class QfcDatamodel` at `:12`) is excluded too. Any + changed-line coverage AC over those two files is structurally unmeasurable. `QfcScanProgressBandMapper.cs:12` states the + same fact in prose, which is a cheap corroboration. Plan a decidable class-element-count determination against the + baseline Cobertura, and anchor the trailing-filename match on a separator — an unanchored `QfcDatamodel.cs` suffix also + selects `IQfcDatamodel.cs`. Same trap as [[project-781-excludefromcodecoverage-guard-plan-seams]] item 1. + +2. **Making the first-batch deadline advisory breaks SEVEN gate tests, not the four `spec.md` names.** The spec's Test + Strategy retargeting list omits `QfcStreamingDequeueConfidenceGateTests.Part2.cs:76-121` + (`..._LowYieldStream_StopsScanningAtDefaultFirstBatchDeadline`), `:124-144` + (`..._DeadlineExpiresWithZeroAccepted_ReturnsEmptyListAtTheBound`), `:205-228` + (`..._AfterDeadlineReturn_StopsTakingAndLeavesUnscannedCandidates`), `:346-385` + (`..._DeadlineExpiry_EmitsOneExpiryLineAndKeepsPerCandidateLogging`) and `Part3.cs:92-127` + (`..._ProgressCallback_StopsReportingOnceTheMethodReturns`). Read every test that passes `firstBatchDeadline:` before + trusting a spec-supplied retarget list. + +3. **`Part2.cs:384` is a TOTAL-count assertion on the injected `debugLog` list** (`logs.Should().HaveCount(4, ...)`). + Adding any new line through `_debugLog` breaks it. The sibling at `Part1.cs:172-179` uses a *filtered* `ContainSingle` + and is unaffected. Before adding a log line behind an injected sink, grep the test tree for total-count assertions on + that sink, not just for the literal being changed. + +4. **`QuickFiler/Interfaces/IFilerFormController.cs:11` declares `Task ActionCancelAsync();`.** A method with an all-optional + extra parameter does NOT implement a zero-parameter interface member in C#, so a `trigger` parameter is a compile break, + and the interface file is outside the #791 Write Set so AC5 forbids editing it. Supply the discriminator as call-site + logging instead. Always grep the whole repo (not just the obvious `IQfc*` file) before adding an optional parameter to a + controller method. + +5. **`QuickFiler/Controllers/QfcHomeController.cs` is 469 lines — 31 to the ceiling.** Rewriting `Cleanup()` (`:370-379`) with + three separate `try`/`catch` blocks plus a `finally` measures out at roughly 505 lines. Two guarded blocks fit. Budget the + guarded-block count against the ceiling *before* specifying the shape, and state the grouping rationale in the plan so a + reviewer does not read it as a weakened requirement. + +6. **`QfcFormControllerTests.cs:392-403` (`ButtonCancel_Click_ShouldCancelAction`) is a vacuous test that becomes a real + constraint.** It awaits `ActionCancelAsync()` against loose mocks where `IQfcHomeController.KeyboardHandler` and + `.DataModel` are both null, so every new dereference on the Cancel path must be null-conditional and the awaited + `QuiesceLoaderAsync` must be captured into a local and awaited only when non-null. `QfcFormControllerSeamTests.cs:162-179` + adds a second constraint: the parent-token cancel must stay ahead of the first `await`, because the test asserts + cancellation by the time `Mock.Raise` returns. + +7. **Once every teardown stage is individually caught, there is no throw source left for a "does not rethrow" test.** Drive + `ButtonCancel_Click_ActionThrows_DoesNotRethrow` from the handler's own body instead — nulling `_formViewer` makes + `SynchronizationContext.SetSynchronizationContext(_formViewer.UiSyncContext)` (`EventHandlers.cs:74`) raise inside the + handler's `try`, which is false-before and true-after against exactly the `throw;` at `:80`. + +8. **`ParkFocusOffWebView2` extraction invalidates its own remark.** `QfcFormController.Deactivate.cs:24` states a null-viewer + branch "would be unreachable code" because the routine is reachable only via `FormDeactivated`. Calling it from the Cancel + path falsifies that sentence and requires the guard. Single-line gate token that exists today: + `a null-viewer branch would be unreachable code`. + +9. **A Phase-1 declaration seam that stores a constructor parameter in a `private readonly` field raises CS0414** ("assigned + but never used") until Phase 2 reads it, and `/p:TreatWarningsAsErrors=true` promotes it to an error. Use an `internal` + get-only auto-property instead: its compiler-generated backing field is read by the getter, so the seam is warning-clean + at every point in the plan. + +10. **This worktree is only half bootstrapped.** `.dotnet-sdk/sdk/8.0.205` exists, but `packages/` is absent and there is no + `QuickFiler.Test/bin/Debug/QuickFiler.Test.dll`. No `nuget.exe` exists anywhere in the repo and none of the repo scripts + invokes one, so plan the packages.config restore as + `msbuild TaskMaster.sln /t:Restore /m /p:RestorePackagesConfig=true` rather than `nuget restore`. Extends + [[agent-worktrees-need-sdk-and-nuget-bootstrap]] and [[project-731-lifecycle-disposal-plan-seams]] round 4. + +11. **`QuiesceLoaderAsync`'s "and logs" assertion has no existing convention to borrow.** There is no `MemoryAppender` or + `log4net.Config` usage anywhere in the C# test tree, and attaching one mutates a process-global logger repository. + The gate's injected `Action debugLog` is the established alternative, so mirror it with an `internal + Action` seam on `QfcDatamodel` rather than asserting over log4net. + +12. **`artifacts/` is git-ignored (`.gitignore:57`) and `artifacts/csharp/` is explicitly permitted by + `enforce-evidence-locations.ps1:22-26`.** So an AC demanding `artifacts/csharp/coverage.xml` be *produced* is satisfiable + on-disk but can never be satisfied by a `git ls-files` retention clause. Gate on existence plus the recorded root + counters, and say so. Related: [[existence-is-not-retention-gate-committed-artifacts]]. + +13. **AC5-style "the branch diff touches no file outside the Write Set" is unsatisfiable read over the whole tree**, because + the plan must write evidence artifacts and check off AC boxes in `spec.md`. Scope every such gate to a source pathspec + (`'*.cs' '*.csproj'`) and record the reading and its rationale in the plan, so the narrower evaluation is not read as an + unstated relaxation. + +Observed command outputs reused from issue #782 (do not re-derive): +`dotnet tool run csharpier check .` prints `Checked files in ms.` and exits 0 on a clean tree; +`dotnet tool run csharpier format .` prints `Formatted files in ms.` whether or not it rewrote anything, so it needs a +before/after tree observation; the coverage aggregation snippet prints +`LINES_COVERED= LINES_VALID= BRANCHES_COVERED= BRANCHES_VALID=`. + +Added on preflight revision round 1 (seven blocking, five non-blocking; five of the twelve were +things a read-only planning pass could have caught and did not): + +14. **A zero-hit `NotImplementedException` gate is unsatisfiable in that file.** + `QfcDatamodel.QueueProcessing.cs:25-29` already contains `throw new NotImplementedException();` inside a pre-existing + `UndoMove()`. Gate on the *seam's own quoted message* instead, and state the expected non-zero count for the retained + pre-existing occurrence. Same class as [[zero-hit-grep-gates-need-carveouts]]: always grep the target file for the + absence token before writing the gate. + +15. **The 500-line ceiling does not reach `*.csproj`, and `QuickFiler.Test/QuickFiler.Test.csproj` is already 524 lines.** + `.csharpierignore:9-14` records project files as owned by Visual Studio and not C# source, and + `.claude/rules/general-code-change.md` caps production code, test code and reusable script files. A file-size audit that + enumerates the csproj alongside `.cs` paths and then asserts "every listed count is at or below 500" is unsatisfiable on + the first line it prints. Scope every ceiling clause to `.cs` and record the project file as an exempt observation. + Related: [[feedback-postformat-file-size-audit]]. + +16. **`Glob` and `Grep` honour `.gitignore`, so a gitignored directory reads as absent.** `packages/` is populated (172 + subdirectories) but `.gitignore:191` (`**/[Pp]ackages/*`) hides it from both tools, and I planned a repair for a state + that did not exist. Never assert a directory is missing from a read-only pass when its path is gitignored — state it as + unverifiable and have the executor observe it, or phrase the task as a confirmation rather than a repair. The same + caveat applies to `.dotnet-sdk/` and `coverage/`. + +17. **No shell variable survives between plan tasks.** Every fenced block runs in its own shell, so a `$vstest` resolved in + one task is unbound in the next, and an unbound `$BaseSha` silently degrades `git diff --name-only $BaseSha -- ...` into + the ref-less G8 form that passes vacuously once the change is committed. Write a plan-wide re-binding rule and repeat + the preamble in every block. Deriving `$BaseSha` with + `Select-String -CaseSensitive -Pattern '^BASE-SHA: ([0-9a-f]{40})$'` over the Phase 0 artifact is better than a pasted + literal: it carries no placeholder and fails loudly if the artifact is missing. Related: + [[never-pin-head-sha-as-plan-expectation]] and [[diff-gates-need-a-commit-task]]. + +18. **A class-scoped `/TestCaseFilter` makes a "passed count equals the inventory count" acceptance unsatisfiable.** + `FullyQualifiedName~SomeTestClass` selects every test in the class, including the ones already green. Assert one + `PASS-AFTER: ` line per inventory entry and record the run's own totals separately as + non-asserted observations. + +19. **`&` binds tighter than `|` in a vstest filter expression.** `TestCategory!=LiveOutlook&FQN~A|FQN~B` parses as + `(TestCategory!=LiveOutlook AND FQN~A) OR FQN~B`, so the category exclusion silently applies to only the first clause. + Either drop the category clause when it selects nothing, and say why, or repeat it on every disjunct. + +20. **A retarget must be checked against the seam the test actually drives.** The `QfcQueuePurePathsTests` case reaches the + gate through `QfcDatamodel.DequeueWithHighConfidenceGateWithOutcomeAsync`, whose construction at + `QfcDatamodel.QueueProcessing.cs:184-194` passes neither new bound, so a "drive it to the scan cap" retarget is + unreachable: the default cap is 250 and the fixture holds ten items. The reachable lever is the time ceiling, driven by + the existing fake-clock advance in the scoring-service callback. Ask which parameters the *intermediate* production + layer forwards before designing a retarget through it. + +21. **Count `[TestMethod]` attributes rather than trusting a reading.** I wrote "all six tests" for a file with seven. + A miscount in an acceptance clause is a defect even when the intent is right. + +22. **A private test helper can block a retarget.** `CreateLowYieldGate` (Part2.cs:37-70) takes a mandatory `TimeSpan + deadline` and exposes no cap, so two of the four Part2 retargets could not express their new arrangement. Enumerate the + helper's callers (exactly two, both retargeted) before widening it, so the widening's blast radius is stated rather + than assumed. + +23. **An artifact must name one source for its figures.** [P0-T14] said "derived from the [P0-T10] TRX" while also + supplying its own run; two sources for one number is a provenance defect even when both agree. + +Added on preflight revision round 2 (one defect, in the changed-line coverage gate): + +24. **A changed-line coverage gate must admit a non-executable outcome, or it is unsatisfiable for a + declaration-only edit.** Cobertura emits a `` element only for a line carrying IL, so XML doc + comments, blank lines, `using` directives, braces, enum members and interface method declarations all + appear in a `git diff --unified=0` changed-line set and in neither branch of the merged + `./lines/line` + `./methods/method/lines/line` map. "Every changed line is recorded with a `hits` + value" therefore cannot be satisfied. Add a `hits=non-executable` marker and state the `hits = 0` + count over executable lines only. `QuickFiler/Interfaces/IQfcDatamodel.cs` is the worked case: its + only IL-emitting members are the `QfcDequeueBatch` constructor and three expression-bodied + properties (`:49-81`), so a change that adds an enum member, an interface declaration and XML docs + yields no coverage datum at all — while the file still reports a class element, because the struct + does. Key the marker on the absence of a `line` element for the specific changed line, not on the + file-level measurable determination. Distinct mechanism from item 1: an excluded type versus a + changed line that emits no IL. Related: [[deletion-adjusted-coverage-no-regression-gate]]. + +Related: [[project-731-lifecycle-disposal-plan-seams]], [[project-781-excludefromcodecoverage-guard-plan-seams]], +[[reference-vstest-scoped-run-command]], [[repo-wide-cobertura-line-rate-is-nondeterministic]]. diff --git a/.claude/agent-memory/feature-review/MEMORY.md b/.claude/agent-memory/feature-review/MEMORY.md index b7eb5a815..aa53750c4 100644 --- a/.claude/agent-memory/feature-review/MEMORY.md +++ b/.claude/agent-memory/feature-review/MEMORY.md @@ -72,6 +72,7 @@ - [680-review-residuals](project_680-review-residuals.md) — closed GO c3; leak class recurred 3x (TRX, plan draft, QA's own fresh vstest output); sanitize in-task every cycle - [677-review-residuals](project_677-review-residuals.md) — PASS/0 blocking; compile-red RED-first equivalence; 70.7% modified-file non-blocking - [781-review-residuals](project_781-review-residuals.md) — PASS/0 blocking; ItemViewer `[ExcludeFromCodeCoverage]` = 0 Cobertura classes; executor blamed the wrong package for a -2 line delta +- [791-review-residuals](project_791-review-residuals.md) — PASS/0 blocking, 6/6 AC; QfcDatamodel excluded from Cobertura; walk ALL THREE links of a "runs under finally" ownership chain - [440-review-residuals](project_440-review-residuals.md) — PASS/0 blocking; a "corrected" defect-encoding test can be defect-NEUTRAL (check fail-before Totals) - [644-review-residuals](project_644-review-residuals.md) — all 3 cycles PASS/0 blocking; rejecting the caller's `.claude/agent-memory` diff exclusion found the only new defect - [647-review-residuals](project_647-review-residuals.md) — PASS/0 blocking, 21/21 AC; AC20 PASS-with-deviation on in-spec provisions diff --git a/.claude/agent-memory/feature-review/policy-audit-comparison-line-schema.md b/.claude/agent-memory/feature-review/policy-audit-comparison-line-schema.md index 7674914a9..53d6bfbaf 100644 --- a/.claude/agent-memory/feature-review/policy-audit-comparison-line-schema.md +++ b/.claude/agent-memory/feature-review/policy-audit-comparison-line-schema.md @@ -21,3 +21,17 @@ The `validate_policy_audit_artifact.py` validator (in `drm-copilot/scripts/dev_t **Confirmed on issue #197 R4 (2026-06-13):** the safest approach is to copy the prior passing cycle's exact 1.2.1 bullet wording and only swap the numbers. Two concrete gotchas observed: - A bullet written as `Baseline: 59.03% lines -> Post-change: 71.65% lines. Change: +12.62 pp lines.` (em-dash-arrow joining Baseline and Post-change on one segment, `pp` unit on Change) FAILED with `Policy audit missing per-language comparison line for C#`. Rewriting to the prior-cycle form `Baseline: 59.03% lines (38,820/65,768) -> Post-change: 71.65% lines (37,019/51,665). Change: +12.62% lines (...).` PASSED. Use `%` (not `pp`) and the parenthetical covered/valid counts. - `New/changed-code coverage: N/A - no new executable production code (...)` is ACCEPTED for an attribute/config/doc-only C# change (no numeric percent required) — the numeric-percent requirement in [[policy-audit-numeric-new-code-coverage]] applies only when the coverage-metrics table row's New Code Coverage cell is non-N/A. + +**Confirmed on issue #791 (2026-09-06), two more exact-shape gotchas, each one rejection cycle:** + +- The percent must be **immediately** followed by the sentence-ending period. + `New/changed-code coverage: 90.8% lines (119/131 executable changed lines covered, 0 regressions).` + FAILED with `missing numeric new/changed-code coverage for C#`; rewriting to + `New/changed-code coverage: 90.8%.` PASSED. Move any parenthetical or unit word into the `Evidence:` + clause or the prose paragraph below the bullets. +- For a zero-file language the bullet must be the bare #781 five-field form and must **omit** the + `New/changed-code coverage:` field entirely: + `- PowerShell: Baseline: N/A. Post-change: N/A. Change: N/A. Disposition: N/A. Evidence: N/A - zero PowerShell files changed on this branch.` + Writing `Baseline: N/A - out of scope. ... New/changed-code coverage: N/A - out of scope.` FAILED + with `comparison line missing numeric baseline, post-change, and new/changed-code coverage`. This + form is also safer against the local coverage hook, because it drops `out of scope` from the bullet. diff --git a/.claude/agent-memory/feature-review/policy-audit-required-structure.md b/.claude/agent-memory/feature-review/policy-audit-required-structure.md index 460fdae93..7ee5fad67 100644 --- a/.claude/agent-memory/feature-review/policy-audit-required-structure.md +++ b/.claude/agent-memory/feature-review/policy-audit-required-structure.md @@ -31,3 +31,59 @@ above as missing-heading / missing-checklist-line / missing-numeric errors. **How to apply:** Keep the template's Appendix A and the full coverage checklist; for out-of-scope languages use `N/A - out of scope` rather than deleting the line. See also [[feature-audit-checkoff-heading-case]]. + +**The checklist must be plain top-level `- ` bullets, not table cells (confirmed #791, 2026-09-06, +cost one rejection cycle).** Rendering the four `TypeScript|PowerShell baseline|post-change coverage +artifact:` items and `Per-language comparison summary:` as rows of a `| Item | State | Note |` table +is NOT accepted — the validator reported all five as missing even though every string was present in +the file. Emit them as the #781 shape, verbatim, ideally under a `### Coverage Evidence Checklist` +heading placed before `### 1.2.1`: + +``` +- C# baseline coverage artifact: `coverage/-baseline.cobertura.xml` +- C# post-change coverage artifact: `artifacts/csharp/coverage.xml` +- TypeScript baseline coverage artifact: `N/A - out of scope` +- TypeScript post-change coverage artifact: `N/A - out of scope` +- PowerShell baseline coverage artifact: `N/A - out of scope` +- PowerShell post-change coverage artifact: `N/A - out of scope` +- Python baseline coverage artifact: `N/A - out of scope` +- Python post-change coverage artifact: `N/A - out of scope` +- Per-language comparison summary: section 1.2.1 of this document +``` + +A detail table may be kept alongside, but then do not title it `### 1.2.2 Coverage Evidence +Checklist` — a second heading with that name risks the parser binding to the table instead of the +bullets. `### 1.2.2 Coverage Artifact State` works and still terminates the 1.2.1 bullet scan. + +**The `**Coverage Metrics by Language:**` table is bound POSITIONALLY, not by header name +(confirmed #791, 2026-09-06, cost a second rejection cycle).** Every markdown row with exactly +SEVEN cells whose first cell is neither `Language` nor a dash rule is treated as a coverage row, and +the cells bind as: + +`| Language | Files Changed | Tests | Test Result | Baseline Coverage | Post-Change Coverage | New Code Coverage |` + +Positions 5, 6 and 7 must each either start with `N/A` (case-insensitive) or contain an unanchored +`\d+(\.\d+)?%`. Consequences: + +- Do NOT reorder, rename or substitute columns. A sensible-looking header + `| Language | Changed Files | Coverage Artifact | Baseline | Post-Change | New Code | Verdict |` + puts New-Code at position 5 and `Verdict` at position 6, so C#'s `FAIL` and PowerShell's `PASS` + are read as coverage values and the audit fails with `missing numeric new/changed-code coverage` + plus spurious per-language comparison-line errors. +- Keep the coverage artifact path and the per-language verdict OUT of that table. Put them in a + separate table with a cell count that is not 7 (a 4-column + `| Language | Coverage artifact | Verdict | Disposition |` works), or in prose. Do not use `- ` + bullets for it if it sits inside the 1.2.1 region, or it will overwrite the comparison lines. +- Before finalizing, scan the whole document for any OTHER 7-cell table row — a `code-review`-style + findings table has exactly 7 and would be parsed as coverage rows if it appeared in a policy audit. +- The validator also treats `missing`, `unverified` and `tbd` inside the checklist or comparison + bullets as placeholder markers. Keep those words out of both bullet sets. + +**Inline-mention hazard (observed while drafting #791, 2026-09-06):** do not write the literal +strings `### 1.2.1 ...` or `### 1.2.2 ...` anywhere in body prose (for example in a Template +Provenance Deviation paragraph explaining which structure you followed). If the extractor does a +substring find rather than a line-start match it will begin scanning at the prose mention, hundreds +of lines above the real block, and pick up unrelated `- ` bullets. Refer to them as "the section +1.2.1 per-language coverage comparison block" instead, and verify afterwards by listing every `^\s*-` +line strictly between the two real `^### 1.2.` headings — the only bullets there should be the +per-language comparison bullets themselves. diff --git a/.claude/agent-memory/feature-review/project_791-review-residuals.md b/.claude/agent-memory/feature-review/project_791-review-residuals.md new file mode 100644 index 000000000..277bd2618 --- /dev/null +++ b/.claude/agent-memory/feature-review/project_791-review-residuals.md @@ -0,0 +1,75 @@ +--- +name: 791-review-residuals +description: "#791 QuickFiler HC deadline + Cancel teardown review: PASS/0 blocking, 6/6 AC; QfcDatamodel is [ExcludeFromCodeCoverage] so both partials emit ZERO Cobertura classes; the exception-safety invariant has an unprotected middle link in an AC5 non-goal file" +metadata: + type: project +--- + +Review of `bug/quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791` +(base `main` @ `7c8ac9ae`, head `59536368`), work mode `full-bug`. Outcome: PASS, 0 blocking, +6/6 AC, no remediation-inputs. Artifacts at timestamp `2026-09-06T15-31`. + +## Durable facts about this file family + +`QuickFiler/Controllers/QfcDatamodel.cs:25` carries `[ExcludeFromCodeCoverage]` on the **partial +class declaration**, so `QfcDatamodel.QueueProcessing.cs` and every other part emit **zero** +`` elements in Cobertura, in both the baseline and the post-change document. Same shape as +`ItemViewer` in [[781-review-residuals]]. Any AC demanding a changed-line percentage on those files +is unevaluable by construction. Confirm by enumerating `` over BOTH documents +before scoring it as something the branch introduced. + +The teardown ownership chain is three links, and only two are protected: +`ActionCancelAsync` -> `finally` -> `QfcFormController.Cleanup()` (SetupDisposal.cs:213-261, **no +try/finally**, `_parentCleanup?.Invoke()` is the last statement) -> `QfcHomeController.Cleanup()` -> +`finally` -> `ParentCleanup` -> `RibbonController.ReleaseQuickFiler`. A throw from +`_formViewer?.Dispose()` at SetupDisposal.cs:251 skips the ribbon release. `SetupDisposal.cs` is an +explicit #791 AC5 non-goal, so the gap cannot be closed on that branch. When an audit says "the +release callback runs under a `finally`", walk **every** link, not the one the diff touches. + +`RibbonController` never calls `QfcHomeController.Cleanup()` directly — it only supplies +`ReleaseQuickFiler` as the `parentCleanup` callback at `RibbonController.cs:106,120,141`. That is +what makes a repeat `QfcHomeController.Cleanup()` unreachable, and therefore what downgrades the +disposed-but-not-nulled `_tokenSource` (QfcHomeController.cs:389) from a live defect to a latent one. +The same CTS instance reaches the datamodel (`:125`) and the form controller (`:144`), and both +`QfcDatamodel.Cleanup()` and `QuiesceLoaderAsync()` open with `_tokenSource?.Cancel()`, which throws +`ObjectDisposedException` after `Dispose()`. + +## Coverage figures this cycle (class-level `classes/class/lines/line`, nine first-party packages) + +Baseline 55587/65783 = 84.50% line, 13204/16684 = 79.14% branch. +Post-change 55783/66009 = 84.51% line, 13292/16784 = 79.19% branch. +The delivery's `.//line` all-descendant selection reports ~2x those counters (112551/133187) — the +[[cobertura-class-line-double-count-trap]] — but the derived percentages match to the digit under +both selections, which is the useful cross-check. + +Per-file, both documents, same selection: gate 97.54 -> 98.10; Deactivate 100 -> 100 (branch 90 -> +91.67); IQfcDatamodel 100 -> 100; `QfcHomeController.cs` 75.85 -> 76.36; **`QfcFormController.EventHandlers.cs` +49.61 -> 58.12**. The last two are below the 85% per-file floor and both improved; both carry +`using Microsoft.Office.Interop.Outlook` + `using System.Windows.Forms`, i.e. CLAUDE.md UT2 exemption +class (c). No delivery artifact reported per-file figures — computing them is what produced the row. + +## Residuals owed at merge (none blocking) + +1. Null `_tokenSource` after `Dispose()` in `QfcHomeController.Cleanup()`; promote to an issue. +2. Promote the unprotected `_parentCleanup?.Invoke()` in `QfcFormController.Cleanup()` (AC5 non-goal + here, so it must be its own issue). +3. Promote the `QfcDatamodel` coverage exclusion as an extraction refactor: 115 new production lines + landed inside the excluded type this cycle. +4. `LogScanBoundReached`'s content (`Bound=scan-cap` / `Bound=zero-acceptance-ceiling`, + `Decision=stop`) is asserted by **no** test — grep for `scan bound reached` returns nothing — + while the two sibling log lines are content-asserted. AC1 says "the bound decision is logged". +5. `runbooks/live-outlook-cancel-teardown-verification.runbook.md:16` embeds + `C:\Users\\repos\TaskMaster\...`; the only host-path leak on the branch. +6. Both `"… ribbon release callback invoked."` INFO lines (EventHandlers.cs:171, + QfcHomeController.cs:401) are emitted unconditionally and can assert something that did not happen. +7. HI-1 (live-Outlook confirmation) outstanding by design; AC2 declares it non-gating. + +## What was strong, and worth reusing as a pattern + +The seven retargeted tests kept their pinning power: `sourceActive: () => true` so exhaustion is not +an available explanation for an empty batch; a cap of 4 substituted for a 4 s deadline so the +original take-count/residual assertions survive at exactly 4 and 6; a #608 pin with a deliberately +undersized cap so a widened guard fails it. `[P2-T15]` broke the #731 three-owner topology pin +because an `async` method hoists locals into a state-machine type — the repair moved the snapshot to +a **synchronous** helper rather than relaxing the pin from 3 to 4. That is the correct response and +is worth citing the next time a pin "has to" be loosened. diff --git a/.claude/agent-memory/human-exception-runbook/MEMORY.md b/.claude/agent-memory/human-exception-runbook/MEMORY.md index f3a3c5da4..21a093971 100644 --- a/.claude/agent-memory/human-exception-runbook/MEMORY.md +++ b/.claude/agent-memory/human-exception-runbook/MEMORY.md @@ -1,4 +1,4 @@ # Memory Index -- [No MCP docs tool wired](project_no_mcp_docs_tool.md) — MCP-first sourcing is currently aspirational; WebFetch is the sole web-second mechanism (re-verified 2026-08-08) +- [No MCP docs tool wired](project_no_mcp_docs_tool.md) — MCP-first sourcing is currently aspirational; WebFetch is the sole web-second mechanism (re-verified 2026-09-06) - [pr-author hook and MCP validator reference](reference_pr_author_hook_and_mcp_validator.md) — enforce-pr-author-skill.ps1 preflight, missing scripts/dev_tools module, available MCP validator substitute diff --git a/.claude/agent-memory/human-exception-runbook/project_no_mcp_docs_tool.md b/.claude/agent-memory/human-exception-runbook/project_no_mcp_docs_tool.md index a34343907..929977809 100644 --- a/.claude/agent-memory/human-exception-runbook/project_no_mcp_docs_tool.md +++ b/.claude/agent-memory/human-exception-runbook/project_no_mcp_docs_tool.md @@ -5,7 +5,7 @@ metadata: type: project --- -Re-verified 2026-08-28 (previously 2026-08-08, 2026-08-04; first recorded 2026-07-06): no `mcp__*` +Re-verified 2026-09-06 (previously 2026-08-28, 2026-08-08, 2026-08-04; first recorded 2026-07-06): no `mcp__*` documentation-retrieval tool wired as a dependency in TaskMaster. The `human-exception-runbook` skill's sourcing rule is MCP-first, then web-second (`.claude/skills/human-exception-runbook/SKILL.md`), but the "MCP-first" clause is currently aspirational: there is no MCP tool that can be queried for third-party UI documentation diff --git a/.claude/agent-memory/prd-feature/feedback_ac_gates_verify_satisfiability.md b/.claude/agent-memory/prd-feature/feedback_ac_gates_verify_satisfiability.md index 74871e3b5..0ee5e8ecd 100644 --- a/.claude/agent-memory/prd-feature/feedback_ac_gates_verify_satisfiability.md +++ b/.claude/agent-memory/prd-feature/feedback_ac_gates_verify_satisfiability.md @@ -1,6 +1,6 @@ --- name: ac-gates-verify-satisfiability -description: Do not encode repo-wide coverage floors (or any global threshold) as blocking AC without checking the captured baseline; grep every asserted token on disk for exact casing; and re-read spec.md from disk before reporting AC tallies +description: Do not encode repo-wide coverage floors (or any global threshold) as blocking AC without checking the captured baseline; grep every asserted token on disk for exact casing; keep unmeasured tuning numbers and file counts out of the AC section; and re-read spec.md from disk before reporting AC tallies metadata: type: feedback --- @@ -9,7 +9,8 @@ Two rules for authoring/correcting acceptance criteria in spec.md: 1. Scope threshold gates to what the change controls. The repo-wide 80% line-coverage floor applies to the testable denominator per `CLAUDE.md` § UT2 (COM/VSTO/WinForms/Outlook-Interop exemptions), not the raw uninstrumented Cobertura figure. Before writing "repository line coverage >= 80%" as a blocking AC, check the merge-base baseline evidence (`/evidence/baseline/`). If the raw figure is already below the floor, make the blocking conditions change-scoped (toolchain pass, no regression on changed lines, >= 90% on named new/changed modules and methods) and make the repo-wide figure a record-and-report obligation inside the criterion, stating the pre-existing shortfall and that the change does not lower it. 2. Grep every "token X is present in file Y" criterion against the real file before writing it, and copy the casing from the grep hit, not from the delegation prompt or the issue text. On #469 (2026-08-29) the prompt specified asserting `stackMovedItems` in `QuickFiler/Interfaces/IQfcCollectionController.cs`; the interface actually declares `StackMovedItems`, so a case-sensitive gate would have been dead on arrival. Also scope every "zero occurrences of X" criterion to a named file: the feature folder's own issue.md/spec.md/research doc quote the defect prose verbatim, so a repo-wide zero-hit gate on that prose is unsatisfiable by construction. -3. Before reporting an AC status summary, re-read the AC section from disk. Executors check off criteria while the spec agent is mid-correction; my in-context copy was stale and I reported 0/13 checked when 9 were already `[x]` on disk. The coordinator corrected this. +3. Keep unmeasured tuning figures out of the AC section. This agent's own contract requires a full `## Numeric Derivation Evidence` record (two independent search strategies, two independently enumerated member sets, an explicit set comparison) before any acceptance criterion may assert a count, enumeration, or population — and a research note that calls its numbers "engineering proposals, not measured in this session" cannot supply one. On #791 (2026-09-06) the recommended bounds (250 scanned candidates, 120 s ceiling) were therefore written into Proposed Fix, Assumptions, and Performance constraints, while AC1 said only "a cap on items scanned ... plus a time ceiling". The bound is still fully reviewable from the diff, and no dead numeric gate is created. Apply the same treatment to file counts: enumerate the Write Set instead of asserting "the seven production files". +4. Before reporting an AC status summary, re-read the AC section from disk. Executors check off criteria while the spec agent is mid-correction; my in-context copy was stale and I reported 0/13 checked when 9 were already `[x]` on disk. The coordinator corrected this. **Why:** On #424 (2026-08-06) the original AC 13 required repo-wide >= 80% while the merge-base baseline was 70.19% line / 58.30% branch — an unsatisfiable dead gate found at execution time. Corrections must be logged in a dated `## Correction Log` entry quoting the original wording, so the relaxation is visibly deliberate. diff --git a/.claude/agent-memory/prd-feature/feedback_full_bug_spec_only.md b/.claude/agent-memory/prd-feature/feedback_full_bug_spec_only.md index 181dc72a4..ed87ba4f1 100644 --- a/.claude/agent-memory/prd-feature/feedback_full_bug_spec_only.md +++ b/.claude/agent-memory/prd-feature/feedback_full_bug_spec_only.md @@ -23,4 +23,6 @@ The rule being protected is **one AC source file** — the hazard is a second fi **Exception 2 — explicit cross-reference instruction (seen on #441).** An orchestrator may delegate a `user-story.md` in `full-bug` mode *and* instruct "do not restate the acceptance criteria; cross-reference `spec.md`". That instruction removes the actual hazard directly. Comply, but harden it: the file must contain **zero** `- [ ]` items and must carry an explicit banner stating it is narrative context only and that `spec.md` is the sole AC source. Report the deviation from this memory in the final message. +**Exception 4 — a SubagentStop hook requires the artifact (seen on #791, 2026-09-06).** The caller may state that this agent's SubagentStop hook fails unless `user-story.md` exists, and ask for a one-line justification at the top. This is mechanical enforcement, not a judgment call: produce the file, open it with the justification line (why the defect is operator-facing) plus a statement that `spec.md` remains the AC source under full-bug mode, use `## Story N` headings with Given/When/Then bullets, and include zero `- [ ]` items. Keep it to one story per acceptance criterion. Report both paths back. + **Exception 3 — explicit caller request for audience context (seen on #512).** The delegating agent may simply ask for `user-story.md` for audience context, with no instruction either way about the criteria. Produce it, and keep the AC surface single: put a note at the top declaring `spec.md` the sole AC source, and use a non-checkbox heading (for example `## Outcomes (non-authoritative)`) instead of `## Acceptance Criteria`, so no checkbox list exists for a tracker to pick up. diff --git a/.claude/agent-memory/task-researcher/MEMORY.md b/.claude/agent-memory/task-researcher/MEMORY.md index 15debae2e..811268c05 100644 --- a/.claude/agent-memory/task-researcher/MEMORY.md +++ b/.claude/agent-memory/task-researcher/MEMORY.md @@ -59,6 +59,7 @@ - [banner-prefix-arity-662](project_banner_prefix_arity_662.md) — #662: AC2's decl regex contradicts AC5's aliasing remedy; the "must agree" assertion does NOT catch the widening (only the BeFalse line does); `/Tests:` and `/TestCaseFilter:` are mutually exclusive (2026-08-31) - [efc736-archiveroot-boundary-sink](project_efc736_archiveroot_boundary_sink.md) — #736: finding 6's cause is FALSE (#699 authoritative); keyboard path logs silently, no crash; 6 sink sites not 4; modal default sink would hang a live test (2026-09-02) - [qfc-lifecycle-disposal-731](project_qfc_lifecycle_disposal_731.md) — #731: sharing EmailMoveMonitor DROPS move actions; `volatile` = CS0420 build break; TWO dead ctor params; Cleanup() is UI-thread so no Task.Wait (2026-09-02) +- [qfc791-deadline-and-cancel-teardown](project_qfc791_deadline_and_cancel_teardown.md) — #791: #424 AC:231/239 + #608 AC:184 ratified the empty-at-deadline result being superseded; item cap alone can't bound the pre-UI wait; gate ctor lookup fails closed (2026-09-06) ## Artifact hygiene - [Never embed absolute host paths](../_shared_no_absolute_host_paths.md) — no `C:\Users\\...`, bare account, or machine name in ANY artifact; use `` / `` / `` / ``. vstest names TRX `__.trx` by default, so control `/ResultsDirectory:` + `LogFileName=` or rename before citing. diff --git a/.claude/agent-memory/task-researcher/project_qfc791_deadline_and_cancel_teardown.md b/.claude/agent-memory/task-researcher/project_qfc791_deadline_and_cancel_teardown.md new file mode 100644 index 000000000..6ce54fa59 --- /dev/null +++ b/.claude/agent-memory/task-researcher/project_qfc791_deadline_and_cancel_teardown.md @@ -0,0 +1,54 @@ +--- +name: qfc791-deadline-and-cancel-teardown +description: "#791 research: #424 AC:231/239 and #608 AC:184 ratified the empty-at-deadline result #791 now supersedes; an item-count cap alone cannot bound the pre-UI wait; the gate test helper's exact 9-param ctor lookup fails closed" +metadata: + type: project +--- + +Issue #791 (High Confidence empty dialog + Cancel teardown), researched 2026-09-06 against `7c8ac9ae`. +Six findings a planner working from the issue body alone would miss. + +**1. Two closed features explicitly ratified the behavior #791 changes.** `#424` spec AC +(`docs/features/archive/2026-08-06-...-424/spec.md:231`) states the zero-accepted deadline result is an +empty list plus an empty first group, and `#608` spec AC +(`docs/features/active/2026-08-25-...-608/spec.md:184`) states that behavior is retained. Both are +superseded by #791 AC1 and must be named as superseded in the spec. #446 AC-6 (`CompleteAddingAsync` +only under `SourceExhausted`, `QfcHomeController.Iteration.cs:39-47`) is *preserved* — route any new +stop reason away from that branch. + +**2. #424 already refused a settings surface for this bound.** Its AC at `spec.md:239` says the deadline +is an internal constant with an internal test seam, "no new `QfSettings`/`IAppQuickFilerSettings` +member, no `Settings.Designer.cs` change, and no ribbon plumbing". Put any hard scan cap in the same +place, not in `AppQuickFilerSettings` (`Settings.Designer.cs:1-9` is auto-generated). + +**3. A scanned-item cap alone does not bound the pre-UI wait.** The gate's empty-queue branch +(`QfcStreamingDequeueConfidenceGate.cs:185-196`) waits `timeOut` ms and retries while +`_remainingLoadActive` is true, and `scanned++` (`:205`) only runs after a score. Removing the deadline +as a terminator therefore needs BOTH an item cap and a wall-clock ceiling. + +**4. The gate test helper fails closed on an exact constructor shape.** +`QfcStreamingDequeueConfidenceGateTests.cs:27-92` does one `GetConstructor` with the exact nine-type +list and asserts it is non-null. Any added ctor parameter breaks every gate test until the helper is +updated — this is by design (#446 replaced a fallback chain that failed open). + +**5. `ActionCancelAsync` is also the normal-completion path.** `MoveAndIterate` calls it at +`QfcFormController.EventHandlers.cs:169` (error) and `:208` ("Finished Moving Emails"), so the missing +`KbdActive` reset / focus parking / ordering affects successful completion too, not just the button. +`ButtonCancel_Click` (`:70-82`) rethrows from `async void`; the existing cancel test +`QfcFormControllerTests.cs:392-403` awaits and asserts nothing. + +**6. File-size routing.** `QfcDatamodel.cs` is 480/500 (put new members in +`QfcDatamodel.QueueProcessing.cs`, 298); `QfcCollectionController.cs` is 2329 (call the public +`UnregisterNavigation()` from the form controller instead of editing it); `QfcFormControllerTests.cs` +792 and `QfcFormControllerSeamTests.cs` 496 — new cancel tests need a new file plus a +`` entry in the legacy `QuickFiler.Test.csproj`. + +**Why:** items 1 and 2 protect the plan from "regressing" ratified ACs or inventing a settings knob; +3 through 6 are the traps that turn a plausible fix into a red build or an unbounded startup. + +**How to apply:** read before planning any change to the high-confidence dequeue bound or the +QuickFiler Cancel/teardown chain. + +Related: [[qfc424-high-confidence-startup-stall]], [[qfc-lifecycle-disposal-731]] (Cleanup() is +UI-thread — no blocking wait), [[qfc677-webview2-focus-hold-outlook-keyboard]] (the park-focus routine +this reuses), [[qfc678-predictor-carry]]. diff --git a/QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs b/QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs new file mode 100644 index 000000000..b2b6d0514 --- /dev/null +++ b/QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Reflection; +using System.Runtime.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using Microsoft.Office.Interop.Outlook; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using UtilitiesCS; +using UtilitiesCS.ReusableTypeClasses; + +namespace QuickFiler.Controllers.Tests +{ + /// + /// Issue #791 AC2 coverage for the datamodel side of the Cancel teardown: the loader-quiesce + /// boundary, the relocated admission guard, and a repeat-safe Cleanup(). + /// + /// Carries its own CreateUninitializedDatamodel / SetPrivateField helpers, + /// following the existing duplication convention documented on + /// QfcDatamodelLivenessTests. Deterministic — for all + /// time, mocked , no COM, no sleeps, no wall-clock waits. + /// + /// + [TestClass] + public class QfcDatamodelTeardownTests + { + private const BindingFlags NonPublicInstance = + BindingFlags.NonPublic | BindingFlags.Instance; + + /// + /// Builds a without running its COM-bound constructors. Fields + /// the code under test reads are assigned explicitly via . + /// + private static QfcDatamodel CreateUninitializedDatamodel() => + (QfcDatamodel)FormatterServices.GetUninitializedObject(typeof(QfcDatamodel)); + + private static void SetPrivateField(object target, string name, object value) + { + FieldInfo field = target.GetType().GetField(name, NonPublicInstance); + field + .Should() + .NotBeNull($"private field '{name}' should exist on {target.GetType().Name}"); + field.SetValue(target, value); + } + + private static object GetPrivateField(object target, string name) + { + FieldInfo field = target.GetType().GetField(name, NonPublicInstance); + field + .Should() + .NotBeNull($"private field '{name}' should exist on {target.GetType().Name}"); + return field.GetValue(target); + } + + /// + /// Bounded, event-driven wait for a state transition. This is not a fixed sleep: it returns + /// as soon as the condition holds and fails the test with a clear message if it never does. + /// Required because Worker_DoWork is async void and runs on the + /// thread, so the field assignment it performs is not + /// observable synchronously from the calling thread. + /// + private static void WaitForState(Func condition, string because) => + SpinWait.SpinUntil(condition, TimeSpan.FromSeconds(5)).Should().BeTrue(because); + + /// + /// AC2, the reported crash. Once Cleanup() has nulled _masterQueue and + /// _moveMonitor, the still-running loader reached this method and constructed + /// QfcRemainingQueueAdmission over method groups on those null instances, which raises + /// "Delegate to an instance method cannot have null 'this'". + /// The guard must return at the accept point instead of throwing at + /// the throw point. + /// + [TestMethod] + public async Task TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing() + { + // Arrange — exactly the post-Cleanup field state. + QfcDatamodel model = CreateUninitializedDatamodel(); + SetPrivateField(model, "_masterQueue", null); + SetPrivateField(model, "_moveMonitor", null); + MailItem mailItem = new Mock().Object; + + // Act + Func> act = () => + model.TryQueueRemainingMailItemAsync(mailItem, CancellationToken.None); + + // Assert + bool queued = false; + Func invoke = async () => queued = await act(); + await invoke + .Should() + .NotThrowAsync( + "a released field must be a refusal at the accept point, not a throw" + ); + queued.Should().BeFalse("nothing can be queued once the master queue is released"); + } + + /// + /// AC2: a loader that has already finished is reported as completed and the quiesce returns + /// without consuming any of the bound. The fake clock is never advanced, so a wait on the + /// bound could not complete: only the completion path can finish this call. + /// + [TestMethod] + public async Task QuiesceLoaderAsync_LoaderCompletes_ReturnsBeforeTimeout() + { + // Arrange + QfcDatamodel model = CreateUninitializedDatamodel(); + var logs = new List(); + model.TimeProvider = new FakeTimeProvider(); + model.QuiesceDebugLog = logs.Add; + model.TokenSource = new CancellationTokenSource(); + SetPrivateField(model, "_remainingLoadTask", Task.CompletedTask); + + // Act + Task pending = model.QuiesceLoaderAsync(TimeSpan.FromSeconds(5)); + await pending; + + // Assert + pending.IsCompleted.Should().BeTrue("the loader had already finished"); + logs.Should() + .ContainSingle(line => line.Contains("Loader quiesce completed")) + .Which.Should() + .NotBeNullOrEmpty(); + } + + /// + /// AC2: a loader still in flight is bounded out, reported, and never raised. The timeout case + /// is what makes the Cancel path safe to await: it always returns, so a hung loader cannot + /// stall the teardown. + /// + [TestMethod] + public async Task QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs() + { + // Arrange + QfcDatamodel model = CreateUninitializedDatamodel(); + var logs = new List(); + var fake = new FakeTimeProvider(); + var hanging = new TaskCompletionSource(); + model.TimeProvider = fake; + model.QuiesceDebugLog = logs.Add; + model.TokenSource = new CancellationTokenSource(); + SetPrivateField(model, "_remainingLoadTask", hanging.Task); + + // Act — advancing the fake clock is the only thing that releases the bound, so the test + // carries no wall-clock wait and no sleep. + Task pending = model.QuiesceLoaderAsync(TimeSpan.FromSeconds(5)); + pending + .IsCompleted.Should() + .BeFalse("the loader has not completed and the bound is open"); + + fake.Advance(TimeSpan.FromSeconds(6)); + Func act = () => pending; + + // Assert + await act.Should().NotThrowAsync("a bounded-out loader is reported, never raised"); + logs.Should().ContainSingle(line => line.Contains("Loader quiesce timed out")); + hanging.TrySetResult(false); + } + + /// + /// AC2: a second Cancel, or a Cancel after a partially failed launch, reaches + /// Cleanup() with _globals and _moveMonitor already released. The + /// unguarded dereferences raised there, which aborted + /// the teardown before the release callback. + /// + [TestMethod] + public void Cleanup_CalledTwice_DoesNotThrow() + { + // Arrange + QfcDatamodel model = CreateUninitializedDatamodel(); + using (var worker = new BackgroundWorker { WorkerSupportsCancellation = true }) + using (var tokenSource = new CancellationTokenSource()) + { + SetPrivateField(model, "_globals", null); + SetPrivateField(model, "_moveMonitor", null); + SetPrivateField(model, "_tokenSource", tokenSource); + SetPrivateField(model, "_worker", worker); + + // Act — `System.Action` is required: a bare `Action` is CS0104-ambiguous with + // Microsoft.Office.Interop.Outlook.Action in this namespace. + System.Action act = () => + { + model.Cleanup(); + model.Cleanup(); + }; + + // Assert + act.Should() + .NotThrow("repeat teardown must be inert, not a fault on released fields"); + } + } + + /// + /// AC2 capture pin. Worker_DoWork is async void and retained no handle to the + /// loader task, so nothing could await it. Capturing the task into _remainingLoadTask + /// is what gives QuiesceLoaderAsync something to wait on. + /// + [TestMethod] + public void Worker_DoWork_CapturesRemainingLoadTask() + { + // Arrange + QfcDatamodel model = CreateUninitializedDatamodel(); + var loaderEntered = new TaskCompletionSource(); + var loaderRelease = new TaskCompletionSource(); + model.RemainingEmailLoader = async _ => + { + loaderEntered.TrySetResult(true); + return await loaderRelease.Task; + }; + + using (var worker = new BackgroundWorker()) + { + // Act — the issue #244 zero-batch short-circuit is COM-free and still starts the + // worker, which is the only path that reaches Worker_DoWork without live Outlook. + model.InitEmailQueue(0, worker); + loaderEntered + .Task.Wait(TimeSpan.FromSeconds(5)) + .Should() + .BeTrue("the started worker must reach the injected RemainingEmailLoader"); + + // Assert + WaitForState( + () => GetPrivateField(model, "_remainingLoadTask") != null, + "the loader task must be captured before it is awaited, so the Cancel path has " + + "a handle to quiesce" + ); + + loaderRelease.TrySetResult(true); + } + } + } +} diff --git a/QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs b/QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs new file mode 100644 index 000000000..df72974d8 --- /dev/null +++ b/QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs @@ -0,0 +1,393 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using QuickFiler.Controllers; +using QuickFiler.Interfaces; +using UtilitiesCS; + +namespace QuickFiler.Controllers.Tests +{ + /// + /// Issue #791 AC2: the ordered, logged, exception-safe Cancel teardown on + /// QfcFormController. + /// + /// Modeled on QfcFormControllerDeactivateTests: the viewer is a of + /// , the collection controller is injected by private-field + /// reflection, and a plus an empty exclusion list + /// satisfy the guard at the top of Register/UnregisterFormEventHandlers. No window + /// is ever shown and no WinForms handle is created, so the suite stays headless. Ordering is + /// asserted through a shared invocation-order list populated by Callback handlers, + /// comparing the first index of each marker. + /// + /// + [TestClass] + public class QfcFormControllerCancelTeardownTests + { + private const BindingFlags PrivateInstance = BindingFlags.NonPublic | BindingFlags.Instance; + + private const string MarkerToggleKeyboard = "toggle-keyboard"; + private const string MarkerParkFocus = "park-focus"; + private const string MarkerUnregisterNavigation = "unregister-navigation"; + private const string MarkerUnregisterFormHandlers = "unregister-form-handlers"; + private const string MarkerQuiesce = "quiesce-loader"; + private const string MarkerGroupsCleanup = "groups-cleanup"; + private const string MarkerParentCleanup = "parent-cleanup"; + + private Mock _mockGlobals; + private Mock _mockAF; + private Mock _mockFormViewer; + private Mock _mockQfcQueue; + private Mock _mockParent; + private Mock _mockKeyboardHandler; + private Mock _mockDataModel; + private CancellationTokenSource _tokenSource; + private List _order; + + [TestInitialize] + public void Setup() + { + _order = new List(); + _mockGlobals = new Mock(); + _mockAF = new Mock(); + _mockGlobals.Setup(g => g.AF).Returns(_mockAF.Object); + _mockFormViewer = new Mock(); + _mockQfcQueue = new Mock(); + _mockParent = new Mock(); + _mockKeyboardHandler = new Mock(); + _mockDataModel = new Mock(); + _tokenSource = new CancellationTokenSource(); + + // Satisfies the guard at the top of Register/UnregisterFormEventHandlers so the + // controller reaches the intent-event unsubscription block. The exclusion-list read is + // the observable proof that the form handlers were unregistered. + _mockFormViewer + .SetupGet(x => x.Controls) + .Returns(new Control.ControlCollection(new Control())); + _mockFormViewer + .Setup(x => x.GetKeyEventExclusionControls()) + .Returns(new List()) + .Callback(() => _order.Add(MarkerUnregisterFormHandlers)); + _mockFormViewer + .Setup(x => x.ParkFocusOffWebView2()) + .Callback(() => _order.Add(MarkerParkFocus)); + + _mockKeyboardHandler + .Setup(x => x.ToggleKeyboardDialog()) + .Callback(() => _order.Add(MarkerToggleKeyboard)); + _mockParent.SetupGet(x => x.KeyboardHandler).Returns(_mockKeyboardHandler.Object); + _mockParent.SetupGet(x => x.DataModel).Returns(_mockDataModel.Object); + _mockParent.SetupGet(x => x.TokenSource).Returns(_tokenSource); + _mockDataModel + .Setup(x => x.QuiesceLoaderAsync(It.IsAny())) + .Returns(Task.CompletedTask) + .Callback(() => _order.Add(MarkerQuiesce)); + } + + private QfcFormController CreateController(System.Action parentCleanup = null) => + new QfcFormController( + _mockGlobals.Object, + _mockFormViewer.Object, + _mockQfcQueue.Object, + QfEnums.InitTypeEnum.Sort, + parentCleanup ?? (() => _order.Add(MarkerParentCleanup)), + _mockParent.Object, + _tokenSource, + _tokenSource.Token + ); + + private static void SetPrivateField(object target, string fieldName, object value) => + target.GetType().GetField(fieldName, PrivateInstance).SetValue(target, value); + + /// + /// Injects a collection controller whose ItemGroups carries one + /// per supplied item controller, with order markers on the two + /// members the Cancel path drives. + /// + private Mock InjectGroups( + QfcFormController controller, + params Mock[] itemControllers + ) + { + var groups = new List(); + foreach (Mock itemController in itemControllers) + { + groups.Add(new QfcItemGroup { ItemController = itemController.Object }); + } + + var collection = new Mock(); + collection.SetupGet(x => x.ItemGroups).Returns(groups); + collection + .Setup(x => x.UnregisterNavigation()) + .Callback(() => _order.Add(MarkerUnregisterNavigation)); + collection.Setup(x => x.Cleanup()).Callback(() => _order.Add(MarkerGroupsCleanup)); + SetPrivateField(controller, "_groups", collection.Object); + return collection; + } + + /// First index of , or -1 when it never occurred. + private int FirstIndexOf(string marker) => _order.IndexOf(marker); + + /// + /// AC2: the keyboard-active flag is reset on the Cancel path. Left set, the Outlook keyboard + /// stays captured after the dialog closes, which is the reported 37-minute lockout. + /// + [TestMethod] + public async Task ActionCancelAsync_ResetsKbdActive_WhenKeyboardDialogActive() + { + // Arrange + _mockKeyboardHandler.SetupGet(x => x.KbdActive).Returns(true); + QfcFormController controller = CreateController(); + InjectGroups(controller); + + // Act + await controller.ActionCancelAsync(); + + // Assert + _mockKeyboardHandler.Verify( + x => x.ToggleKeyboardDialog(), + Times.Once, + "an active keyboard dialog must be toggled off before the form goes away" + ); + } + + /// + /// AC2 negative control: toggling an already-inactive keyboard dialog would turn it ON, so + /// the reset must be conditional. Mirrors the OK path. + /// + [TestMethod] + public async Task ActionCancelAsync_DoesNotToggle_WhenInactive() + { + // Arrange + _mockKeyboardHandler.SetupGet(x => x.KbdActive).Returns(false); + QfcFormController controller = CreateController(); + InjectGroups(controller); + + // Act + await controller.ActionCancelAsync(); + + // Assert + _mockKeyboardHandler.Verify( + x => x.ToggleKeyboardDialog(), + Times.Never, + "toggling an inactive dialog would activate it, not reset it" + ); + } + + /// + /// AC2: WebView2 focus is parked and every open breadcrumb selector is cancelled on the + /// Cancel path, which the #677 fix wired to Form.Deactivate only — an event the + /// Cancel path itself unsubscribes. + /// + [TestMethod] + public async Task ActionCancelAsync_ParksFocusAndCancelsBreadcrumbSelectors() + { + // Arrange + _mockFormViewer.SetupGet(x => x.IsWebView2Focused).Returns(true); + var first = new Mock(); + var second = new Mock(); + QfcFormController controller = CreateController(); + InjectGroups(controller, first, second); + + // Act + await controller.ActionCancelAsync(); + + // Assert + _mockFormViewer.Verify(x => x.ParkFocusOffWebView2(), Times.Once); + first.Verify(x => x.CancelBreadcrumbSelector(), Times.Once); + second.Verify(x => x.CancelBreadcrumbSelector(), Times.Once); + } + + /// + /// AC2 ordering: navigation and form keyboard handlers are unregistered BEFORE the item rows + /// are removed. Reversed — which is what the code did — the recursive unsubscribe no longer + /// reaches the item controls' PreviewKeyDown/KeyDown subscriptions, because the controls are + /// already gone. + /// + [TestMethod] + public async Task ActionCancelAsync_UnregistersHandlersBeforeGroupsCleanup() + { + // Arrange + QfcFormController controller = CreateController(); + InjectGroups(controller, new Mock()); + + // Act + await controller.ActionCancelAsync(); + + // Assert + FirstIndexOf(MarkerUnregisterNavigation) + .Should() + .BeGreaterThanOrEqualTo(0, "the navigation ledger must be drained on Cancel"); + FirstIndexOf(MarkerGroupsCleanup) + .Should() + .BeGreaterThan( + FirstIndexOf(MarkerUnregisterNavigation), + "rows may only be removed after navigation is unregistered" + ); + FirstIndexOf(MarkerGroupsCleanup) + .Should() + .BeGreaterThan( + FirstIndexOf(MarkerUnregisterFormHandlers), + "rows may only be removed after the form handlers are unregistered" + ); + } + + /// + /// AC2 ordering: the background loader is stopped and awaited before any datamodel field is + /// nulled. A completed task is the same shape the timeout path returns, so a timed-out + /// quiesce still proceeds through the later stages; the timeout path itself is pinned + /// independently by QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs. + /// + [TestMethod] + public async Task ActionCancelAsync_AwaitsLoaderQuiesceBeforeGroupsCleanup() + { + // Arrange + QfcFormController controller = CreateController(); + InjectGroups(controller, new Mock()); + + // Act + await controller.ActionCancelAsync(); + + // Assert + _mockDataModel.Verify(x => x.QuiesceLoaderAsync(It.IsAny()), Times.Once); + FirstIndexOf(MarkerGroupsCleanup) + .Should() + .BeGreaterThan( + FirstIndexOf(MarkerQuiesce), + "the loader must be quiesced before the rows and fields are released" + ); + FirstIndexOf(MarkerParentCleanup) + .Should() + .BeGreaterThan( + FirstIndexOf(MarkerGroupsCleanup), + "a completed quiesce must not short-circuit the remaining stages" + ); + } + + /// + /// AC2: the ribbon release callback runs even when an earlier teardown stage throws. Without + /// it, RibbonController.ReleaseQuickFiler never runs and both ribbon buttons become + /// no-ops for the rest of the Outlook session. + /// + [TestMethod] + public async Task ActionCancelAsync_GroupsCleanupThrows_StillInvokesParentCleanup() + { + // Arrange + QfcFormController controller = CreateController(); + Mock groups = InjectGroups(controller); + groups + .Setup(x => x.Cleanup()) + .Callback(() => _order.Add(MarkerGroupsCleanup)) + .Throws(new InvalidOperationException("groups cleanup failed")); + + // Act + Func act = () => controller.ActionCancelAsync(); + + // Assert + await act.Should().NotThrowAsync("a failing stage must not abort the teardown"); + _order + .Should() + .Contain( + MarkerParentCleanup, + "the release callback runs under finally, whichever stage threw" + ); + } + + /// + /// AC2: ButtonCancel_Click is async void, so a rethrown exception becomes an + /// unhandled Outlook UI-thread failure rather than anything an operator can act on. The + /// throw is raised from the handler's own body by nulling the private _formViewer + /// field, so the SetSynchronizationContext call at the top of the handler raises + /// inside its own try. + /// + /// An async void escape is posted to the captured + /// rather than propagated to the caller, so a capturing + /// context is installed for the call: asserting that nothing was posted is the only way to + /// observe the rethrow, and it is what makes this test false before the fix. + /// + /// + [TestMethod] + public void ButtonCancel_Click_ActionThrows_DoesNotRethrow() + { + // Arrange + QfcFormController controller = CreateController(); + InjectGroups(controller); + SetPrivateField(controller, "_formViewer", null); + SynchronizationContext previous = SynchronizationContext.Current; + var capturing = new CapturingSynchronizationContext(); + + // Act + try + { + SynchronizationContext.SetSynchronizationContext(capturing); + controller.ButtonCancel_Click(this, EventArgs.Empty); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } + + // Assert + capturing + .Captured.Should() + .BeEmpty( + "a teardown failure must be logged, not rethrown into the Outlook UI thread" + ); + } + + /// + /// AC2: repeat invocation is inert. Double Cancel, or a Cancel after the MoveAndIterate + /// completion path (which calls the same method), must not invoke the ribbon release + /// callback twice and must not throw. Repeat invocation is inert by construction rather than + /// by a flag: the first pass nulls the fields the second pass would use. + /// + [TestMethod] + public async Task ActionCancelAsync_CalledTwice_InvokesParentCleanupOnce() + { + // Arrange + QfcFormController controller = CreateController(); + InjectGroups(controller, new Mock()); + + // Act + await controller.ActionCancelAsync(); + Func second = () => controller.ActionCancelAsync(); + + // Assert + await second.Should().NotThrowAsync("a second Cancel must be inert, not a fault"); + _order + .FindAll(marker => marker == MarkerParentCleanup) + .Should() + .ContainSingle("the ribbon release callback must run exactly once"); + } + + /// + /// Captures anything posted or sent to it instead of letting it reach the thread pool, which + /// is where an async void escape would otherwise surface as an unobserved crash. + /// + private sealed class CapturingSynchronizationContext : SynchronizationContext + { + public List Captured { get; } = new List(); + + public override void Post(SendOrPostCallback d, object state) => Run(d, state); + + public override void Send(SendOrPostCallback d, object state) => Run(d, state); + + private void Run(SendOrPostCallback d, object state) + { + try + { + d(state); + } + catch (Exception exception) + { + Captured.Add(exception); + } + } + } + } +} diff --git a/QuickFiler.Test/Controllers/QfcHomeControllerCleanupTests.cs b/QuickFiler.Test/Controllers/QfcHomeControllerCleanupTests.cs new file mode 100644 index 000000000..a3513780b --- /dev/null +++ b/QuickFiler.Test/Controllers/QfcHomeControllerCleanupTests.cs @@ -0,0 +1,118 @@ +using System; +using System.ComponentModel; +using System.Reflection; +using System.Threading; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using QuickFiler.Interfaces; +using UtilitiesCS; + +namespace QuickFiler.Controllers.Tests +{ + /// + /// Issue #791 AC2 coverage for QfcHomeController.Cleanup(): the ribbon release callback + /// must run under a finally, the cancellation token source must be disposed, and the + /// worker-completed handler must be detached before the viewer reference is dropped. + /// + /// The controller is built through the public + /// QfcHomeController(IApplicationGlobals, System.Action) constructor and its private + /// fields are injected by reflection, exactly as QfcHomeControllerPropertyTests already + /// does. No window is shown and no Outlook COM is touched. + /// + /// + [TestClass] + public class QfcHomeControllerCleanupTests + { + private const BindingFlags PrivateInstance = BindingFlags.NonPublic | BindingFlags.Instance; + + private static void SetPrivateField(object target, string name, object value) + { + FieldInfo field = target.GetType().GetField(name, PrivateInstance); + field + .Should() + .NotBeNull($"private field '{name}' should exist on {target.GetType().Name}"); + field.SetValue(target, value); + } + + /// + /// AC2: if the datamodel cleanup throws, RibbonController.ReleaseQuickFiler must still + /// run. Without the finally it never runs and both ribbon buttons become no-ops for + /// the rest of the Outlook session, which is unrecoverable without restarting Outlook. + /// + [TestMethod] + public void Cleanup_DatamodelCleanupThrows_StillInvokesParentCleanup() + { + // Arrange + var parentCleanup = new Mock(); + var dataModel = new Mock(); + dataModel + .Setup(x => x.Cleanup()) + .Throws(new InvalidOperationException("datamodel cleanup failed")); + var controller = new QfcHomeController( + new Mock().Object, + parentCleanup.Object + ); + SetPrivateField(controller, "_datamodel", dataModel.Object); + + // Act + Action act = () => controller.Cleanup(); + + // Assert + act.Should().NotThrow("a failing cleanup stage must be logged, not propagated"); + parentCleanup.Verify( + x => x.Invoke(), + Times.Once, + "the release callback runs under finally, whichever stage threw" + ); + } + + /// + /// AC2: the token source is disposed and the worker-completed handler is detached. An + /// undisposed leaks its registrations, and a + /// worker-completed handler still attached after teardown runs against a nulled viewer. + /// Disposal is observed by reading afterwards, + /// which is the documented post-dispose throw; the detach is observed by the viewer mock's + /// Worker getter having been read, which only the detach path does during cleanup. + /// + [TestMethod] + public void Cleanup_DisposesTokenSourceAndDetachesWorkerCompleted() + { + // Arrange + var parentCleanup = new Mock(); + var formViewer = new Mock(); + using (var worker = new BackgroundWorker()) + { + formViewer.SetupGet(x => x.Worker).Returns(worker); + var tokenSource = new CancellationTokenSource(); + var controller = new QfcHomeController( + new Mock().Object, + parentCleanup.Object + ); + SetPrivateField(controller, "_datamodel", new Mock().Object); + SetPrivateField(controller, "_formViewer", formViewer.Object); + SetPrivateField(controller, "_tokenSource", tokenSource); + + // Act + controller.Cleanup(); + + // Assert + Action readToken = () => + { + CancellationToken _ = tokenSource.Token; + }; + readToken + .Should() + .Throw( + "the token source must be disposed during cleanup" + ); + formViewer.VerifyGet( + x => x.Worker, + Times.AtLeastOnce, + "the worker-completed handler must be detached before the viewer is dropped" + ); + parentCleanup.Verify(x => x.Invoke(), Times.Once); + } + } + } +} diff --git a/QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs b/QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs index e32ed1bc4..17bc798a3 100644 --- a/QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs +++ b/QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs @@ -405,6 +405,26 @@ public async Task IterateQueueAsync_EmptyBatchWithDeadlineExpired_DoesNotComplet ); } + /// + /// Issue #791 AC6. The new bounded-exit stop reason must not be routed into the + /// SourceExhausted branch: a scan-cap or ceiling exit leaves unscanned candidates in + /// the master queue, exactly as a deadline exit did, so it must not close the UI queue. This + /// is the pin that #446 AC-6 is preserved by an unmodified QfcHomeController.Iteration.cs. + /// + [TestMethod] + public async Task IterateQueueAsync_EmptyBatchWithScanCapReached_DoesNotCompleteAdding() + { + var (_, queue, _, _) = ArrangeIterate(stop: QfcDequeueStop.ScanCapReached); + + await _controller.IterateQueueAsync(); + + VerifyCompleteAdding( + queue, + Times.Never, + "a bound-terminated empty batch must not close the queue" + ); + } + /// /// Issue #446 negative control for AC2: a genuinely drained source SHOULD close the queue, /// so a fix that merely stopped calling CompleteAddingAsync would break this test. diff --git a/QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs b/QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs index 45493c743..e5e724e58 100644 --- a/QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs +++ b/QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs @@ -191,15 +191,18 @@ public async Task DequeueNextItemGroupAsync_HighConfidenceRejectedItem_UnhooksFr } /// - /// Issue #446. A gate result produced by first-batch deadline expiry must be projected - /// through the datamodel as QfcDequeueStop.DeadlineExpired rather than folded into - /// the generic quantity-satisfied outcome, otherwise the caller cannot tell a - /// deadline-bounded empty batch from genuine exhaustion. Driven by - /// : every score consumes one second of a three-second budget - /// and nothing qualifies, so the deadline exit is the one the gate takes. + /// Issue #446, retargeted by issue #791. The purpose is unchanged: a bounded empty gate + /// result must be projected through the datamodel verbatim rather than folded into the + /// generic quantity-satisfied outcome, otherwise the caller cannot tell a bounded empty + /// batch from genuine exhaustion. #791 made the first-batch deadline advisory, so the bound + /// this seam can reach is the zero-acceptance time ceiling, not the scan cap: the datamodel + /// constructs the gate at QfcDatamodel.QueueProcessing.cs without passing either new + /// bound, so the default cap of 250 is unreachable from a ten-item fixture that drains + /// first. Driven by : every score consumes 61 seconds, so the + /// run origin passes the 120-second default ceiling at the third loop-top bound check. /// [TestMethod] - public async Task DequeueNextItemGroupWithOutcomeAsync_DeadlineExpiredGate_ReportsDeadlineExpiredStop() + public async Task DequeueNextItemGroupWithOutcomeAsync_ZeroAcceptanceCeilingGate_ReportsScanCapReachedStop() { // Arrange var model = CreateUninitializedDatamodel(); @@ -229,7 +232,9 @@ public async Task DequeueNextItemGroupWithOutcomeAsync_DeadlineExpiredGate_Repor ) .Returns(() => { - fake.Advance(TimeSpan.FromSeconds(1)); + // Issue #791: 61 s per score carries the run origin past the 120 s default + // zero-acceptance ceiling at the third loop-top bound check. + fake.Advance(TimeSpan.FromSeconds(61)); return Task.FromResult((100L, string.Empty, (IFolderSearchHandler)null)); }); @@ -250,12 +255,12 @@ public async Task DequeueNextItemGroupWithOutcomeAsync_DeadlineExpiredGate_Repor ); // Assert - batch.Items.Should().BeEmpty("no candidate qualified before the deadline"); + batch.Items.Should().BeEmpty("no candidate qualified before the bound"); batch .Stop.Should() .Be( - QfcDequeueStop.DeadlineExpired, - "a deadline-bounded empty batch must not be reported as quantity satisfaction" + QfcDequeueStop.ScanCapReached, + "a bound-terminated empty batch must not be reported as quantity satisfaction" ); } diff --git a/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs b/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs index 951ca2116..3775971a1 100644 --- a/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs +++ b/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Time.Testing; using Microsoft.Office.Interop.Outlook; using Microsoft.VisualStudio.TestTools.UnitTesting; +using QuickFiler.Interfaces; using UtilitiesCS; namespace QuickFiler.Controllers.Tests @@ -33,13 +34,20 @@ public partial class QfcStreamingDequeueConfidenceGateTests /// Builds a gate over candidates that all score below the /// cutoff, each score consuming one second of the budget. and /// expose the residual queue and the take count. + /// + /// Issue #791 made optional (it is now an advisory checkpoint) + /// and added (the bound that now terminates a + /// zero-acceptance scan). Both are forwarded unchanged. The optional parameters trail the + /// out ones because C# requires optional parameters last. + /// /// private static object CreateLowYieldGate( int candidateCount, - TimeSpan deadline, FakeTimeProvider fakeTime, out Queue source, - out Func takeCounter + out Func takeCounter, + TimeSpan? deadline = null, + int? maxScanWithoutAcceptance = null ) { source = new Queue( @@ -65,16 +73,19 @@ out Func takeCounter threshold: 0.90, timeProvider: fakeTime, sourceActive: () => false, - firstBatchDeadline: deadline + firstBatchDeadline: deadline, + maxScanWithoutAcceptance: maxScanWithoutAcceptance ); } /// - /// Issue #424 regression test. A low-yield stream (1 qualifier in 50) at 1 s per score is - /// scanned to exhaustion before the fix and bounded by the 12 s default deadline after it. + /// Issue #424 regression test, retargeted by issue #791. A low-yield stream (1 qualifier in + /// 50) at 1 s per score was bounded by the 12 s default deadline under #424; #791 made that + /// deadline advisory, so it now continues past it to the qualifier at position 40 and on to + /// source exhaustion. The intent is preserved, with the superseding outcome asserted. /// [TestMethod] - public async Task DequeueAsync_LowYieldStream_StopsScanningAtDefaultFirstBatchDeadline() + public async Task DequeueAsync_LowYieldStream_ContinuesPastDefaultDeadlineToTheQualifier() { // Arrange const int candidateCount = 50; @@ -113,34 +124,44 @@ public async Task DequeueAsync_LowYieldStream_StopsScanningAtDefaultFirstBatchDe ); // Act - IList result = await DequeueAsync(gate, 5, 0, CancellationToken.None); + QfcGateBatch batch = await DequeueBatchAsync(gate, 5, 0, CancellationToken.None); + IList result = batch.Accepted.Select(x => x.MailItem).ToList(); // Assert - takeCount.Should().BeLessThanOrEqualTo(13, "12 s at 1 s per score bounds the scan"); - result.Should().Equal(acceptedSoFar, "only pre-expiry acceptances may be returned"); + takeCount + .Should() + .Be(candidateCount + 1, "the advisory checkpoint no longer bounds the scan"); + result.Should().Equal(acceptedSoFar).And.Equal(qualifying); + batch.Stop.Should().Be(QfcDequeueStop.SourceExhausted, "neither bound was reached"); } - /// AC 2: zero acceptances before expiry returns an empty list at the bound. + /// + /// Issue #424 AC 2, retargeted by issue #791. Zero acceptances at the checkpoint returned an + /// empty list at the bound under #424; #791 supersedes that, so the same low-yield stream is + /// now scanned to source exhaustion instead of truncating after three candidates. + /// [TestMethod] - public async Task DequeueAsync_DeadlineExpiresWithZeroAccepted_ReturnsEmptyListAtTheBound() + public async Task DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesToSourceExhaustion() { // Arrange + const int candidateCount = 20; var fakeTime = new FakeTimeProvider(); object gate = CreateLowYieldGate( - candidateCount: 20, - deadline: TimeSpan.FromSeconds(3), + candidateCount, fakeTime, out Queue source, - out Func takeCounter + out Func takeCounter, + deadline: TimeSpan.FromSeconds(3) ); // Act - IList result = await DequeueAsync(gate, 2, 0, CancellationToken.None); + QfcGateBatch batch = await DequeueBatchAsync(gate, 2, 0, CancellationToken.None); // Assert - result.Should().BeEmpty("no candidate reached the cutoff before expiry"); - takeCounter().Should().Be(3, "a 3 s budget at 1 s per score admits three candidates"); - source.Should().HaveCount(17, "unscanned candidates stay queued, not discarded"); + batch.Accepted.Should().BeEmpty("no candidate reached the cutoff"); + takeCounter().Should().Be(candidateCount + 1, "the scan drains the source"); + source.Should().BeEmpty("no candidate is left unscanned"); + batch.Stop.Should().Be(QfcDequeueStop.SourceExhausted, "exhaustion, not a bound"); } /// @@ -200,28 +221,32 @@ IFolderSearchHandler Handler } /// - /// AC 1: after a deadline return no further takes occur and unscanned candidates remain. + /// Issue #424 AC 1, retargeted by issue #791. The bounded-exit intent is unchanged — after + /// the bound no further take occurs and unscanned candidates remain — but the bound is now + /// the scan cap rather than the 4 s deadline. A cap of 4 keeps the existing take-count and + /// residual assertions at exactly 4 and 6. /// [TestMethod] - public async Task DequeueAsync_AfterDeadlineReturn_StopsTakingAndLeavesUnscannedCandidates() + public async Task DequeueAsync_AfterScanCapReached_StopsTakingAndLeavesUnscannedCandidates() { // Arrange var fakeTime = new FakeTimeProvider(); object gate = CreateLowYieldGate( candidateCount: 10, - deadline: TimeSpan.FromSeconds(4), fakeTime, out Queue source, - out Func takeCounter + out Func takeCounter, + maxScanWithoutAcceptance: 4 ); // Act - IList result = await DequeueAsync(gate, 5, 0, CancellationToken.None); + QfcGateBatch batch = await DequeueBatchAsync(gate, 5, 0, CancellationToken.None); int takesAtReturn = takeCounter(); // Assert - result.Should().BeEmpty(); - takesAtReturn.Should().Be(4, "a 4 s budget at 1 s per score admits four candidates"); + batch.Accepted.Should().BeEmpty(); + batch.Stop.Should().Be(QfcDequeueStop.ScanCapReached, "the cap ended this scan"); + takesAtReturn.Should().Be(4, "a cap of 4 admits exactly four candidates"); takeCounter().Should().Be(takesAtReturn, "no take may occur after the method returns"); source.Should().HaveCount(6, "the unscanned remainder stays for later dequeues"); source.Dequeue().Should().NotBeNull("unscanned candidates remain takeable"); @@ -339,12 +364,16 @@ public void Constructor_NonPositiveNonSentinelDeadline_IsRejectedByGuardClause() } /// - /// Expiry emits exactly one debug line through the existing _debugLog seam carrying - /// the accepted and scanned counts, and the per-candidate "Probability debug" logging is - /// unchanged. Asserted via the injected delegate, not log capture. + /// Issue #424 logging test, retargeted by issue #791. Each checkpoint decision emits one + /// debug line through the existing _debugLog seam carrying the accepted and scanned + /// counts, and the per-candidate "Probability debug" logging is unchanged. Asserted via the + /// injected delegate, not log capture. The pre-#791 total-count assertion of four is + /// replaced by per-category counts: #791 adds a launch line and turns the single expiry line + /// into one per checkpoint, so a total count would be brittle while proving nothing about + /// which lines were emitted. /// [TestMethod] - public async Task DequeueAsync_DeadlineExpiry_EmitsOneExpiryLineAndKeepsPerCandidateLogging() + public async Task DequeueAsync_CheckpointExpiry_EmitsCheckpointLineAndKeepsPerCandidateLogging() { // Arrange var source = new Queue( @@ -372,16 +401,20 @@ public async Task DequeueAsync_DeadlineExpiry_EmitsOneExpiryLineAndKeepsPerCandi // Assert result.Should().BeEmpty(); - logs.Should() - .ContainSingle(log => - log.Contains("First-batch deadline expired") - && log.Contains("Accepted=0") - && log.Contains("Scanned=3") - ); + var checkpoints = logs.Where(x => x.Contains("Zero-acceptance checkpoint")).ToList(); + checkpoints + .Should() + .HaveCount(3, "ten candidates at 1 s per score cross a 3 s interval three times"); + checkpoints[0] + .Should() + .Contain("Accepted=0") + .And.Contain("Scanned=3", "the first checkpoint reports the first three scores"); logs.Where(log => log.Contains("Probability debug")) .Should() - .HaveCount(3, "per-candidate logging is unchanged, one line per scored candidate"); - logs.Should().HaveCount(4, "three per-candidate lines plus one expiry line"); + .HaveCount(10, "per-candidate logging is unchanged, one line per scored candidate"); + logs.Where(log => log.Contains("High-confidence dequeue launch")) + .Should() + .ContainSingle("the launch line is emitted exactly once per dequeue"); } /// diff --git a/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs b/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs index 5b53de01d..6c6fbdf4a 100644 --- a/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs +++ b/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs @@ -87,7 +87,10 @@ public async Task DequeueAsync_ProgressCallback_FiresOncePerScannedCandidateMono /// /// AC 5: no callback invocation occurs after DequeueAsync returns, including on the - /// deadline-expiry path where the method exits early. + /// early-exit path. Issue #791 made the deadline advisory, so the run is bounded here by an + /// injected scan cap instead of the 3 s deadline; the "no invocation after the method + /// returns" intent and the report sequence are unchanged, and the sequence now follows the + /// cap. /// [TestMethod] public async Task DequeueAsync_ProgressCallback_StopsReportingOnceTheMethodReturns() @@ -110,7 +113,8 @@ public async Task DequeueAsync_ProgressCallback_StopsReportingOnceTheMethodRetur timeProvider: fakeTime, sourceActive: () => false, firstBatchDeadline: TimeSpan.FromSeconds(3), - progressCallback: (scanned, accepted, quantity) => reports.Add(scanned) + progressCallback: (scanned, accepted, quantity) => reports.Add(scanned), + maxScanWithoutAcceptance: 3 ); // Act @@ -119,7 +123,9 @@ public async Task DequeueAsync_ProgressCallback_StopsReportingOnceTheMethodRetur // Assert result.Should().BeEmpty(); - reportsAtReturn.Should().Be(3, "the 3 s budget admits exactly three scored candidates"); + reportsAtReturn + .Should() + .Be(3, "the injected cap admits exactly three scored candidates"); reports.Should().Equal(new[] { 1, 2, 3 }); reports .Count.Should() @@ -165,14 +171,16 @@ public async Task DequeueAsync_ThrowingProgressCallback_PropagatesAndLeavesSourc } /// - /// Issue #446. A deadline-bounded empty result must be distinguishable from genuine source - /// exhaustion, otherwise the caller closes the UI queue for the rest of the session while - /// the master queue still holds unscanned items. Driven by FakeTimeProvider: each - /// score consumes one second of a three-second budget and nothing qualifies, so the - /// deadline exit is the one taken. + /// Issue #446, retargeted by issue #791. A bounded empty result must be distinguishable from + /// genuine source exhaustion, otherwise the caller closes the UI queue for the rest of the + /// session while the master queue still holds unscanned items. The bound that produces the + /// empty result is now the scan cap rather than the first-batch deadline, which #791 made + /// advisory, so the stop reason the datamodel must be able to distinguish is + /// . The producer still reports itself active, so + /// exhaustion is not an available explanation for the empty batch. /// [TestMethod] - public async Task DequeueAsync_DeadlineExpiresWithZeroAccepted_ReportsDeadlineExpiredStop() + public async Task DequeueAsync_ZeroAcceptedAndCapReached_ReportsScanCapReachedStop() { // Arrange var fakeTime = new FakeTimeProvider(); @@ -191,7 +199,8 @@ public async Task DequeueAsync_DeadlineExpiresWithZeroAccepted_ReportsDeadlineEx threshold: 0.90, timeProvider: fakeTime, sourceActive: () => true, - firstBatchDeadline: TimeSpan.FromSeconds(3) + firstBatchDeadline: TimeSpan.FromSeconds(3), + maxScanWithoutAcceptance: 3 ); // Act @@ -201,10 +210,10 @@ public async Task DequeueAsync_DeadlineExpiresWithZeroAccepted_ReportsDeadlineEx batch .Stop.Should() .Be( - QfcDequeueStop.DeadlineExpired, - "an empty batch caused by the first-batch deadline is not source exhaustion" + QfcDequeueStop.ScanCapReached, + "an empty batch caused by a scan bound is not source exhaustion" ); - batch.Accepted.Should().BeEmpty("no candidate qualified before the deadline"); + batch.Accepted.Should().BeEmpty("no candidate qualified before the bound"); } /// diff --git a/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs b/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs new file mode 100644 index 000000000..c27883b1f --- /dev/null +++ b/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using Microsoft.Office.Interop.Outlook; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using QuickFiler.Interfaces; +using UtilitiesCS; + +namespace QuickFiler.Controllers.Tests +{ + /// + /// Issue #791 AC1 coverage for QfcStreamingDequeueConfidenceGate: the first-batch + /// deadline becomes an advisory checkpoint, and two hard bounds — a cap on candidates scored + /// without an acceptance and a time ceiling — terminate the extended zero-acceptance scan. + /// Fourth part of the partial class declared in + /// QfcStreamingDequeueConfidenceGateTests.cs; a new file because the three existing parts + /// are already close to the 500-line limit. [TestClass] stays on the base file only (it is + /// AllowMultiple = false, so repeating it would be CS0579). Shares the base file's + /// reflection-based CreateGate / DequeueBatchAsync helpers and Part 3's + /// Scored helper. Deterministic — for all time, mocked + /// , no COM, no sleeps, no wall-clock waits. + /// + public partial class QfcStreamingDequeueConfidenceGateTests + { + /// + /// Builds mail items whose subjects and entry ids are indexed from + /// one, so an assertion failure names the position that failed. + /// + private static List BuildCandidates(int count) => + Enumerable + .Range(1, count) + .Select(i => CreateMailItem($"candidate-{i}", $"entry-{i}")) + .ToList(); + + /// + /// Issue #791 AC1, the reported defect. Zero acceptances when the first-batch deadline + /// expires must no longer return an empty batch at the bound: the scan continues until the + /// first acceptance. Forty below-cutoff candidates precede the single qualifier and each + /// score consumes one second of a twelve-second checkpoint interval, so the pre-change gate + /// returned empty after twelve scans. + /// + [TestMethod] + public async Task DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance() + { + // Arrange + List candidates = BuildCandidates(41); + MailItem qualifying = candidates[40]; + var source = new Queue(candidates); + var fakeTime = new FakeTimeProvider(); + + object gate = CreateGate( + () => source.Count == 0 ? null : source.Dequeue(), + (mail, token) => + { + fakeTime.Advance(TimeSpan.FromSeconds(1)); + return Scored(ReferenceEquals(mail, qualifying) ? 950L : 100L); + }, + threshold: 0.90, + timeProvider: fakeTime, + sourceActive: () => false + ); + + // Act — the default twelve-second interval is in force and is deliberately not overridden. + QfcGateBatch batch = await DequeueBatchAsync(gate, 1, 0, CancellationToken.None); + + // Assert + batch + .Accepted.Should() + .ContainSingle("the scan continues past the checkpoint until the first acceptance") + .Which.MailItem.Should() + .BeSameAs(qualifying); + batch + .Scanned.Should() + .Be(41, "every candidate up to and including the qualifier is scored"); + batch + .Stop.Should() + .Be( + QfcDequeueStop.QuantitySatisfied, + "an acceptance inside the bounds satisfies the request" + ); + } + + /// + /// Issue #791 AC1. With neither bound reached and the producer dead, a zero-acceptance scan + /// ends in genuine exhaustion, which is the one empty-batch case a caller may treat as a + /// closed queue. + /// + [TestMethod] + public async Task DequeueAsync_ZeroAcceptedAndSourceDrained_ReportsSourceExhausted() + { + // Arrange + var source = new Queue(BuildCandidates(5)); + var fakeTime = new FakeTimeProvider(); + + object gate = CreateGate( + () => source.Count == 0 ? null : source.Dequeue(), + (mail, token) => + { + fakeTime.Advance(TimeSpan.FromSeconds(1)); + return Scored(100L); + }, + threshold: 0.90, + timeProvider: fakeTime, + sourceActive: () => false, + firstBatchDeadline: TimeSpan.FromSeconds(2) + ); + + // Act + QfcGateBatch batch = await DequeueBatchAsync(gate, 3, 0, CancellationToken.None); + + // Assert + batch.Accepted.Should().BeEmpty("no candidate reached the cutoff"); + batch.Scanned.Should().Be(5, "the whole source is scored before it drains"); + batch + .Stop.Should() + .Be( + QfcDequeueStop.SourceExhausted, + "a drained source with a dead producer is exhaustion, not a bound" + ); + source.Should().BeEmpty("nothing is left unscanned"); + } + + /// + /// Issue #791 AC1. The scan cap terminates the extended scan and reports the bounded exit as + /// . The cap is checked ahead of the take, so a + /// capped scan cannot consume one extra candidate from the master queue. + /// + [TestMethod] + public async Task DequeueAsync_ZeroAcceptedAndCapReached_StopsAndReportsScanCapReached() + { + // Arrange + var source = new Queue(BuildCandidates(10)); + var fakeTime = new FakeTimeProvider(); + var takeCount = 0; + + object gate = CreateGate( + () => + { + takeCount++; + return source.Count == 0 ? null : source.Dequeue(); + }, + (mail, token) => Scored(100L), + threshold: 0.90, + timeProvider: fakeTime, + sourceActive: () => true, + maxScanWithoutAcceptance: 4 + ); + + // Act + QfcGateBatch batch = await DequeueBatchAsync(gate, 5, 0, CancellationToken.None); + + // Assert + batch + .Stop.Should() + .Be( + QfcDequeueStop.ScanCapReached, + "a bounded zero-acceptance exit is not source exhaustion" + ); + batch.Accepted.Should().BeEmpty("no candidate reached the cutoff"); + batch.Scanned.Should().Be(4, "the cap bounds the scored count"); + takeCount.Should().Be(4, "no take may occur after the cap is reached"); + source.Should().HaveCount(6, "the unscanned remainder stays for later dequeues"); + } + + /// + /// Issue #791 AC1. The scan cap alone cannot bound the pre-UI wait, because the empty-queue + /// wait path does not increment the scored count while the loader is still refilling. The + /// time ceiling is what terminates that wait: the source never yields and reports itself + /// active, so only the ceiling can end the loop. + /// + [TestMethod] + public async Task DequeueAsync_ZeroAcceptedAndCeilingReached_StopsWhileSourceStillRefilling() + { + // Arrange + var fakeTime = new FakeTimeProvider(); + object gate = CreateGate( + () => null, + (mail, token) => Scored(950L), + threshold: 0.90, + timeProvider: fakeTime, + sourceActive: () => true, + zeroAcceptanceCeiling: TimeSpan.FromSeconds(120) + ); + + // Act — the gate parks on the injected empty-source delay, then the clock passes the + // ceiling. Advancing the fake clock is the only thing that releases the delay, so the + // test carries no wall-clock wait and no sleep. + Task pending = DequeueBatchAsync(gate, 1, 200, CancellationToken.None); + pending.IsCompleted.Should().BeFalse("the gate is parked on the empty-source delay"); + + fakeTime.Advance(TimeSpan.FromSeconds(121)); + QfcGateBatch batch = await pending; + + // Assert + batch + .Stop.Should() + .Be( + QfcDequeueStop.ScanCapReached, + "the ceiling is a bounded exit even though the producer is still active" + ); + batch.Accepted.Should().BeEmpty("nothing was ever takeable"); + batch.Scanned.Should().Be(0, "the wait path scores nothing"); + } + + /// + /// Issue #791 AC1 logging. Every checkpoint decision records the cutoff in force and the + /// scanned and accepted counts, which the pre-change expiry line never carried. Asserted + /// through the injected debugLog delegate, which is the convention this gate already + /// established, rather than through a log4net appender. + /// + [TestMethod] + public async Task DequeueAsync_CheckpointExpiry_LogsCutoffAndCounts() + { + // Arrange + var source = new Queue(BuildCandidates(10)); + var fakeTime = new FakeTimeProvider(); + var logs = new List(); + + object gate = CreateGate( + () => source.Count == 0 ? null : source.Dequeue(), + (mail, token) => + { + fakeTime.Advance(TimeSpan.FromSeconds(1)); + return Scored(100L); + }, + threshold: 0.90, + timeProvider: fakeTime, + debugLog: logs.Add, + sourceActive: () => false, + firstBatchDeadline: TimeSpan.FromSeconds(3) + ); + + // Act + QfcGateBatch batch = await DequeueBatchAsync(gate, 5, 0, CancellationToken.None); + + // Assert + batch.Accepted.Should().BeEmpty(); + List checkpoints = logs.Where(log => log.Contains("Zero-acceptance checkpoint")) + .ToList(); + checkpoints + .Should() + .HaveCount( + 3, + "a ten-candidate scan at 1 s per score crosses a 3 s interval 3 times" + ); + checkpoints[0] + .Should() + .Contain("Accepted=0") + .And.Contain("Scanned=3") + .And.Contain( + "Cutoff=900", + "the cutoff in effect must be recorded at each decision" + ); + } + + /// + /// Issue #791 AC1 logging. One launch line records the cutoff, the requested quantity and + /// both bounds, so an operator reading the log can tell which cutoff and which bounds a run + /// used without inferring them from the outcome. + /// + [TestMethod] + public async Task DequeueAsync_Launch_LogsCutoffQuantityAndBounds() + { + // Arrange + var source = new Queue(BuildCandidates(1)); + var logs = new List(); + + object gate = CreateGate( + () => source.Count == 0 ? null : source.Dequeue(), + (mail, token) => Scored(950L), + threshold: 0.90, + timeProvider: new FakeTimeProvider(), + debugLog: logs.Add, + sourceActive: () => false, + maxScanWithoutAcceptance: 250, + zeroAcceptanceCeiling: TimeSpan.FromSeconds(120) + ); + + // Act + _ = await DequeueBatchAsync(gate, 7, 0, CancellationToken.None); + + // Assert + logs.Should() + .ContainSingle(log => log.Contains("High-confidence dequeue launch")) + .Which.Should() + .Contain("Cutoff=900") + .And.Contain("0.9") + .And.Contain("Quantity=7") + .And.Contain("ScanCap=250") + .And.Contain("Ceiling=00:02:00"); + } + + /// + /// Issue #608 regression pin. Once one candidate has been accepted the checkpoint and both + /// bounds are inert, so a non-empty prefix still fills or exhausts. The injected cap of two + /// is deliberately smaller than the scan this test performs: if the guard were widened to + /// evaluate the bounds after an acceptance, the run would stop early and this test would + /// fail. + /// + [TestMethod] + public async Task DequeueAsync_NonEmptyPrefix_UnchangedByCheckpoint() + { + // Arrange + List candidates = BuildCandidates(21); + MailItem qualifying = candidates[0]; + var source = new Queue(candidates); + var fakeTime = new FakeTimeProvider(); + var logs = new List(); + + object gate = CreateGate( + () => source.Count == 0 ? null : source.Dequeue(), + (mail, token) => + { + fakeTime.Advance(TimeSpan.FromSeconds(10)); + return Scored(ReferenceEquals(mail, qualifying) ? 950L : 100L); + }, + threshold: 0.90, + timeProvider: fakeTime, + debugLog: logs.Add, + sourceActive: () => false, + firstBatchDeadline: TimeSpan.FromSeconds(3), + maxScanWithoutAcceptance: 2 + ); + + // Act — quantity 5 is never satisfied, so the scan runs to exhaustion. + QfcGateBatch batch = await DequeueBatchAsync(gate, 5, 0, CancellationToken.None); + + // Assert + batch + .Accepted.Should() + .ContainSingle("the accepted prefix is unchanged by #791") + .Which.MailItem.Should() + .BeSameAs(qualifying); + batch.Scanned.Should().Be(21, "fill-or-exhaust is preserved after a non-empty prefix"); + batch + .Stop.Should() + .Be(QfcDequeueStop.SourceExhausted, "the source drained, no bound was reached"); + logs.Where(log => log.Contains("Zero-acceptance checkpoint")) + .Should() + .BeEmpty("the checkpoint is evaluated only while nothing has been accepted"); + } + } +} diff --git a/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs b/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs index 9312ec9f8..30eec23b9 100644 --- a/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs +++ b/QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs @@ -37,7 +37,9 @@ private static object CreateGate( Func sourceActive = null, TimeSpan? firstBatchDeadline = null, Action progressCallback = null, - Action onRejected = null + Action onRejected = null, + int? maxScanWithoutAcceptance = null, + TimeSpan? zeroAcceptanceCeiling = null ) { Type gateType = typeof(QfcDatamodel).Assembly.GetType( @@ -68,12 +70,14 @@ private static object CreateGate( typeof(TimeSpan?), typeof(Action), typeof(Action), + typeof(int?), + typeof(TimeSpan?), }, modifiers: null ); constructor .Should() - .NotBeNull("the gate must expose the nine-parameter testable constructor seam"); + .NotBeNull("the gate must expose the eleven-parameter testable constructor seam"); return constructor.Invoke( new object[] @@ -87,6 +91,8 @@ private static object CreateGate( firstBatchDeadline, progressCallback, onRejected, + maxScanWithoutAcceptance, + zeroAcceptanceCeiling, } ); } @@ -100,7 +106,9 @@ private static object CreateGate( Func sourceActive = null, TimeSpan? firstBatchDeadline = null, Action progressCallback = null, - Action onRejected = null + Action onRejected = null, + int? maxScanWithoutAcceptance = null, + TimeSpan? zeroAcceptanceCeiling = null ) { return CreateGate( @@ -116,7 +124,9 @@ private static object CreateGate( sourceActive, firstBatchDeadline, progressCallback, - onRejected + onRejected, + maxScanWithoutAcceptance, + zeroAcceptanceCeiling ); } diff --git a/QuickFiler.Test/QuickFiler.Test.csproj b/QuickFiler.Test/QuickFiler.Test.csproj index a1ec24205..38406cb2b 100644 --- a/QuickFiler.Test/QuickFiler.Test.csproj +++ b/QuickFiler.Test/QuickFiler.Test.csproj @@ -165,6 +165,10 @@ + + + + diff --git a/QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs b/QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs index 6b55c09a6..ce2bc0428 100644 --- a/QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs +++ b/QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs @@ -6,6 +6,7 @@ using Microsoft.Office.Interop.Outlook; using QuickFiler.Interfaces; using UtilitiesCS; +using UtilitiesCS.ReusableTypeClasses; namespace QuickFiler.Controllers { @@ -22,6 +23,120 @@ public partial class QfcDatamodel /// private volatile bool _remainingLoadActive; + /// + /// Issue #791. Injected diagnostic seam for the loader-quiesce path, mirroring the + /// debugLog constructor parameter of + /// . This type logs through log4net, + /// and no memory-appender convention exists anywhere in QuickFiler.Test; attaching + /// one would mutate a process-global logger repository and break test independence. A test + /// assigns a collecting delegate and asserts on it directly. in + /// production, where the same lines still reach log4net. + /// + internal Action QuiesceDebugLog { get; set; } + + /// + /// Issue #791. The loader task is awaiting, captured so the + /// Cancel path has something to wait on. Worker_DoWork is async void and + /// retained no handle to it, so nothing could observe the loader's completion and the + /// teardown nulled fields underneath a loader that was still producing. + /// + private Task _remainingLoadTask; + + /// + /// Issue #791. See for the contract. + /// + public async Task QuiesceLoaderAsync(TimeSpan timeout) + { + _tokenSource?.Cancel(); + + // Snapshot before the check: the field is written on the worker thread, so reading it + // twice could observe two different values. + Task loader = _remainingLoadTask; + if (loader is null || loader.IsCompleted) + { + LogQuiesceOutcome(completed: true, timeout); + return; + } + + // CancellationToken.None deliberately: the bound must still expire after the token this + // method just cancelled, or a hung loader would leave the Cancel path with no exit. + Task bound = TimeProvider.Delay(timeout, CancellationToken.None); + Task first = await Task.WhenAny(loader, bound).ConfigureAwait(false); + LogQuiesceOutcome(ReferenceEquals(first, loader), timeout); + } + + /// + /// Emits exactly one outcome line for a quiesce, through both the injected diagnostic seam + /// and log4net. INFO rather than DEBUG: a normal Cancel must be readable without + /// raising the log level, which is what the 37-minute silent gap in the field report needed. + /// + private void LogQuiesceOutcome(bool completed, TimeSpan timeout) + { + string message = completed + ? $"Loader quiesce completed [QfcDatamodel.QuiesceLoaderAsync] Bound={timeout}" + : $"Loader quiesce timed out [QfcDatamodel.QuiesceLoaderAsync] Bound={timeout}"; + + QuiesceDebugLog?.Invoke(message); + logger.Info(message); + } + + /// + /// Issue #791. Admits one remaining mail item to the master queue. Relocated here from + /// QfcDatamodel.cs and given the precondition it lacked. + /// + /// Both fields are snapshotted into locals and checked before any delegate is constructed + /// over them. A method-group conversion on a null instance raises + /// "Delegate to an instance method cannot have null 'this'" + /// at delegate-construction time, which is exactly the crash the field log records after a + /// Cancel had already released the fields. Refusing at the accept point is harmless: the + /// loader is being torn down, so there is nothing left to admit. The three-delegate + /// constructor shape is unchanged (issue #731). + /// + /// + internal async Task TryQueueRemainingMailItemAsync( + MailItem mailItem, + CancellationToken cancel + ) + { + QfcRemainingQueueAdmission admission = TryCreateRemainingQueueAdmission(cancel); + if (admission is null) + { + return false; + } + + return await admission.TryQueueAsync(mailItem, cancel).ConfigureAwait(false); + } + + /// + /// Issue #791. Snapshots the two fields and builds the admission, or returns + /// when either field is already released or cancellation is pending. + /// + /// Deliberately a separate synchronous method rather than the opening block of + /// . A local held in an async method is + /// hoisted into the compiler-generated state machine as a field, and an + /// local hoisted that way makes the state machine a fourth + /// type declaring such a field, which breaks the three-owner topology pin from issue #731 + /// finding 1. Keeping the snapshot in a synchronous method leaves the topology unchanged. + /// + /// + private QfcRemainingQueueAdmission TryCreateRemainingQueueAdmission( + CancellationToken cancel + ) + { + LockingLinkedList masterQueue = _masterQueue; + IEmailMoveMonitor moveMonitor = _moveMonitor; + if (masterQueue is null || moveMonitor is null || cancel.IsCancellationRequested) + { + return null; + } + + return new QfcRemainingQueueAdmission( + masterQueue.AddLast, + moveMonitor.HookItem, + x => masterQueue.Remove(x) + ); + } + //TODO: Implement UndoMove() public void UndoMove() { diff --git a/QuickFiler/Controllers/QfcDatamodel.cs b/QuickFiler/Controllers/QfcDatamodel.cs index be3f4fa20..d9b95b99d 100644 --- a/QuickFiler/Controllers/QfcDatamodel.cs +++ b/QuickFiler/Controllers/QfcDatamodel.cs @@ -76,8 +76,18 @@ public void Cleanup() { _tokenSource?.Cancel(); _worker?.CancelAsync(); - _globals.Ol.App.NewMailEx -= Application_NewMailEx; - _moveMonitor.UnhookAll(); + + // Issue #791: both dereferences are null-conditional because this method is reachable + // with the fields already released — a second Cancel, or a Cancel after a partially + // failed launch. Unguarded, the NullReferenceException aborted the teardown before the + // ribbon release callback, leaving both ribbon buttons inert for the session. + IApplicationGlobals globals = _globals; + if (globals?.Ol?.App is not null) + { + globals.Ol.App.NewMailEx -= Application_NewMailEx; + } + + _moveMonitor?.UnhookAll(); _moveMonitor = null; _activeExplorer = null; _olApp = null; @@ -188,7 +198,13 @@ private async void Worker_DoWork(object sender, DoWorkEventArgs e) //e.Result = LoadRemainingEmailsToQueue(bw, _token); try { - e.Result = await RemainingEmailLoader(_token); + // Issue #791: capture the loader task before awaiting it. This method is + // async void, so without a handle nothing downstream can observe the loader's + // completion, and the Cancel path nulled fields underneath a loader that was + // still producing. QuiesceLoaderAsync awaits exactly this task. + Task loaderTask = RemainingEmailLoader(_token); + _remainingLoadTask = loaderTask; + e.Result = await loaderTask; } finally { @@ -347,19 +363,6 @@ private async Task LoadRemainingEmailsToQueueAsync(CancellationToken cance return true; } - internal async Task TryQueueRemainingMailItemAsync( - MailItem mailItem, - CancellationToken cancel - ) - { - var admission = new QfcRemainingQueueAdmission( - _masterQueue.AddLast, - _moveMonitor.HookItem, - x => _masterQueue.Remove(x) - ); - return await admission.TryQueueAsync(mailItem, cancel).ConfigureAwait(false); - } - private bool LoadRemainingEmailsToQueue(BackgroundWorker bw, CancellationToken token) { if ((_frame is null) || (_frame.RowCount == 0)) diff --git a/QuickFiler/Controllers/QfcFormController.Deactivate.cs b/QuickFiler/Controllers/QfcFormController.Deactivate.cs index 673878411..3fe999ecb 100644 --- a/QuickFiler/Controllers/QfcFormController.Deactivate.cs +++ b/QuickFiler/Controllers/QfcFormController.Deactivate.cs @@ -20,12 +20,25 @@ internal partial class QfcFormController /// Parks focus off any focused WebView2 and cancels every item's breadcrumb selector. /// /// - /// No _formViewer null guard is written: this handler is reachable only through - /// _formViewer.FormDeactivated, so a null-viewer branch would be unreachable code. + /// Delegates to , which issue #791 extracted so the + /// Cancel path can run the same routine. /// - internal void FormViewer_Deactivated(object sender, EventArgs e) + internal void FormViewer_Deactivated(object sender, EventArgs e) => + ParkFocusAndCancelSelectors(); + + /// + /// Parks focus off any focused WebView2 and cancels every item's breadcrumb selector. + /// + /// + /// Issue #791 added the _formViewer null guard. Before the extraction this routine + /// was reachable only through _formViewer.FormDeactivated, so a null-viewer branch + /// was unreachable and none was written. The Cancel path now calls it directly, and it is + /// reachable there with the viewer already released — a second Cancel, or a Cancel after a + /// partially failed launch — so the guard is live code rather than defensive padding. + /// + internal void ParkFocusAndCancelSelectors() { - if (_formViewer.IsWebView2Focused) + if (_formViewer?.IsWebView2Focused == true) { _formViewer.ParkFocusOffWebView2(); } diff --git a/QuickFiler/Controllers/QfcFormController.EventHandlers.cs b/QuickFiler/Controllers/QfcFormController.EventHandlers.cs index a210aa5b8..9be960add 100644 --- a/QuickFiler/Controllers/QfcFormController.EventHandlers.cs +++ b/QuickFiler/Controllers/QfcFormController.EventHandlers.cs @@ -17,6 +17,46 @@ namespace QuickFiler.Controllers { internal partial class QfcFormController { + #region Cancel teardown support (issue #791) + + /// Issue #791. Bound on the Cancel-path loader wait. A caller-supplied constant, not a setting. + internal static readonly TimeSpan LoaderQuiesceBound = TimeSpan.FromSeconds(5); + + /// Issue #791. Runs one teardown stage, logging completion at DEBUG and any escaping exception at ERROR with the stage name, so a throwing stage cannot skip a later one. `System.Action` is required: a bare `Action` is CS0104-ambiguous here. + private void RunTeardownStage(string stage, System.Action body) + { + try + { + body(); + logger.Debug($"Cancel teardown stage completed. Stage={stage}"); + } + catch (System.Exception e) + { + logger.Error($"Cancel teardown stage failed. Stage={stage}", e); + } + } + + /// Issue #791. Resets the keyboard-active flag, toggling only when set — toggling an inactive dialog would activate it. + private void ResetKeyboardActive() + { + IQfcKeyboardHandler keyboard = _parent?.KeyboardHandler; + bool wasActive = keyboard?.KbdActive == true; + if (wasActive) + { + keyboard.ToggleKeyboardDialog(); + } + logger.Debug($"Cancel teardown reset keyboard. PreviousKbdActive={wasActive}"); + } + + /// Issue #791. Drains the navigation ledger and removes the form event handlers. Both must run before the item rows are removed, or the recursive unsubscribe no longer reaches the item controls' PreviewKeyDown/KeyDown subscriptions. + private void UnregisterCancelPathHandlers() + { + _groups?.UnregisterNavigation(); + UnregisterFormEventHandlers(); + } + + #endregion + #region Event Handlers internal void DarkMode_CheckedChanged(object sender, EventArgs e) @@ -76,21 +116,60 @@ public async void ButtonCancel_Click(object sender, EventArgs e) } catch (System.Exception ex) { + // Issue #791: deliberately not rethrown. This handler is `async void`, so a rethrow + // becomes an unhandled Outlook UI-thread exception reporting nothing actionable. logger.Error(ex.Message, ex); - throw; } } + /// Issue #791. The ordered Cancel teardown. Each stage runs through so a throwing stage is logged and cannot skip a later one, and — which reaches the ribbon release callback — runs under finally. Keeps its zero-parameter IFilerFormController signature. public async Task ActionCancelAsync() { - _parent?.TokenSource?.Cancel(); - if (_formViewer?.UiSyncContext is not null) + bool already = _parent?.TokenSource?.IsCancellationRequested == true; + logger.Info($"Cancel teardown starting. AlreadyCancelled={already}"); + + // Stays ahead of the first await: the seam test raising CancelClicked asserts the + // parent token is cancelled by the time Mock.Raise returns. + RunTeardownStage("cancel-token", () => _parent?.TokenSource?.Cancel()); + + try + { + SynchronizationContext uiContext = _formViewer?.UiSyncContext; + if (uiContext is not null) + { + await uiContext; + } + + RunTeardownStage("reset-keyboard", ResetKeyboardActive); + RunTeardownStage("park-focus", ParkFocusAndCancelSelectors); + RunTeardownStage("unregister-handlers", UnregisterCancelPathHandlers); + RunTeardownStage("hide-form", () => _formViewer?.Hide()); + + // Awaited only when non-null: loose mocks resolve DataModel to null. + Task quiesce = null; + RunTeardownStage( + "quiesce-loader", + () => quiesce = _parent?.DataModel?.QuiesceLoaderAsync(LoaderQuiesceBound) + ); + if (quiesce is not null) + { + try + { + await quiesce; + } + catch (System.Exception e) + { + logger.Error("Cancel teardown stage failed. Stage=quiesce-await", e); + } + } + + RunTeardownStage("groups-cleanup", () => _groups?.Cleanup()); + } + finally { - await _formViewer.UiSyncContext; + RunTeardownStage("controller-cleanup", Cleanup); + logger.Info("Cancel teardown complete; ribbon release callback invoked."); } - _formViewer?.Hide(); - _groups?.Cleanup(); - Cleanup(); } public async void ButtonOK_Click(object sender, EventArgs e) @@ -205,6 +284,9 @@ internal async Task MoveAndIterate() MessageBoxButtons.OK, MessageBoxIcon.Information ); + // Issue #791 trigger discriminator: ActionCancelAsync keeps its zero-parameter + // IFilerFormController signature, so it is supplied at the call site. + log.Debug("Cancel teardown trigger=completion-path (MoveAndIterate finished)."); await ActionCancelAsync(); } } diff --git a/QuickFiler/Controllers/QfcHomeController.cs b/QuickFiler/Controllers/QfcHomeController.cs index 03ee5262d..6fe4a3cd7 100644 --- a/QuickFiler/Controllers/QfcHomeController.cs +++ b/QuickFiler/Controllers/QfcHomeController.cs @@ -367,15 +367,42 @@ private void Worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArg } } + /// Issue #791. Two guarded blocks under one finally, so the ribbon release callback runs whichever stage threw. public void Cleanup() { - _datamodel.Cleanup(); - Globals = null; - _formViewer = null; - _explorerController = null; - _formController = null; - _keyboardHandler = null; - ParentCleanup.Invoke(); + try + { + // Detach before the viewer reference is dropped, or the handler outlives it. + if (_formViewer?.Worker is BackgroundWorker worker) + { + worker.RunWorkerCompleted -= Worker_RunWorkerCompleted; + } + logger.Debug("Home cleanup stage completed. Stage=detach-worker-completed"); + } + catch (System.Exception e) + { + logger.Error("Home cleanup stage failed. Stage=detach-worker-completed", e); + } + try + { + _datamodel?.Cleanup(); + _tokenSource?.Dispose(); + Globals = null; + _formViewer = null; + _explorerController = null; + _formController = null; + _keyboardHandler = null; + logger.Debug("Home cleanup stage completed. Stage=datamodel-and-fields"); + } + catch (System.Exception e) + { + logger.Error("Home cleanup stage failed. Stage=datamodel-and-fields", e); + } + finally + { + ParentCleanup?.Invoke(); + logger.Info("Home cleanup complete; ribbon release callback invoked."); + } } private bool _loaded = false; diff --git a/QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs b/QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs index 7e00ee960..ebaba6527 100644 --- a/QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs +++ b/QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs @@ -55,6 +55,23 @@ internal sealed class QfcStreamingDequeueConfidenceGate /// internal static readonly TimeSpan DefaultFirstBatchDeadline = TimeSpan.FromSeconds(12); + /// + /// Issue #791. Cap on candidates scored without a single acceptance. One of the two hard + /// bounds that terminate the extended zero-acceptance scan after the first-batch deadline + /// became advisory. An implementation quality bound, not a user setting: it is exposed only + /// through the optional constructor parameter so tests can drive it deterministically, and + /// it introduces no settings surface, following the ratified #424 precedent. + /// + internal static readonly int DefaultMaxScanWithoutAcceptance = 250; + + /// + /// Issue #791. Time ceiling on the extended zero-acceptance scan. The scan cap alone cannot + /// bound the pre-UI wait, because the empty-queue wait path does not increment the scanned + /// count while the loader is still refilling, so a time bound is required in addition. An + /// implementation quality bound with a constructor test seam and no settings surface. + /// + internal static readonly TimeSpan DefaultZeroAcceptanceCeiling = TimeSpan.FromSeconds(120); + private readonly Func _tryTakeNext; // Issue #678: the loader publishes the handler its scoring pass initialised, so an accepted @@ -108,6 +125,14 @@ internal QfcStreamingDequeueConfidenceGate( /// disables the sink. The drop-on-reject contract is unchanged: the /// candidate is still discarded and is still absent from the result. /// + /// + /// Issue #791. Cap on candidates scored without a single acceptance. + /// selects . + /// + /// + /// Issue #791. Time ceiling on the extended zero-acceptance scan. + /// selects . + /// internal QfcStreamingDequeueConfidenceGate( Func tryTakeNext, Func< @@ -121,7 +146,9 @@ internal QfcStreamingDequeueConfidenceGate( Func sourceActive, TimeSpan? firstBatchDeadline = null, Action progressCallback = null, - Action onRejected = null + Action onRejected = null, + int? maxScanWithoutAcceptance = null, + TimeSpan? zeroAcceptanceCeiling = null ) { _tryTakeNext = tryTakeNext ?? throw new ArgumentNullException(nameof(tryTakeNext)); @@ -144,8 +171,22 @@ internal QfcStreamingDequeueConfidenceGate( } _firstBatchDeadline = deadline; + MaxScanWithoutAcceptance = maxScanWithoutAcceptance ?? DefaultMaxScanWithoutAcceptance; + ZeroAcceptanceCeiling = zeroAcceptanceCeiling ?? DefaultZeroAcceptanceCeiling; } + /// + /// Issue #791. The effective cap on candidates scored without an acceptance. Declared as a + /// get-only auto-property rather than a private readonly field so the seam is + /// warning-clean at every point of the change: a private field assigned and never read + /// raises CS0414, which /p:TreatWarningsAsErrors=true promotes to an error, whereas + /// an auto-property's compiler-generated backing field is read by its getter. + /// + internal int MaxScanWithoutAcceptance { get; } + + /// Issue #791. The effective time ceiling on the extended zero-acceptance scan. + internal TimeSpan ZeroAcceptanceCeiling { get; } + internal async Task DequeueAsync( int quantity, int timeOut, @@ -153,6 +194,7 @@ CancellationToken token ) { token.ThrowIfCancellationRequested(); + LogLaunch(quantity); var accepted = new List(); int scanned = 0; @@ -164,19 +206,38 @@ CancellationToken token bool deadlineEnabled = _firstBatchDeadline != Timeout.InfiniteTimeSpan; long start = _timeProvider.GetTimestamp(); + // Issue #791: the checkpoint interval is measured from its own origin, which is reset at + // every checkpoint, while both hard bounds are measured against the run origin. Sharing + // one origin would make the first checkpoint also the last, which is the superseded + // #424 behaviour. + long checkpointOrigin = start; + bool alreadyWaitedForEmptySource = false; while (accepted.Count < quantity) { token.ThrowIfCancellationRequested(); - if ( - deadlineEnabled - && accepted.Count == 0 - && _timeProvider.GetElapsedTime(start) >= _firstBatchDeadline - ) + // Issue #791: the whole zero-acceptance policy stays inside the same + // `deadlineEnabled && accepted.Count == 0` guard the #424 deadline used, so + // Timeout.InfiniteTimeSpan still means "no bound at all" and a non-empty prefix is + // still governed by #608 fill-or-exhaust rather than by any bound. + if (deadlineEnabled && accepted.Count == 0) { - LogDeadlineExpiry(accepted.Count, scanned); - return new QfcGateBatch(accepted, QfcDequeueStop.DeadlineExpired, scanned); + TimeSpan elapsed = _timeProvider.GetElapsedTime(start); + + // The bounds are evaluated ahead of the take, so a bounded scan cannot consume + // one extra candidate out of the master queue on its way out. + if (scanned >= MaxScanWithoutAcceptance || elapsed >= ZeroAcceptanceCeiling) + { + LogScanBoundReached(accepted.Count, scanned, elapsed); + return new QfcGateBatch(accepted, QfcDequeueStop.ScanCapReached, scanned); + } + + if (_timeProvider.GetElapsedTime(checkpointOrigin) >= _firstBatchDeadline) + { + LogZeroAcceptanceCheckpoint(accepted.Count, scanned, elapsed); + checkpointOrigin = _timeProvider.GetTimestamp(); + } } MailItem mailItem = _tryTakeNext(); @@ -239,11 +300,61 @@ await _timeProvider return new QfcGateBatch(accepted, QfcDequeueStop.QuantitySatisfied, scanned); } - private void LogDeadlineExpiry(int acceptedCount, int scannedCount) + /// + /// Issue #791. One line per dequeue recording the cutoff in force, the requested quantity, + /// the checkpoint interval and both hard bounds, so an operator reading the log can tell + /// which configuration a run used instead of inferring it from the outcome. The reported + /// cutoff (900) was never logged before this change, which is why the field reports could + /// not be diagnosed. + /// + private void LogLaunch(int quantity) + { + string message = + $"High-confidence dequeue launch [QfcStreamingDequeueConfidenceGate.DequeueAsync] " + + $"Cutoff={_cutoff} ({_cutoff / 1000.0}) Quantity={quantity} " + + $"CheckpointInterval={_firstBatchDeadline} ScanCap={MaxScanWithoutAcceptance} " + + $"Ceiling={ZeroAcceptanceCeiling}"; + + _debugLog?.Invoke(message); + logger.Debug(message); + } + + /// + /// Issue #791. Replaces the #424 expiry line. The first-batch deadline is now an advisory + /// checkpoint, so this records a decision to continue rather than a bounded return, and + /// carries the remaining headroom on both bounds alongside the counts. + /// + private void LogZeroAcceptanceCheckpoint( + int acceptedCount, + int scannedCount, + TimeSpan elapsed + ) + { + string message = + $"Zero-acceptance checkpoint [QfcStreamingDequeueConfidenceGate.DequeueAsync] " + + $"Accepted={acceptedCount} Scanned={scannedCount} Cutoff={_cutoff} " + + $"Elapsed={elapsed} Interval={_firstBatchDeadline} " + + $"RemainingScans={MaxScanWithoutAcceptance - scannedCount} " + + $"RemainingTime={ZeroAcceptanceCeiling - elapsed} Decision=continue"; + + _debugLog?.Invoke(message); + logger.Debug(message); + } + + /// + /// Issue #791. The bounded zero-acceptance exit: which bound was reached, and the counts and + /// cutoff that produced it. This is the one case in which the gate may now return an empty + /// batch while candidates remain unscanned, so it is logged explicitly. + /// + private void LogScanBoundReached(int acceptedCount, int scannedCount, TimeSpan elapsed) { + string bound = + scannedCount >= MaxScanWithoutAcceptance ? "scan-cap" : "zero-acceptance-ceiling"; string message = - $"First-batch deadline expired [QfcStreamingDequeueConfidenceGate.DequeueAsync] " - + $"Accepted={acceptedCount} Scanned={scannedCount} Deadline={_firstBatchDeadline}"; + $"Zero-acceptance scan bound reached [QfcStreamingDequeueConfidenceGate.DequeueAsync] " + + $"Accepted={acceptedCount} Scanned={scannedCount} Cutoff={_cutoff} " + + $"Elapsed={elapsed} ScanCap={MaxScanWithoutAcceptance} " + + $"Ceiling={ZeroAcceptanceCeiling} Bound={bound} Decision=stop"; _debugLog?.Invoke(message); logger.Debug(message); diff --git a/QuickFiler/Interfaces/IQfcDatamodel.cs b/QuickFiler/Interfaces/IQfcDatamodel.cs index 216bbcf62..6bb79e24b 100644 --- a/QuickFiler/Interfaces/IQfcDatamodel.cs +++ b/QuickFiler/Interfaces/IQfcDatamodel.cs @@ -35,8 +35,26 @@ public enum QfcDequeueStop /// The mail source is drained and no producer is still loading. SourceExhausted, - /// The first-batch deadline expired before any candidate qualified. + /// + /// The first-batch deadline expired before any candidate qualified. Issue #791 made that + /// deadline advisory: expiry with zero acceptances is now a logged checkpoint that resets + /// its interval and continues scanning, so the gate no longer returns this member. It is + /// retained for compatibility — existing callers, mocks and switch arms that name it still + /// compile, and a caller must continue to treat it exactly as it treats + /// : the queue stays open because unscanned candidates may + /// remain. + /// DeadlineExpired, + + /// + /// Issue #791. The extended zero-acceptance scan reached one of its hard bounds — the cap on + /// candidates scanned without an acceptance, or the time ceiling that bounds the wait while + /// the background loader is still refilling — before any candidate qualified. This reports a + /// bounded exit, not exhaustion, and must be treated exactly as + /// is: the mail source may still hold unscanned candidates, so + /// the caller must leave the UI queue open. + /// + ScanCapReached, } /// @@ -128,6 +146,23 @@ Task> InitEmailQueueAsync( CancellationTokenSource tokenSource ); bool Complete { get; set; } + + /// + /// Issue #791. Stops the background remaining-email loader and waits for it, bounded by + /// . Cancels the datamodel's token source, then awaits the loader + /// task against a delay of the supplied bound, and logs whether + /// the loader completed or the bound expired. + /// + /// Returns when the loader completes or when the bound expires, whichever happens first. + /// It never throws for the timeout case: a bounded-out loader is reported, not raised. It is + /// awaited from the Cancel path before any datamodel field is nulled, so a loader still in + /// flight cannot observe a released field. It must not be converted into a blocking wait + /// inside , which runs on the UI thread (issue #731 finding 4). + /// + /// + /// The upper bound on the wait. Supplied by the caller as a constant. + Task QuiesceLoaderAsync(TimeSpan timeout); + void Cleanup(); } } diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/code-review.2026-09-06T15-31.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/code-review.2026-09-06T15-31.md new file mode 100644 index 000000000..35b7b941b --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/code-review.2026-09-06T15-31.md @@ -0,0 +1,173 @@ +# Code Review — Issue #791 (quickfiler-high-confidence-cancel-teardown-and-deadline-defects) + +- **Date:** 2026-09-06 +- **Reviewer:** feature-review agent (cycle 1) +- **Base:** `main` @ `7c8ac9ae34b8b3dda9134a5e310f39742fd2f0b6` +- **Head:** `bug/quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791` @ `59536368756d979f3f72268dfb4dfd0d4b2f7d9f` +- **Scope:** the full branch diff against the merge base — 17 `.cs`/`.csproj` files, 40 documentation + and evidence files, 6 agent-memory files. No caller instruction narrowed this scope. +- **Companion artifacts:** `policy-audit.2026-09-06T15-31.md`, `feature-audit.2026-09-06T15-31.md` + +## Executive Summary + +The change is well constructed and the review found no blocking defect. Two separate defects are +fixed with a design that is stated before it is written, justified in place, and pinned by tests that +retain real discriminating power. + +Three things stand out as above the repository norm: + +1. **The retargeting is honest.** Seven pre-existing tests encoded the superseded #424/#608 + empty-at-the-deadline behavior. All seven were retargeted rather than deleted, and each keeps the + discrimination that made it valuable. `DequeueAsync_ZeroAcceptedAndCapReached_ReportsScanCapReachedStop` + still reports `sourceActive: () => true`, so exhaustion is not an available explanation for the + empty batch. `DequeueAsync_AfterScanCapReached_StopsTakingAndLeavesUnscannedCandidates` swaps a 4 s + deadline for a cap of 4 and thereby preserves its original take-count and residual assertions at + exactly 4 and 6. The new #608 pin injects a cap of 2 that is deliberately smaller than the 21 + candidates it scans, so a guard widened to evaluate the bounds after an acceptance would fail it. +2. **An architecture pin was respected rather than relaxed.** Introducing an `IEmailMoveMonitor` local + inside an `async` method made the compiler-generated state machine a fourth type declaring such a + field, which broke the #731 three-owner topology pin. The repair moved the snapshot into a + synchronous helper so no state machine is generated, instead of changing the pin's expected count + from 3 to 4. The reason is written into the helper's XML doc. +3. **The RED-first evidence reproduces the reported failure, not a proxy for it.** + `TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing` fails before + the fix with `System.ArgumentException: Delegate to an instance method cannot have null 'this'`, + character-for-character the message in the attached production log, reproduced deterministically + without Outlook. + +Sixteen findings are recorded below: six Minor with a concrete recommendation, ten Observations. None +is blocking. Two of the Minor findings (N1, N2) concern the exception-safety guarantee the change +itself states, and are the most useful items to act on; N2 cannot be fixed on this branch because the +file it lives in is an explicit AC5 non-goal. + +Independently verified by this reviewer at head, not read from a delivery artifact: `csharpier check` +(1587 files, exit 0), both `/t:Rebuild` gate builds (exit 0, 0 warnings, 0 errors), the +`QuickFiler.Test` assembly (1362 tests, exit 0), and per-file coverage for all seven changed +production paths from both Cobertura documents. + +## Findings Table + +| Severity | File | Location | Finding | Recommendation | Rationale | Evidence | +|---|---|---|---|---|---|---| +| Minor | `QuickFiler/Controllers/QfcHomeController.cs` | `:389` (`_tokenSource?.Dispose();`) | The cancellation token source is disposed but the field is not nulled. The same `CancellationTokenSource` instance is handed to the datamodel at `:125` and to the form controller at `:144`, and `QfcDatamodel.Cleanup()` and `QfcDatamodel.QuiesceLoaderAsync()` both begin with `_tokenSource?.Cancel()`. `CancellationTokenSource.Cancel()` after `Dispose()` raises `ObjectDisposedException` on .NET Framework 4.8, so a repeat `Cleanup()` would throw where it previously could not. Before this change the source was never disposed, so this failure mode did not exist. It is currently unreachable: `QfcFormController.Cleanup()` nulls `_parentCleanup` after invoking it and nulls `_parent`, and `RibbonController` never calls `QfcHomeController.Cleanup()` directly — it only supplies `ReleaseQuickFiler` as the callback (`RibbonController.cs:106,120,141`). | Set `_tokenSource = null;` on the line after `Dispose()`, and extend `Cleanup_DisposesTokenSourceAndDetachesWorkerCompleted` with a second `Cleanup()` call asserting no ERROR-level stage failure. | The change's own goal is that repeat teardown is inert. Leaving a disposed-but-reachable source in a field is the one place on the branch where a second pass acquires a throw it did not have before, and the only thing keeping it unreachable is a nulling in a file the branch deliberately does not touch. | Read of `QfcHomeController.cs:118-150,367-405`, `QfcDatamodel.cs:74-90`, `QfcDatamodel.QueueProcessing.cs:44-63`, `QfcFormController.SetupDisposal.cs:250-261`, `TaskMaster/Ribbon/RibbonController.cs:100-150` | +| Minor | `QuickFiler/Controllers/QfcFormController.SetupDisposal.cs` | `:213-261`, specifically `:251` and `:259` | The stated invariant — "`RibbonController.ReleaseQuickFiler` has been invoked exactly once regardless of which teardown stage threw" (`spec.md:116`) — holds for the two outer links but not the middle one. `ActionCancelAsync` runs `Cleanup` under `finally`, and `QfcHomeController.Cleanup()` invokes `ParentCleanup` under `finally`, but `QfcFormController.Cleanup()` calls `_parentCleanup?.Invoke()` as its last statement with no `try`/`finally`. A throw from `_formViewer?.Dispose()` at `:251`, or from `Controls.ForAllControls` at `:185`, skips the invocation and the ribbon buttons stay inert for the session — exactly the failure the fix is meant to eliminate. No test covers a throw inside `QfcFormController.Cleanup()`; the two existing exception tests cover the groups-cleanup stage and the datamodel-cleanup stage, both of which sit outside this method. | Do not change it on this branch: `QfcFormController.SetupDisposal.cs` is an explicit AC5 non-goal (`spec.md:85`) and editing it would break AC5. Promote a follow-up issue to wrap `:215-258` in a `try` with `_parentCleanup?.Invoke(); _parentCleanup = null;` in a `finally`, and add the matching exception test. | The invariant is written as unconditional. A reader who trusts it will not re-check the middle link, and the residual gap is the same class of defect the issue reports. Recording it now is cheaper than rediscovering it from a field log. | Read of `QfcFormController.SetupDisposal.cs:213-261`, `QfcFormController.EventHandlers.cs:168-172`, `QfcHomeController.cs:396-403`; `spec.md:85,116` | +| Minor | `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs` | class scope; corresponds to `QfcStreamingDequeueConfidenceGate.cs:346-361` | AC1 requires that "the bound decision is logged", and `LogScanBoundReached` emits a `Bound=scan-cap` / `Bound=zero-acceptance-ceiling` discriminator plus `Decision=stop`. No test asserts any of it. A search of the whole `QuickFiler.Test` tree for `scan bound reached`, `Bound=` and `Decision=stop` returns no match. The line executes during the two bound tests, so it is covered, but its content is unpinned: swapping the two bound names, or deleting the call, would not fail a test. The sibling lines are pinned — `DequeueAsync_CheckpointExpiry_LogsCutoffAndCounts` asserts `Cutoff=900`, `Accepted=0`, `Scanned=3`, and `DequeueAsync_Launch_LogsCutoffQuantityAndBounds` asserts five fields. | Pass `debugLog: logs.Add` in `DequeueAsync_ZeroAcceptedAndCapReached_StopsAndReportsScanCapReached` and assert the emitted line contains `Bound=scan-cap`; do the same in `_ZeroAcceptedAndCeilingReached_` asserting `Bound=zero-acceptance-ceiling`. Both are two-line additions using the seam the file already uses. | The bound discriminator is the single piece of information an operator needs to tell an item-cap exit from a time-ceiling exit, and diagnosability of the bounded exit is an explicit AC1 clause. Coverage alone does not protect a log message's content. | Grep of `QuickFiler.Test` for `scan bound reached\|Bound=\|Decision=stop`: no matches. Read of `QfcStreamingDequeueConfidenceGateTests.Part4.cs:127-207` and `QfcStreamingDequeueConfidenceGate.cs:340-362` | +| Minor | `QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs` | `:66-67` and `:219-222` | `SpinWait.SpinUntil(condition, TimeSpan.FromSeconds(5))` and `loaderEntered.Task.Wait(TimeSpan.FromSeconds(5))` are real wall-clock bounded waits. `.claude/rules/general-unit-test.md`, "Determinism Infrastructure", lists "real wall-clock waits" among the banned APIs in test code. Both are condition-driven rather than fixed sleeps, both fail with a clear message if the condition never holds, and both are verbatim copies of the pre-existing convention at `QfcDatamodelLivenessTests.cs:56,103,173` and `QfcInitEmailQueueZeroBatchTests.cs:161`, which the new file's own docstring cites as the convention it follows. The boundary being crossed is genuinely awkward: `Worker_DoWork` is `async void` on a `BackgroundWorker` thread and exposes no completion handle a test can await. | No change on this branch. Matching the established local style is what the General Code Change Policy §7.1 instructs, and diverging here would leave two idioms for the same boundary. Track repository-wide: an awaited completion seam on the worker boundary would let all four call sites drop the timed wait. | The rule is real and the exception is real. Recording both, with the precedent, is more useful than either scoring a FAIL against a repo-wide convention or leaving the divergence unmentioned. | Read of `QfcDatamodelTeardownTests.cs:59-67,201-233`; grep of `QuickFiler.Test` for `SpinUntil\|\.Wait\(` showing four pre-existing sites | +| Minor | `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` | whole file; attribute at `QuickFiler/Controllers/QfcDatamodel.cs:25` | 115 lines of new production code — `QuiesceLoaderAsync`, `LogQuiesceOutcome`, the relocated `TryQueueRemainingMailItemAsync`, `TryCreateRemainingQueueAdmission`, `_remainingLoadTask`, `QuiesceDebugLog` — land inside a type carrying a class-level `[ExcludeFromCodeCoverage]`. Both the baseline and the post-change Cobertura emit **zero** `class` elements for both `QfcDatamodel` partials, so this code is outside the coverage denominator entirely. The attribute is pre-existing and this branch neither added nor extended it. `.claude/rules/general-unit-test.md`'s Coverage Exclusion Policy ("No production file may be excluded from coverage measurement") and `CLAUDE.md` UT2's ratified `[ExcludeFromCodeCoverage]` exemption are in direct conflict here; the conflict pre-exists the branch. | Promote a follow-up issue to extract the host-neutral queue and quiesce logic out of `QfcDatamodel` into a testable type, leaving only COM-bound wiring behind the attribute. Do not resolve it on this branch. | The immediate risk is low — `evidence/qa-gates/p3-t7-changed-line-coverage.md` names a passing test for each changed member and this reviewer confirmed all five are recorded `PASS-AFTER` — but each addition makes the excluded surface larger and the substitute-evidence table longer, which is a maintenance cost that compounds silently. | Per-`filename` enumeration of `class` elements in both Cobertura documents returns `ABSENT` for both partials; `evidence/baseline/p0-t12-coverage-measurability.md`; `evidence/qa-gates/p3-t7-changed-line-coverage.md:40-65` | +| Minor | `docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/runbooks/live-outlook-cancel-teardown-verification.runbook.md` | `:16` | The committed runbook embeds an absolute host path including the account name: `C:\Users\DanMoisan\repos\TaskMaster\TaskMaster\bin\Debug\TaskMaster.vsto`. This is the only such occurrence anywhere on the branch; a scan of all 1671 added source lines for `C:\Users\` returns zero hits, and every other document in the feature folder is clean. | Replace with `\TaskMaster\bin\Debug\TaskMaster.vsto`, or with `%USERPROFILE%\repos\TaskMaster\...` if the runbook's human reader needs a runnable form. | Committed artifacts outlive the machine they were written on. An account name in a document that will be read by whoever performs HI-1 has no operational benefit that a placeholder does not also provide. | Grep of the feature folder for `DanMoisan`: one match, at the cited line | +| Observation | `QuickFiler/Controllers/QfcFormController.EventHandlers.cs`, `QuickFiler/Controllers/QfcHomeController.cs` | `EventHandlers.cs:26-34,160-163`; `QfcHomeController.cs:381-394` | Five broad `catch (System.Exception)` handlers are added. `.claude/rules/general-code-change.md` allows a broad catch only at a defined boundary with added context; all five qualify — each logs the stage name and the exception at ERROR — and AC2 explicitly requires that a throwing stage cannot skip a later one and that every exception is logged, so this is the specified design, not a shortcut. The residual is that a programming error inside any teardown stage now surfaces only as a log line an operator has to go looking for. | No change. The two catches the spec says must not be widened were verified intact: the per-item boundary catch in the deactivate routine and the gate's rejection-sink catch. | Recorded so the tradeoff is visible rather than discovered later from an ERROR line nobody read. | Regex scan of added lines: 5 `catch (System.Exception` / `catch (Exception`; read of each site | +| Observation | `QuickFiler.Test/QuickFiler.Test.csproj` | whole file | 528 lines at head, 524 at base, above the 500-line ceiling in `.claude/rules/general-code-change.md`. The rule enumerates "production code, test code, or reusable script file"; an MSBuild project file is none of these, and the growth is four `` entries that the legacy non-SDK project format requires for the four new test files. Pre-existing. | No change. | Recorded because the ceiling is otherwise applied mechanically to every changed file and this one would look like an unexplained omission. | Line counts at base and head for every changed `.cs`/`.csproj` path | +| Observation | `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs`, `QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs`, `QuickFiler/Controllers/QfcHomeController.cs` | whole files | Three changed files sit within four lines of the 500-line ceiling: 498, 497 and 496 respectively. All three are under the limit and all three are compliant. | No change now. The next edit to any of them will need a split; `QfcStreamingDequeueConfidenceGateTests` already demonstrates the pattern with four partial-class parts. | The branch author already anticipated this once, creating `Part4.cs` rather than growing an existing part. Naming the three files makes the next author's decision explicit. | Line counts at head | +| Observation | `QuickFiler.Test/Controllers/QfcFormControllerCleanupTests.cs` | `:376-397` | `Cleanup_SourceContainsNoSynchronousWait` is the #731 forward guard asserting `.Wait(`, `.Result`, `Thread.Sleep` and `Task.Delay` are absent from the teardown path. It reads only `QfcFormController.SetupDisposal.cs`. The Cancel-path ordering and the awaited loader quiesce now live in `QfcFormController.EventHandlers.cs`, which the guard does not scan. | Consider extending `ReadDisposalPartialSource()` to include the `EventHandlers.cs` teardown region in a follow-up. Not required by any acceptance criterion here. | This reviewer read the new path and confirmed it contains none of the four literals, so nothing is wrong today. The guard's coverage simply did not move with the code it protects. | Read of `QfcFormControllerCleanupTests.cs:371-397` and `QfcFormController.EventHandlers.cs:126-173` | +| Observation | `artifacts/pr_context.summary.txt` | "Changed files overview" section | The summary reports `Core logic changes: 0 files` and `Docs/templates/agents/tooling: 46 files` while the branch changes 17 `.cs`/`.csproj` files. The C# files are absent from every bucket, not misfiled into one. A simulation of `.claude/hooks/validate-feature-review-coverage.ps1`'s `Get-ChangedLanguageSet` against this summary returns an empty language set, meaning the coverage hook would classify this C#-only branch as having zero changed languages and would skip its own enforcement entirely. | Report the collector defect upstream. Reviewers should continue deriving the changed-file set from `git diff --numstat`, as this audit did. | The hook that exists to guarantee a coverage verdict is disarmed by the artifact that feeds it. The correctness of this audit does not depend on the summary, but the guarantee does. | `Get-ChangedLanguageSet` simulation: `count=0`; `git diff --numstat` shows 17 code paths | +| Observation | `docs/.../evidence/qa-gates/p3-t5-tests-coverage.md`, `p3-t8-coverage-delta.md` | absolute counters only | The delivery aggregates Cobertura with an all-descendant `.//line` selection, which counts a source line once under `class/lines/line` and again under `class/methods/method/lines/line`. Its absolute counters (112551/133187 lines, 26584/33568 branches) are therefore roughly double the class-level counters this reviewer computed (55783/66009 and 13292/16784). The derived percentages are unaffected: 84.51% and 79.19% reproduce exactly under both selections, and the delivery's own comparability precondition correctly refuses to compare the absolute counts across unequal denominators. | Prefer `classes/class/lines/line` in future aggregations so the absolute counters are directly meaningful. No correction is needed to any conclusion drawn on this branch. | An artifact whose absolute counters are twice the real figure invites a later reader to compare them against a differently-computed number and reach a wrong conclusion. | This reviewer's two-selection aggregation over the same document | +| Observation | `QuickFiler/Controllers/QfcFormController.EventHandlers.cs`, `QuickFiler/Controllers/QfcHomeController.cs` | whole files | Per-file line coverage is 58.12% and 76.36%, both below the 85% uniform per-file floor. Both improved (from 49.61% and 75.85%), both improved on branch coverage (+10.86 and +3.90 points), and no changed line lost coverage. Both files carry `using Microsoft.Office.Interop.Outlook` and `using System.Windows.Forms` and are Outlook-Interop event-handler surfaces in `QuickFiler`, which is exemption class (c) of the maintainer-ratified `CLAUDE.md` UT2 exemption. | No change. The uncovered remainder is host-bound and has no injectable seam; the long-term answer is extraction, which is out of this branch's Write Set. | Dispositioned FAIL-but-non-blocking in `policy-audit.2026-09-06T15-31.md` section 1.2.1. Recorded here so the numbers are visible in both artifacts. | Per-file aggregation of `coverage/791-baseline.cobertura.xml` and `artifacts/csharp/coverage.xml` by this reviewer | +| Observation | test selection | `/TestCaseFilter` in every run | All runs, baseline and final, apply `TestCategory!=LiveOutlook` and exclude four `UtilitiesCS.Test` shell-icon classes (`ShellUtilities_Tests`, `ShellUtilitiesStatic_Tests`, `SysImageListHelperTests`, `OSBrowser_Tests`) that stall `vstest` on this machine. The exclusion is applied identically on both sides, so the baseline-to-final comparison is like-for-like, and none of the excluded classes is related to this change. | No change. CI runs the unfiltered suite. | Recorded because the headline "7023 tests passed" is a filtered figure, and the filter should be visible next to it. | `evidence/qa-gates/p3-t5-tests-coverage.md:8`; `evidence/baseline/p0-t11-coverage.md` | +| Observation | `docs/.../spec.md` | `:266-268` | AC5 as written ("The branch diff touches no file outside the Write Set…") is unsatisfiable over the whole tree, because delivering the fix requires writing evidence artifacts and checking the AC boxes in `spec.md` itself. The delivery narrows the evaluation to the pathspec `'*.cs' '*.csproj'` and records that narrowing explicitly in the AC's own evidence bullet rather than leaving it implicit. | No change. This is the correct handling of an over-broad criterion. | Recorded so a later reader who evaluates AC5 literally does not score it FAIL and conclude the delivery broke scope. Under the stated pathspec the criterion is fully satisfied and all five named exclusions are verifiably unmodified. | `spec.md:266-268`; `git diff --name-only` over `'*.cs' '*.csproj'` returning exactly 17 paths | +| Observation | `QuickFiler/Controllers/QfcFormController.EventHandlers.cs`, `QuickFiler/Controllers/QfcHomeController.cs` | `EventHandlers.cs:171`; `QfcHomeController.cs:401` | Both files log `"… ribbon release callback invoked."` unconditionally. At `EventHandlers.cs:171` the line runs in a `finally` immediately after `RunTeardownStage("controller-cleanup", Cleanup)`, which swallows any exception, so the message is emitted even when `Cleanup` threw before reaching `_parentCleanup?.Invoke()`. At `QfcHomeController.cs:401` it runs after `ParentCleanup?.Invoke()`, so it is emitted even when `ParentCleanup` is null. | Make the claim conditional, or reword to describe the stage rather than the outcome (for example `"Cancel teardown finished."`), and log the callback invocation from the site that actually performs it. | The issue this change fixes is fundamentally a diagnosability failure — 37 minutes of silence. A log line that asserts something that may not have happened is a weaker outcome than silence, because it actively misleads the next reader of the log. | Read of `QfcFormController.EventHandlers.cs:168-172` and `QfcHomeController.cs:396-403` | + +## Design and Correctness Review + +### The gate change (AC1) + +The zero-acceptance policy stays inside the same `deadlineEnabled && accepted.Count == 0` guard the +#424 deadline used (`QfcStreamingDequeueConfidenceGate.cs:224`). That is the right structural choice: +`Timeout.InfiniteTimeSpan` still means "no bound at all", and a non-empty prefix is still governed by +#608 fill-or-exhaust rather than by any new bound. The separate `checkpointOrigin` is necessary and +its comment says why — sharing one origin with `start` would make the first checkpoint also the last, +which is the superseded behavior. + +The two bounds are evaluated before `_tryTakeNext()` (`:230`), so a bounded scan cannot consume one +extra candidate on its way out; `DequeueAsync_ZeroAcceptedAndCapReached_StopsAndReportsScanCapReached` +asserts exactly that with `takeCount.Should().Be(4)` and `source.Should().HaveCount(6)`. + +The time ceiling is genuinely necessary and not redundant with the item cap: the empty-queue wait path +at `:244-257` does not increment `scanned`, so with the loader still refilling and `tryTakeNext` +returning null the cap can never be reached. The `await _timeProvider.Delay(...)` then `continue` +returns control to the loop top where the ceiling is evaluated, which is what terminates the wait. +`DequeueAsync_ZeroAcceptedAndCeilingReached_StopsWhileSourceStillRefilling` pins it with +`sourceActive: () => true`, `tryTakeNext` always null, and `Scanned=0`. + +`MaxScanWithoutAcceptance` is a get-only auto-property rather than a `private readonly` field, with a +comment explaining that a private field assigned and never read raises CS0414, which +`/p:TreatWarningsAsErrors=true` promotes to an error. This reviewer confirmed the reasoning is correct +and the resulting gate build is warning-clean. + +`ScanCapReached` is documented as requiring identical caller treatment to `DeadlineExpired`, and +`DeadlineExpired` is retained with an updated doc rather than removed, so existing switch arms and +mocks still compile. `IterateQueueAsync_EmptyBatchWithScanCapReached_DoesNotCompleteAdding` verifies +the new reason is not routed into the queue-closing branch, and its negative control +`IterateQueueAsync_EmptyBatchWithSourceExhausted_CompletesAddingOnce` verifies genuine exhaustion +still closes it. That pair is what makes #446 AC-6 verifiably preserved rather than merely untouched. + +### The teardown change (AC2) + +The implemented order in `ActionCancelAsync` matches the ten stages the spec specifies, in the +specified sequence: log entry, cancel token (before the first await), marshal to the UI context, reset +`KbdActive`, park focus and cancel selectors, unregister navigation and form handlers, hide, await the +loader quiesce, groups cleanup, and `Cleanup()` under `finally`. The two ordering constraints that +matter are separately asserted: +`ActionCancelAsync_UnregistersHandlersBeforeGroupsCleanup` (handlers before rows) and +`ActionCancelAsync_AwaitsLoaderQuiesceBeforeGroupsCleanup` (loader before field release). + +`QuiesceLoaderAsync` is correct in the two places it is easy to get wrong. It snapshots +`_remainingLoadTask` into a local before testing it, because the field is written on the worker +thread. It passes `CancellationToken.None` to the bound delay, because the bound must survive the +token this method just cancelled — otherwise a hung loader would leave the Cancel path with no exit. +Both reasons are written in place. + +`TryCreateRemainingQueueAdmission` is the correct guard placement. It refuses at the accept point +rather than throwing at the delegate-construction point, and it snapshots both fields into locals +before the null test, which is the right shape for cross-thread fields. Being a separate synchronous +method rather than the opening block of the `async` method is not stylistic: it is what keeps the +`IEmailMoveMonitor` local off a compiler-generated state machine and preserves the #731 topology pin. + +`ButtonCancel_Click` no longer rethrows. This is a deliberate behavior change and it is the right one +for an `async void` handler, where a rethrow becomes an unhandled Outlook UI-thread exception carrying +nothing actionable. The replacement is stage-level ERROR logging, which is strictly more diagnosable. +`ButtonCancel_Click_ActionThrows_DoesNotRethrow` pins it by installing a capturing +`SynchronizationContext` and asserting nothing was posted — the only way to observe an `async void` +escape — and restores the previous context in a `finally`. + +### Test quality + +The 23 added tests use MSTest, Moq and FluentAssertions throughout, follow Arrange–Act–Assert with +explicit section comments, and carry XML-doc summaries naming the criterion and the failure each +prevents. Determinism is achieved through seams rather than timing: `FakeTimeProvider` for both +clocks, injected `Action` delegates for both log sinks, `TaskCompletionSource` for the hanging +loader, `FormatterServices.GetUninitializedObject` to bypass COM-bound constructors, and a bare +`Control.ControlCollection` with an empty exclusion list to satisfy the unregister guard without +creating a window handle. The one exception is finding N4. + +Assertion reasons are supplied consistently and are informative rather than restating the assertion +("toggling an inactive dialog would activate it, not reset it"; "a bounded zero-acceptance exit is +not source exhaustion"). Ordering assertions compare the first index of two markers and separately +assert each marker's presence, so they cannot pass vacuously. + +## Verification Performed by This Reviewer + +- Read the full diff of all seven production files and all nine test files against the merge base. +- Re-ran `dotnet tool run csharpier check .` — `Checked 1587 files in 4202ms`, exit 0. +- Re-ran both `/t:Rebuild` gate builds — exit 0; the nullable build printed `0 Warning(s) 0 Error(s)`. +- Re-ran the `QuickFiler.Test` assembly at head — `Test Run Successful. Total tests: 1362`, exit 0. +- Aggregated both Cobertura documents per package and per changed file with an independent + `classes/class/lines/line` selection, reproducing the delivery's derived percentages exactly and + producing per-file baseline-versus-head figures the delivery did not report. +- Scanned all 1671 added `.cs`/`.csproj` lines for `Thread.Sleep`, `Task.Delay`, `DateTime.Now`, + `DateTime.UtcNow`, `Random.Shared`, `GetTempPath`, `GetTempFileName`, `SuppressMessage`, + `#pragma warning disable`, `ExcludeFromCodeCoverage`, `xunit`, `nunit` and `C:\Users\` — zero hits + for every one of them. +- Verified that the five files `spec.md` names as non-goals are absent from `git diff --name-only`. +- Verified that no `.cs` change exists in any commit before `59536368`, so the delivery's use of + `51b557df` as its changed-line base is equivalent to the merge base for source paths. +- Verified that `RibbonController` never calls `QfcHomeController.Cleanup()` directly, which is what + keeps finding N1 unreachable. +- Simulated `.claude/hooks/validate-feature-review-coverage.ps1` against this review's policy audit: + the C# coverage rows pass, and the summary-derived changed-language set is empty (finding N11). + +## Recommendation + +**GO for PR.** No blocking defect. The two highest-value follow-ups are N1 (null `_tokenSource` after +disposing it) and N2 (protect `_parentCleanup?.Invoke()` in `QfcFormController.Cleanup()`, which must +be a separate issue because that file is an AC5 non-goal). N3 is a two-line test addition that would +close the last unpinned clause of AC1. N5 should be promoted as a coverage-measurability refactor. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t10-quickfiler-tests.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t10-quickfiler-tests.md new file mode 100644 index 000000000..a0bfb82cc --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t10-quickfiler-tests.md @@ -0,0 +1,35 @@ +# [P0-T10] QuickFiler.Test baseline run + +Timestamp: 2026-09-06T14-27 + +Command: + +``` +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p0-t10' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:TestCategory!=LiveOutlook' +``` + +`$vstest` was re-bound inside this command block by the two R10 resolution lines. The resolved value +reduced per R3 is `\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe`. + +EXIT_CODE: 0 + +BASELINE-QFT-TOTAL: 1339 +BASELINE-QFT-PASSED: 1339 +BASELINE-QFT-FAILED: 0 + +Output Summary: `Test Run Successful. Total tests: 1339, Passed: 1339, Total time: 13.2586 Seconds.` +The three derived lines above are read from the TRX `ResultSummary/Counters` element +(`total`, `passed`, `failed`), and the run's `ResultSummary/outcome` attribute is `Completed`. No +raw TRX content is reproduced here: a TRX carries `runUser` and `computerName` attributes and its +default filename embeds both, which R3 forbids in an artifact. The results directory +`TestResults/` is git-ignored at `.gitignore` line 39, so the TRX is a local run output and is not +committed. + +`BASELINE-QFT-FAILED: 0` is the value [P2-T15] compares against: `POST-QFT-FAILED` must be less than +or equal to it, and `NEWLY-FAILING` must be `NONE`. Because the baseline failure set is empty, any +failure in [P2-T15] is newly failing by construction. + +`/InIsolation` is present because a shared test host in this worktree loads assemblies from sibling +worktrees; the run is scoped to a single explicitly named assembly, so no `.claude` worktree path is +enumerated (D15). No shell-icon exclusion clause is required here, because the four hanging classes +live in `UtilitiesCS.Test`, which this run does not load. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t11-coverage.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t11-coverage.md new file mode 100644 index 000000000..0013af69b --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t11-coverage.md @@ -0,0 +1,88 @@ +# [P0-T11] Baseline coverage over the nine first-party test assemblies + +Timestamp: 2026-09-06T14-28 + +Command: + +``` +dotnet-coverage collect --output coverage\791-baseline.cobertura.xml --output-format cobertura --settings coverage\791-effective-coverage.config -- $vstest '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p0-t11' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests' +``` + +The nine assemblies are named explicitly (D15): `QuickFiler.Test`, `SVGControl.Test`, `Tags.Test`, +`TaskMaster.Test`, `TaskTree.Test`, `TaskVisualization.Test`, `ToDoModel.Test`, `UtilitiesCS.Test` +and `VBFunctions.Test`, each as `\bin\Debug\.dll`. A path never enumerated cannot +be loaded, so no worktree under a `.claude` segment can enter the run. + +`$vstest` was re-bound inside this command block by the two R10 resolution lines; the resolved value +reduced per R3 is `\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe`. + +EXIT_CODE: 0 + +BASELINE-TOTAL-TESTS: 7000 +BASELINE-FAILED-TESTS: 0 + +Output Summary: `A total of 9 test files matched the specified pattern.` then +`Test Run Successful. Total tests: 7000, Passed: 7000, Total time: 48.9533 Seconds.` and +`Code coverage results: coverage\791-baseline.cobertura.xml.` The four shell-icon exclusion clauses +in the filter are the known local hang set in `UtilitiesCS.Test`; they are excluded here so the run +terminates, and CI covers them. + +## Derived coverage config + +`coverage\791-effective-coverage.config` is `coverage.config` with one appended +`` exclusion for `.*\.Test\.dll$`, because `coverage.config` carries no such entry +and the test assemblies must not enter the denominator. The same derived file is reused by +[P3-T5], so both sides of the comparison are produced by one collector, one configuration, one +selection and one filter. + +## Aggregated first-party counters + +Aggregated from `coverage\791-baseline.cobertura.xml` by the pinned all-descendant `.//line` +selection over each first-party ``. The document contains fourteen packages; the five +non-first-party ones (`log4net`, `Mono.Reflection`, `Microsoft.IO.RecyclableMemoryStream`, +`System.Linq.Async`, `System.Interactive`) are excluded by the first-party name list. + +```powershell +$CoberturaPath = 'coverage\791-baseline.cobertura.xml' +$doc = New-Object System.Xml.XmlDocument +$doc.Load((Resolve-Path -LiteralPath $CoberturaPath).Path) +$firstParty = @('Tags','ToDoModel','TaskVisualization','UtilitiesCS','QuickFiler','TaskTree','TaskMaster','SVGControl','VBFunctions') +$lc = 0; $lv = 0; $bc = 0; $bv = 0 +foreach ($pkg in $doc.SelectNodes('/coverage/packages/package')) { + if ($firstParty -notcontains $pkg.GetAttribute('name')) { continue } + foreach ($ln in $pkg.SelectNodes('.//line')) { + $lv++ + $h = $ln.GetAttribute('hits') + if ($h -and [int]$h -gt 0) { $lc++ } + $cc = $ln.GetAttribute('condition-coverage') + if ($cc -and $cc -match '\((\d+)/(\d+)\)') { $bc += [int]$Matches[1]; $bv += [int]$Matches[2] } + } +} +"LINES_COVERED=$lc LINES_VALID=$lv BRANCHES_COVERED=$bc BRANCHES_VALID=$bv" +``` + +printed, verbatim: + +```text +LINES_COVERED=112355 LINES_VALID=132961 BRANCHES_COVERED=26496 BRANCHES_VALID=33480 +``` + +BASELINE-LINES-COVERED: 112355 +BASELINE-LINES-VALID: 132961 +BASELINE-BRANCHES-COVERED: 26496 +BASELINE-BRANCHES-VALID: 33480 +BASELINE-LINE-PERCENT: 84.50 +BASELINE-BRANCH-PERCENT: 79.14 + +BASELINE_FLOOR: MET + +The 84.50 percent first-party line rate is at or above the CLAUDE.md UT2 80 percent floor, so the +floor is met at `BASE-SHA`. Per the task, the plan continues regardless of this determination; a +pre-existing repository floor never halts it. + +## Status of the collected document + +`coverage\791-baseline.cobertura.xml` is git-ignored by `.gitignore` line 144 (`coverage/*`). It is +a local output of this run, not committed evidence. The TRX under `TestResults\791-p0-t11` is +covered by `.gitignore` line 39. No TRX content is reproduced here (R3); only the parsed totals are +recorded. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t12-coverage-measurability.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t12-coverage-measurability.md new file mode 100644 index 000000000..1944ca94a --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t12-coverage-measurability.md @@ -0,0 +1,74 @@ +# [P0-T12] Coverage measurability of the seven Write Set production files + +Timestamp: 2026-09-06T14-30 + +Command: a separator-anchored trailing-name query over every `class` element in +`coverage\791-baseline.cobertura.xml`: + +```powershell +$doc = New-Object System.Xml.XmlDocument +$doc.Load((Resolve-Path -LiteralPath 'coverage\791-baseline.cobertura.xml').Path) +$names = @('QfcStreamingDequeueConfidenceGate.cs','IQfcDatamodel.cs','QfcDatamodel.QueueProcessing.cs','QfcDatamodel.cs','QfcFormController.EventHandlers.cs','QfcFormController.Deactivate.cs','QfcHomeController.cs') +foreach ($n in $names) { + $hit = 0 + foreach ($c in $doc.SelectNodes('//class')) { + $f = $c.GetAttribute('filename') + if ($f.EndsWith('\' + $n) -or $f.EndsWith('/' + $n)) { $hit++ } + } + "$n classElements=$hit" +} +``` + +EXIT_CODE: 0 + +The match is separator-anchored on purpose: an unanchored `QfcDatamodel.cs` suffix would also select +`IQfcDatamodel.cs`, which would report the excluded partial as measurable through its interface +file's class element. + +## Class-element counts the determination was made from + +TOTAL-CLASS-ELEMENTS-IN-DOCUMENT: 3282 + +| Write Set production path | class elements | +|---|---| +| `QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs` | 3 | +| `QuickFiler/Interfaces/IQfcDatamodel.cs` | 1 | +| `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` | 0 | +| `QuickFiler/Controllers/QfcDatamodel.cs` | 0 | +| `QuickFiler/Controllers/QfcFormController.EventHandlers.cs` | 14 | +| `QuickFiler/Controllers/QfcFormController.Deactivate.cs` | 1 | +| `QuickFiler/Controllers/QfcHomeController.cs` | 8 | + +## Determination + +MEASURABLE: QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs +MEASURABLE: QuickFiler/Interfaces/IQfcDatamodel.cs +UNMEASURABLE: QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs +UNMEASURABLE: QuickFiler/Controllers/QfcDatamodel.cs +MEASURABLE: QuickFiler/Controllers/QfcFormController.EventHandlers.cs +MEASURABLE: QuickFiler/Controllers/QfcFormController.Deactivate.cs +MEASURABLE: QuickFiler/Controllers/QfcHomeController.cs + +MEASURABLE-COUNT: 5 +UNMEASURABLE-COUNT: 2 +TOTAL: 7 + +## Reading + +The determination matches D1 exactly. The two zero-count files are the two `QfcDatamodel` partials. +`QuickFiler/Controllers/QfcDatamodel.cs` line 25 carries `[ExcludeFromCodeCoverage]` on the partial +class declaration; the attribute applies to the whole type, so members declared in +`QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs`, which declares +`public partial class QfcDatamodel` at line 12, are excluded too. Changed-line coverage for those +two files is structurally unmeasurable rather than merely low, and [P3-T7] records +`CHANGED-LINE-COVERAGE: NOT MEASURABLE` for both with named-test evidence as the substitute. + +The five measurable paths are the set [P3-T7] compares. The command block [P3-T7] carries enumerates +exactly these five paths, so this determination and that block agree and no divergence has to be +recorded. + +`QuickFiler/Interfaces/IQfcDatamodel.cs` is reported measurable at the file level because the +`QfcDequeueBatch` struct emits IL. That is a file-level fact and does not imply any *changed* line +in it is executable: [P1-T1] adds only an enum member, an interface method declaration and XML docs, +none of which emits IL. [P3-T7] resolves that one level lower, per changed line, with the +`hits=non-executable` marker. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t13-line-counts.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t13-line-counts.md new file mode 100644 index 000000000..1670096ec --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t13-line-counts.md @@ -0,0 +1,62 @@ +# [P0-T13] Baseline line counts of every file this plan edits or creates + +Timestamp: 2026-09-06T14-31 + +Command: `foreach ($p in ) { (Get-Content -LiteralPath $p).Count }` + +EXIT_CODE: 0 + +CEILING: 500 (applies to *.cs only) + +## Production `.cs` (Write Set, seven paths) + +QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs = 262 +QuickFiler/Interfaces/IQfcDatamodel.cs = 133 +QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs = 298 +QuickFiler/Controllers/QfcDatamodel.cs = 480 +QuickFiler/Controllers/QfcFormController.EventHandlers.cs = 408 +QuickFiler/Controllers/QfcFormController.Deactivate.cs = 60 +QuickFiler/Controllers/QfcHomeController.cs = 469 + +## Existing test `.cs` this plan modifies (five paths) + +QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs = 477 +QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs = 465 +QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs = 280 +QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs = 413 +QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs = 477 + +## New test `.cs` this plan creates (four paths, zero at baseline) + +QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs = 0 (does not exist yet) +QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs = 0 (does not exist yet) +QuickFiler.Test/Controllers/QfcHomeControllerCleanupTests.cs = 0 (does not exist yet) +QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs = 0 (does not exist yet) + +## PROJECT-FILE (exempt) + +PROJECT-FILE (exempt): QuickFiler.Test/QuickFiler.Test.csproj = 524 + +Reason for the exemption: `.claude/rules/general-code-change.md` caps *production code, test code +and reusable script files* at 500 lines. `.csharpierignore` lines 9-14 record that project files are +owned by Visual Studio and are not C# source, and list `*.csproj` at line 12. The file already +stands at 524 lines today and becomes 528 after [P1-T7] adds four `` entries, so +asserting it against the ceiling would be unsatisfiable regardless of what this plan does. Its count +is recorded as an observation and is never asserted (R8). + +## The three tightest `.cs` files + +1. `QuickFiler/Controllers/QfcDatamodel.cs` = 480, headroom 20. +2. `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs` = 477, headroom 23. + `QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs` = 477, headroom 23 (tied). +3. `QuickFiler/Controllers/QfcHomeController.cs` = 469, headroom 31. + +`QfcDatamodel.cs` is the tightest file in the set, but [P2-T5] and [P2-T6] together remove more +lines from it than they add: `TryQueueRemainingMailItemAsync` is relocated out of it into the +QueueProcessing partial. `QfcHomeController.cs` at 31 lines of headroom is what forces D11's +two-guarded-block form for `Cleanup()` rather than three. The two 477-line test files are the ones +[P1-T5] and [P1-T11] must stay inside; [P1-T5] budgets at most 12 added lines and [P1-T11] adds one +small test method. + +These counts are re-measured after the final format by [P3-T9], because CSharpier can change line +counts, and an interim measurement is taken by [P2-T16] before that format. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t14-deadline-test-inventory.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t14-deadline-test-inventory.md new file mode 100644 index 000000000..b774c8fec --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t14-deadline-test-inventory.md @@ -0,0 +1,53 @@ +# [P0-T14] Pre-change status of the seven deadline-dependent tests (D2) + +Timestamp: 2026-09-06T14-31 + +Command: + +``` +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p0-t14' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:FullyQualifiedName~QfcStreamingDequeueConfidenceGateTests|FullyQualifiedName~QfcQueuePurePathsTests' +``` + +`$vstest` was re-bound inside this command block by the two R10 resolution lines. The run uses the +same runsettings, isolation and blame switches as [P0-T10] and differs only in the class-scoping +`/TestCaseFilter`, so the seven statuses come from a run whose scope is exactly the affected set. + +The `TestCategory!=LiveOutlook` clause is deliberately omitted from this filter rather than combined +with the two `FullyQualifiedName` clauses, because `&` binds tighter than `|` in a vstest filter +expression: the combined form `TestCategory!=LiveOutlook&A|B` would apply the category exclusion to +only the first clause. Neither class declares a `LiveOutlook` test, so omitting it changes no +selected test. + +EXIT_CODE: 0 + +Output Summary: `Test Run Successful. Total tests: 39, Passed: 39, Total time: 1.6524 Seconds.` + +## The seven deadline-dependent tests, from the `TestResults\791-p0-t14` TRX + +BASELINE-PASS: QuickFiler.Controllers.Tests.QfcStreamingDequeueConfidenceGateTests.DequeueAsync_LowYieldStream_StopsScanningAtDefaultFirstBatchDeadline +BASELINE-PASS: QuickFiler.Controllers.Tests.QfcStreamingDequeueConfidenceGateTests.DequeueAsync_DeadlineExpiresWithZeroAccepted_ReturnsEmptyListAtTheBound +BASELINE-PASS: QuickFiler.Controllers.Tests.QfcStreamingDequeueConfidenceGateTests.DequeueAsync_AfterDeadlineReturn_StopsTakingAndLeavesUnscannedCandidates +BASELINE-PASS: QuickFiler.Controllers.Tests.QfcStreamingDequeueConfidenceGateTests.DequeueAsync_DeadlineExpiry_EmitsOneExpiryLineAndKeepsPerCandidateLogging +BASELINE-PASS: QuickFiler.Controllers.Tests.QfcStreamingDequeueConfidenceGateTests.DequeueAsync_ProgressCallback_StopsReportingOnceTheMethodReturns +BASELINE-PASS: QuickFiler.Controllers.Tests.QfcStreamingDequeueConfidenceGateTests.DequeueAsync_DeadlineExpiresWithZeroAccepted_ReportsDeadlineExpiredStop +BASELINE-PASS: QuickFiler.Controllers.Tests.QfcQueuePurePathsTests.DequeueNextItemGroupWithOutcomeAsync_DeadlineExpiredGate_ReportsDeadlineExpiredStop + +BASELINE-PASS-COUNT: 7 + +## Cross-check against the [P0-T10] whole-assembly run + +All seven names were also queried by outcome in the `TestResults\791-p0-t10` TRX (the +whole-assembly baseline that recorded 1339 passed, 0 failed) and all seven are recorded there as +`Passed`. The class-scoped run and the whole-assembly run therefore agree on every one of the seven, +so the class-scoping filter did not change any outcome. + +## Reading + +This is the set Phase 1 deliberately turns red and Phase 2 turns green again. All seven pass today, +which is what makes the Phase 2 no-newly-failing comparison in [P2-T15] meaningful: a test in this +set that is still red at the end of Phase 2 is a regression rather than a pre-existing failure. + +Four of the seven live in `QfcStreamingDequeueConfidenceGateTests.Part2.cs` and are retargeted by +[P1-T8]; two live in `.Part3.cs` and are retargeted by [P1-T9]; one lives in +`QfcQueuePurePathsTests.cs` and is retargeted by [P1-T10]. Three of the seven are outside the four +retargeting obligations `spec.md` Test Strategy names, which is the D2 finding. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t2-branch-commit.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t2-branch-commit.md new file mode 100644 index 000000000..2e8fc9dbf --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t2-branch-commit.md @@ -0,0 +1,25 @@ +# [P0-T2] Branch and base commit + +Timestamp: 2026-09-06T14-22 + +Command: `git rev-parse --abbrev-ref HEAD` ; `git rev-parse HEAD` ; `git status --porcelain --untracked-files=all` + +EXIT_CODE: 0 + +BASE-BRANCH: bug/quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791 +BASE-SHA: 51b557dfe35702090fec778febfd4049e0e0fed4 + +Output Summary: `git rev-parse --abbrev-ref HEAD` printed the branch name recorded above as +`BASE-BRANCH`. `git rev-parse HEAD` printed the 40-character hexadecimal commit recorded above as +`BASE-SHA`; this is the ref operand every anchored `git diff` in this plan uses (R6). The merge base +with `main` is `7c8ac9ae`, which is the commit the issue reports against. + +`git status --porcelain --untracked-files=all` reported two entries at the time of capture, both +inside this feature folder and both produced by this plan's own execution: + +- ` M docs/features/active//plan.2026-09-06T12-57.md` — the [P0-T1] check-off. +- `?? docs/features/active//evidence/baseline/phase0-instructions-read.md` — the [P0-T1] + artifact. + +No `*.cs` or `*.csproj` path was modified at base capture time, so the R7 scope pathspec is empty +at this point in the plan. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t3-nuget-restore.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t3-nuget-restore.md new file mode 100644 index 000000000..c79b8359c --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t3-nuget-restore.md @@ -0,0 +1,53 @@ +# [P0-T3] NuGet restore and analyzer HintPath resolution + +Timestamp: 2026-09-06T14-24 + +Command: `msbuild TaskMaster.sln /t:Restore /m /p:RestorePackagesConfig=true /p:Configuration=Debug "/p:Platform=Any CPU"` + +EXIT_CODE: 0 + +packages-subdirs before=172 after=172 + +Output Summary: The restore completed with `Build succeeded. 0 Warning(s) 0 Error(s)` in 1.10 s. +The `packages/` subdirectory count is unchanged at 172 before and after, which confirms this step +was a verification of an already complete tree rather than a repair. No package was downloaded or +added. The only network traffic in the log is the NuGet vulnerability index, which is a metadata +fetch and not a package restore. + +## Analyzer `` HintPath resolution + +Every `` item declared by the two QuickFiler projects was resolved against the +project directory. An unresolved analyzer path produces CS0006, an error, which would fail the +[P0-T8] analyzer gate and the [P0-T9] nullable gate for a reason unrelated to this change. + +### `QuickFiler/QuickFiler.csproj` (9 items) + +RESOLVED: ..\packages\Meziantou.Analyzer.3.0.203\analyzers\dotnet\roslyn5.0\cs\Meziantou.Analyzer.dll +RESOLVED: ..\packages\Roslynator.Analyzers.5.0.0\analyzers\dotnet\roslyn4.7\cs\Roslynator.CSharp.Analyzers.dll +RESOLVED: ..\packages\Roslynator.Analyzers.5.0.0\analyzers\dotnet\roslyn4.7\cs\Roslynator_Analyzers_Roslynator.Common.dll +RESOLVED: ..\packages\Roslynator.Analyzers.5.0.0\analyzers\dotnet\roslyn4.7\cs\Roslynator_Analyzers_Roslynator.Core.dll +RESOLVED: ..\packages\Roslynator.Analyzers.5.0.0\analyzers\dotnet\roslyn4.7\cs\Roslynator_Analyzers_Roslynator.CSharp.dll +RESOLVED: ..\packages\AsyncFixer.2.1.0\analyzers\dotnet\cs\AsyncFixer.dll +RESOLVED: ..\packages\Microsoft.CodeAnalysis.BannedApiAnalyzers.5.6.0\analyzers\dotnet\cs\Microsoft.CodeAnalysis.BannedApiAnalyzers.dll +RESOLVED: ..\packages\Microsoft.CodeAnalysis.BannedApiAnalyzers.5.6.0\analyzers\dotnet\cs\Microsoft.CodeAnalysis.CSharp.BannedApiAnalyzers.dll +RESOLVED: ..\packages\SonarAnalyzer.CSharp.10.33.0.1635\analyzers\SonarAnalyzer.CSharp.dll + +### `QuickFiler.Test/QuickFiler.Test.csproj` (11 items) + +RESOLVED: ..\packages\MSTest.Analyzers.4.4.0\analyzers\dotnet\cs\MSTest.Analyzers.CodeFixes.dll +RESOLVED: ..\packages\MSTest.Analyzers.4.4.0\analyzers\dotnet\cs\MSTest.Analyzers.dll +RESOLVED: ..\packages\SonarAnalyzer.CSharp.10.33.0.1635\analyzers\SonarAnalyzer.CSharp.dll +RESOLVED: ..\packages\Meziantou.Analyzer.3.0.203\analyzers\dotnet\roslyn5.0\cs\Meziantou.Analyzer.dll +RESOLVED: ..\packages\Roslynator.Analyzers.5.0.0\analyzers\dotnet\roslyn4.7\cs\Roslynator.CSharp.Analyzers.dll +RESOLVED: ..\packages\Roslynator.Analyzers.5.0.0\analyzers\dotnet\roslyn4.7\cs\Roslynator_Analyzers_Roslynator.Common.dll +RESOLVED: ..\packages\Roslynator.Analyzers.5.0.0\analyzers\dotnet\roslyn4.7\cs\Roslynator_Analyzers_Roslynator.Core.dll +RESOLVED: ..\packages\Roslynator.Analyzers.5.0.0\analyzers\dotnet\roslyn4.7\cs\Roslynator_Analyzers_Roslynator.CSharp.dll +RESOLVED: ..\packages\AsyncFixer.2.1.0\analyzers\dotnet\cs\AsyncFixer.dll +RESOLVED: ..\packages\Microsoft.CodeAnalysis.BannedApiAnalyzers.5.6.0\analyzers\dotnet\cs\Microsoft.CodeAnalysis.BannedApiAnalyzers.dll +RESOLVED: ..\packages\Microsoft.CodeAnalysis.BannedApiAnalyzers.5.6.0\analyzers\dotnet\cs\Microsoft.CodeAnalysis.CSharp.BannedApiAnalyzers.dll + +UNRESOLVED-COUNT: 0 + +The enumeration reads `` elements by element name from each project XML document rather +than by an XPath with a namespace predicate; both forms select the same item set here because the +MSBuild default namespace gives every element the unprefixed qualified name. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t4-dotnet-tool-restore.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t4-dotnet-tool-restore.md new file mode 100644 index 000000000..f31284649 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t4-dotnet-tool-restore.md @@ -0,0 +1,22 @@ +# [P0-T4] dotnet tool restore + +Timestamp: 2026-09-06T14-25 + +Command: `dotnet tool restore` then `dotnet tool run csharpier --version`, both with +`DOTNET_ROOT` bound to the repository-local `.dotnet-sdk` directory and that directory prepended +to `PATH`. + +EXIT_CODE: 0 + +Output Summary: + +- `dotnet tool restore` printed `Tool 'csharpier' (version '1.2.6') was restored. Available commands: csharpier` + followed by `Restore was successful.`, exit code 0. +- `dotnet tool run csharpier --version` printed the single line `1.2.6`, exit code 0. + +The printed version `1.2.6` is the manifest-pinned version named by `dotnet-tools.json` and by +CLAUDE.md, so the local formatter agrees with `.github/workflows/_format-check.yml`. The +repository-local SDK marker directory `.dotnet-sdk/sdk/8.0.205` exists, so `global.json` resolves +without a machine-wide SDK. + +CSHARPIER-VERSION: 1.2.6 diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t5-dotnet-coverage.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t5-dotnet-coverage.md new file mode 100644 index 000000000..6576ccc3b --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t5-dotnet-coverage.md @@ -0,0 +1,18 @@ +# [P0-T5] dotnet-coverage resolution + +Timestamp: 2026-09-06T14-26 + +Command: `dotnet-coverage --version` + +EXIT_CODE: 0 + +BRANCH-TAKEN: probe-only. The first `dotnet-coverage --version` probe exited 0, so the conditional +`dotnet tool install --global dotnet-coverage` branch was not taken and no re-probe was required. +The tool was already installed globally before this plan began. + +DOTNET-COVERAGE-VERSION: 18.10.0+f4cc39224845ffa74bf246c9da2399d50e5d6342 + +Output Summary: The probe printed the single version line recorded above and exited 0. This is the +collector D13 pins for the two coverage runs ([P0-T11] and [P3-T5]), which use +`dotnet-coverage collect --output-format cobertura` rather than `vstest /EnableCodeCoverage`, +because `/EnableCodeCoverage` writes a binary `.coverage` file and the two collectors conflict. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t6-vstest-resolution.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t6-vstest-resolution.md new file mode 100644 index 000000000..4a4d33a37 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t6-vstest-resolution.md @@ -0,0 +1,26 @@ +# [P0-T6] vstest.console.exe resolution + +Timestamp: 2026-09-06T14-26 + +Command: + +``` +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vstest = & $vswhere -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 +``` + +EXIT_CODE: 0 + +VSTEST-PATH: C:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe + +Output Summary: `vswhere` resolved exactly one candidate, and `Test-Path` on the resolved value +returned `True`, so `VSTEST-PATH` names an existing file. The installation is Visual Studio 18 +Community. + +R3 exemption: this artifact deliberately records the absolute resolved path in full. R3 requires +absolute host paths to be reduced everywhere else, and names this one value as the single +exception, because pinning the resolved path is the whole purpose of the task. The path contains no +user-profile segment and no machine name; it is a `Program Files` installation path. + +Every later task in this plan that uses `$vstest` re-binds it with the same two resolution lines +inside its own command block, per R10, because no variable survives between tasks. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t7-csharpier-check.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t7-csharpier-check.md new file mode 100644 index 000000000..ea07b8631 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t7-csharpier-check.md @@ -0,0 +1,22 @@ +# [P0-T7] CSharpier baseline check + +Timestamp: 2026-09-06T14-27 + +Command: `dotnet tool run csharpier check .` + +EXIT_CODE: 0 + +Verbatim printed line: + +``` +Checked 1583 files in 4650ms. +``` + +BASELINE-CSHARPIER-CHECKED-FILES: 1583 + +Output Summary: The check is read-only and returns non-zero on drift, so exit 0 with the single +success line is the clean-tree observation. No drifting path was reported, so there is no +pre-existing drift set to disclose. The tree is formatter-clean at `BASE-SHA`. + +This is the number [P3-T2] compares against. Four new `.cs` files are added by this plan +([P1-T6], [P1-T12], [P1-T13], [P1-T14]), so the expected final count is 1587, a delta of 4. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t8-msbuild-analyzers.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t8-msbuild-analyzers.md new file mode 100644 index 000000000..a40ab2fc3 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t8-msbuild-analyzers.md @@ -0,0 +1,30 @@ +# [P0-T8] Analyzer-build baseline + +Timestamp: 2026-09-06T14-29 + +Command: `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` + +EXIT_CODE: 0 + +BASELINE-ANALYZER-WARNINGS: 0 +BASELINE-ANALYZER-ERRORS: 0 + +Output Summary: The command is character-for-character the CLAUDE.md analyzer gate. `/t:Rebuild` is +used rather than `/t:Build`, because MSBuild's incremental up-to-date check does not invalidate on a +command-line `/p:` change and a warm `/t:Build` would skip `CoreCompile` on every project and run no +analyzers. + +MSBuild reported: + +``` +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:15.95 +``` + +Seventeen projects were rebuilt, ending with `UtilitiesCS.Test` and then the solution node. The +tree is analyzer-clean at `BASE-SHA`: this baseline is zero warnings and zero errors, so [P3-T3] +has no pre-existing diagnostic set to discount and any diagnostic it reports is attributable to +this change. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t9-msbuild-nullable.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t9-msbuild-nullable.md new file mode 100644 index 000000000..959858780 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t9-msbuild-nullable.md @@ -0,0 +1,32 @@ +# [P0-T9] Nullable-build baseline + +Timestamp: 2026-09-06T14-32 + +Command: `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` + +EXIT_CODE: 0 + +BASELINE-NULLABLE-WARNINGS: 0 +BASELINE-NULLABLE-ERRORS: 0 + +Output Summary: The command is character-for-character the CLAUDE.md nullable gate and the command +in `.github/workflows/_build-nullable.yml`. `/p:Nullable=enable` was not added, because no project +in this repository carries a `` element and there is no `Directory.Build.props`, so the +property would be a solution-wide opt-in that conscripts every file which has never adopted the +`#nullable enable` pragma. `/t:Build` was not substituted, because MSBuild's up-to-date check does +not invalidate on a command-line `/p:` change and a warm `/t:Build` would return exit 0 having +skipped `CoreCompile` on every project. + +MSBuild reported: + +``` +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:13.95 +``` + +Nullable enforcement in this repository is per-file opt-in: a file participates when it carries +`#nullable enable`, and `/p:TreatWarningsAsErrors=true` then promotes its `CS86xx` diagnostics to +build errors. The baseline is clean, so [P3-T4] has no pre-existing diagnostic set to discount. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/phase0-instructions-read.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/phase0-instructions-read.md new file mode 100644 index 000000000..46874dcb0 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/phase0-instructions-read.md @@ -0,0 +1,39 @@ +# [P0-T1] Phase 0 — Policy instructions read + +Timestamp: 2026-09-06T14-21 + +Policy Order: The `policy-compliance-order` sequence was followed exactly: (1) `CLAUDE.md` — all +sections; (2) General Code Change Policy; (3) General Unit Test Policy; (4) the C#-specific rules; +(5) the tonality rules. Language-specific standards layer on top of the general policy. Where a +conflict is found, the session halts and notifies the user; no conflict was found in this pass. + +## Files read (in policy order), with line counts + +| # | Path | Lines | +|---|---|---| +| 1 | `CLAUDE.md` | 447 | +| 2 | `.claude/rules/general-code-change.md` | 80 | +| 3 | `.claude/rules/general-unit-test.md` | 105 | +| 4 | `.claude/rules/csharp.md` | 96 | +| 5 | `.claude/rules/tonality.md` | 80 | + +Command: `pwsh -NoProfile -Command '... foreach ($f in @()) { (Get-Content -LiteralPath $f).Count } ...'` + +EXIT_CODE: 0 + +Output Summary: All five policy files exist and were read in full in the order listed above. Line +counts were measured with `Get-Content ... | .Count` rather than estimated. The five counts are +447, 80, 105, 96 and 80 respectively. + +## Constraints carried forward into execution + +- C# toolchain order is format, lint, type-check, test; a failure or a file-changing step restarts + the loop from formatting. +- CSharpier is invoked through `dotnet tool run` so the manifest-pinned 1.2.6 is used; + `dotnet format` is prohibited. +- Both gate builds use `/t:Rebuild`; `/t:Build` and `/p:Nullable=enable` are prohibited for the + gate builds. +- MSTest + Moq + FluentAssertions are the required test stack; no temporary files, no wall-clock + waits, no external services in tests. +- No `.cs` production, test, or reusable-script file may exceed 500 lines. +- Tone is professional, factual, and neutral in all artifacts. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/issue-updates/issue-791.2026-09-06T15-17.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/issue-updates/issue-791.2026-09-06T15-17.md new file mode 100644 index 000000000..006f43474 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/issue-updates/issue-791.2026-09-06T15-17.md @@ -0,0 +1,73 @@ +# Issue #791 update mirror + +Timestamp: 2026-09-06T15-17 + +Command: manual edit of `docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/issue.md`, appending the Outcome section reproduced verbatim below and adding two Next Step entries. + +EXIT_CODE: 0 + +Output Summary: The local `issue.md` now carries the Outcome section below verbatim, plus two added Next Step checklist entries: `- [x] Implement the fix and record evidence (2026-09-06; see Outcome below)` and `- [ ] Human live-Outlook confirmation per `runbooks/live-outlook-cancel-teardown-verification.runbook.md` (human-interaction exception HI-1; does not gate the automated review)`. The narrative AC copy in `issue.md` is deliberately left unchecked; `spec.md` is the sole authoritative acceptance-criteria source and carries the six check-offs. + +PostedAs: local file update only. This text was NOT posted to GitHub issue #791 by this execution. Posting to the remote issue is the orchestrators step; this artifact is the mirror the evidence conventions require, and it carries the exact text intended for that post. + +--- + +## Exact text appended to issue.md + +## Outcome + +Implemented on 2026-09-06 on branch +`bug/quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791`. + +Both reported defects are fixed and pinned by deterministic MSTest regression tests. + +**Defect 1 — the deadline policy.** The first-batch deadline is now an advisory checkpoint rather +than a return. When it expires with zero acceptances the gate logs the cutoff, the scanned and +accepted counts, the elapsed time and the remaining headroom on both bounds, resets the checkpoint +interval, and keeps scanning. Two hard bounds terminate the extended scan — a cap of 250 candidates +scored without an acceptance, and a 120-second ceiling that bounds the wait while the background +loader is still refilling — and a bounded exit is reported as the new stop reason +`QfcDequeueStop.ScanCapReached`, which callers treat exactly as they treated `DeadlineExpired`: the +UI queue stays open. A launch line now records the cutoff (the reported 900 was never logged), the +requested quantity, the checkpoint interval and both bounds. Both bounds are internal constants with +constructor test seams and introduce no settings surface. + +**Defect 2 — the Cancel teardown.** `ActionCancelAsync` is reordered and made exception-safe: it +logs entry, cancels the token before its first await, marshals to the UI context, resets the +keyboard-active flag, parks WebView2 focus and cancels every breadcrumb selector through a routine +extracted from the `Form.Deactivate` handler, unregisters navigation and form handlers before the +item rows are removed, hides the form, awaits the new `IQfcDatamodel.QuiesceLoaderAsync` before any +datamodel field is nulled, cleans up the groups, and reaches `Cleanup()` — and through it +`RibbonController.ReleaseQuickFiler` — from a `finally`. Every stage runs through a helper that logs +its completion at DEBUG and any escaping exception at ERROR with the stage name, so no stage is +silent and a throwing stage cannot skip a later one. `Worker_DoWork` now captures the loader task so +there is something to await; `TryQueueRemainingMailItemAsync` snapshots and guards `_masterQueue` +and `_moveMonitor` and returns `false` instead of constructing a delegate over a null instance, which +is the exact `ArgumentException` the attached log records; `QfcDatamodel.Cleanup()` is null-guarded +so a second Cancel is inert; and `QfcHomeController.Cleanup()` is two guarded blocks under a +`finally` that also disposes the token source and detaches the worker-completed handler. +`ButtonCancel_Click` no longer rethrows — a deliberate behaviour change, since an `async void` +rethrow becomes an unhandled Outlook UI-thread exception that reports nothing actionable, which the +stage-level ERROR logging replaces. + +**Verification.** 7023 tests passed with 0 failures across the nine first-party test assemblies; +`QuickFiler.Test` alone went from 1339 to 1362 passing with no newly-failing test. The toolchain +passed in the CLAUDE.md order in one uninterrupted final pass: 1587 files formatter-clean, 0 +analyzer warnings and errors, 0 nullable warnings and errors. First-party line coverage moved from +84.50 % to 84.51 % and branch coverage from 79.14 % to 79.19 %; no changed line lost coverage, and +90.8 % of the executable changed lines are covered. + +**Acceptance criteria.** All six are checked off in this feature folder's `spec.md`, which is the +sole authoritative acceptance-criteria source for this work. The two criteria restated in this file +above are a narrative copy and are deliberately left unchecked so there is one place of record. + +**Superseded criteria**, stated deliberately rather than regressed silently: the #424 criterion at +`docs/features/archive/2026-08-06-quickfiler-high-confidence-queue-init-stall-424/spec.md:231` and +the #608 criterion at +`docs/features/active/2026-08-25-quickfiler-high-confidence-partial-screen-backfill-608/spec.md:184` +are both superseded by #791 AC1. #446 AC-6 is preserved: `QfcHomeController.Iteration.cs` is +unmodified and `CompleteAddingAsync` remains reachable only under `SourceExhausted`. + +**Still open.** The live-Outlook confirmation is a human follow-up (HI-1) and does not gate the +automated review. Issue #792 tracks the breadcrumb WebView2 initialization failure (0x8007139F), +which is out of scope here. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p2-t16-file-size-interim.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p2-t16-file-size-interim.md new file mode 100644 index 000000000..a00757ca9 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p2-t16-file-size-interim.md @@ -0,0 +1,70 @@ +# [P2-T16] Interim file-size audit (pre-format) + +Timestamp: 2026-09-06T15-00 + +Command: `foreach ($p in ) { (Get-Content -LiteralPath $p).Count }` + +EXIT_CODE: 0 + +CEILING: 500 (applies to *.cs only) + +This measurement is taken before the final CSharpier format. CSharpier can change line counts, so +[P3-T9] re-measures the same set afterwards and is the audit that decides the ceiling. + +## Production `.cs` (seven Write Set paths) + +| Path | Baseline ([P0-T13]) | Now | Headroom | +|---|---|---|---| +| `QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs` | 262 | 374 | 126 | +| `QuickFiler/Interfaces/IQfcDatamodel.cs` | 133 | 168 | 332 | +| `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` | 298 | 411 | 89 | +| `QuickFiler/Controllers/QfcDatamodel.cs` | 480 | 483 | 17 | +| `QuickFiler/Controllers/QfcFormController.EventHandlers.cs` | 408 | 490 | 10 | +| `QuickFiler/Controllers/QfcFormController.Deactivate.cs` | 60 | 73 | 427 | +| `QuickFiler/Controllers/QfcHomeController.cs` | 469 | 496 | 4 | + +## Test `.cs` (five modified, four created) + +| Path | Baseline ([P0-T13]) | Now | Headroom | +|---|---|---|---| +| `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs` | 477 | 487 | 13 | +| `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs` | 465 | 498 | 2 | +| `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs` | 280 | 287 | 213 | +| `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs` | 0 (new) | 341 | 159 | +| `QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs` | 0 (new) | 391 | 109 | +| `QuickFiler.Test/Controllers/QfcHomeControllerCleanupTests.cs` | 0 (new) | 118 | 382 | +| `QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs` | 0 (new) | 230 | 270 | +| `QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs` | 413 | 418 | 82 | +| `QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs` | 477 | 497 | 3 | + +MAX-CS-LINE-COUNT: 498 (`QfcStreamingDequeueConfidenceGateTests.Part2.cs`) +ALL-CS-AT-OR-BELOW-500: YES + +## PROJECT-FILE (exempt) + +PROJECT-FILE (exempt): QuickFiler.Test/QuickFiler.Test.csproj = 528 (baseline 524, +4) + +Recorded but not asserted against the ceiling, for the reason [P0-T13] states under the same +heading: `.claude/rules/general-code-change.md` caps production code, test code and reusable script +files, and `.csharpierignore` lines 9-14 record project files as owned by Visual Studio and not C# +source. The +4 is exactly the four `` entries [P1-T7] added. + +## Files within ten lines of the ceiling + +Four `.cs` files are inside the ten-line margin and are named explicitly, in ascending headroom: + +1. `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs` — 498, headroom 2. +2. `QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs` — 497, headroom 3. +3. `QuickFiler/Controllers/QfcHomeController.cs` — 496, headroom 4. +4. `QuickFiler/Controllers/QfcFormController.EventHandlers.cs` — 490, headroom 10. + +The two production files in that list were both first written over the ceiling — `EventHandlers.cs` +at 521 and `QfcHomeController.cs` at 505 — and were brought back inside it by condensing added XML +documentation and comments only. No assertion, log line, guard, stage or ordering was removed to fit. +`QfcHomeController.cs` at four lines of headroom is the D11 constraint reaching its measured limit: +D11 predicted the three-guarded-block form would measure about 505 lines, and the two-block form +this plan uses measured 505 before its documentation was condensed. + +`QuickFiler/Controllers/QfcDatamodel.cs` fell from a first draft above its baseline back to 483 +because [P2-T5] relocated `TryQueueRemainingMailItemAsync` out of it into the QueueProcessing +partial, which is a net removal from the tightest production file in the baseline set. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t1-csharpier-format.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t1-csharpier-format.md new file mode 100644 index 000000000..499fefc86 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t1-csharpier-format.md @@ -0,0 +1,67 @@ +# [P3-T1] CSharpier format + +Timestamp: 2026-09-06T15-02 + +Command: `dotnet tool run csharpier format .`, with `DOTNET_ROOT` bound to the repository-local +`.dotnet-sdk` directory, and with the tree observed before and after by +`git status --porcelain --untracked-files=all` and `git diff --stat $BaseSha`. + +`$BaseSha` was bound inside the same command block by the R10 line that reads +`BASE-SHA` out of the [P0-T2] artifact. It resolved to +`51b557dfe35702090fec778febfd4049e0e0fed4`. + +`format` rewrites tracked source and still exits 0 after rewriting, so the exit code alone cannot +distinguish a clean run from a repairing one. The distinguishing observation is the pair of +before/after comparisons recorded below. + +## Pass 1 — repairing + +Verbatim printed line: + +``` +Formatted 1587 files in 4731ms. +``` + +EXIT_CODE: 0 +PATH_SETS_IDENTICAL: True +DIFFSTAT_IDENTICAL: False + +The path set was unchanged (43 entries before and after: the formatter created and deleted no file), +but the anchored diffstat moved from `18 files changed, 1705 insertions(+), 161 deletions(-)` to +`18 files changed, 1721 insertions(+), 161 deletions(-)`. A 16-line insertion delta with no change +to the file set is the signature of a repairing run: CSharpier rewrapped code this plan had written +in a shape the pinned 1.2.6 formatter does not produce. + +Because this step changed files, the toolchain loop was restarted from step 1 rather than continued, +as the General Code Change Policy requires. + +## Pass 2 — clean + +Verbatim printed line: + +``` +Formatted 1587 files in 2093ms. +``` + +EXIT_CODE: 0 +PATH_SETS_IDENTICAL: True +DIFFSTAT_IDENTICAL: True + +Both derived comparison lines are `True`, so this invocation rewrote nothing: the path set is +identical (43 entries) and the anchored diffstat is byte-identical +(`18 files changed, 1721 insertions(+), 161 deletions(-)`) before and after. This is the clean pass +that opens the uninterrupted toolchain pass [P3-T6] records. + +## The 1587 figure + +`BASELINE-CSHARPIER-CHECKED-FILES` from [P0-T7] is 1583. The delta of 4 is exactly the four new +`.cs` files this plan creates: `QfcStreamingDequeueConfidenceGateTests.Part4.cs`, +`QfcFormControllerCancelTeardownTests.cs`, `QfcHomeControllerCleanupTests.cs` and +`QfcDatamodelTeardownTests.cs`. [P3-T2] records the same delta from the read-only `check`. + +## Line-count effect of the format + +The pass-1 rewrite changed line counts, which is why the ceiling audit is taken after the format +rather than before it. The measurements [P3-T9] records are taken from the tree as it stands after +pass 2. No `.cs` file in the plan's edited or created set exceeded 500 lines at any point after the +format; the largest is `QfcStreamingDequeueConfidenceGateTests.Part2.cs` at 498. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t10-scope-boundary.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t10-scope-boundary.md new file mode 100644 index 000000000..12b5b4549 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t10-scope-boundary.md @@ -0,0 +1,142 @@ +# [P3-T10] Scope boundary (AC5) + +Timestamp: 2026-09-06T15-12 + +Commands, all in one block with the R10 `$BaseSha` binding, which resolved to +`51b557dfe35702090fec778febfd4049e0e0fed4`: + +``` +git add --intent-to-add -- '*.cs' '*.csproj' +git diff --name-only $BaseSha -- '*.cs' '*.csproj' +git status --porcelain --untracked-files=all -- '*.cs' '*.csproj' +``` + +EXIT_CODE: 0 + +## Why both outputs are listed + +Neither output alone is correct in both states. An anchored `git diff --name-only` enumerates +tracked changes only, so a path this plan creates is invisible to it until it is staged — hence the +`git add --intent-to-add` companion. Conversely, `git status --porcelain` goes empty once the change +is committed. The two are therefore recorded side by side, and they agree exactly here: seventeen +paths in each, the same seventeen. + +## Anchored diff — `git diff --name-only $BaseSha -- '*.cs' '*.csproj'` + +``` +QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs +QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs +QuickFiler.Test/Controllers/QfcHomeControllerCleanupTests.cs +QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs +QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs +QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs +QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs +QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs +QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs +QuickFiler.Test/QuickFiler.Test.csproj +QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs +QuickFiler/Controllers/QfcDatamodel.cs +QuickFiler/Controllers/QfcFormController.Deactivate.cs +QuickFiler/Controllers/QfcFormController.EventHandlers.cs +QuickFiler/Controllers/QfcHomeController.cs +QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs +QuickFiler/Interfaces/IQfcDatamodel.cs +``` + +## Porcelain status — `git status --porcelain --untracked-files=all -- '*.cs' '*.csproj'` + +``` + A QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs + A QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs + A QuickFiler.Test/Controllers/QfcHomeControllerCleanupTests.cs + M QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs + M QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs + M QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs + M QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs + A QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs + M QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs + M QuickFiler.Test/QuickFiler.Test.csproj + M QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs + M QuickFiler/Controllers/QfcDatamodel.cs + M QuickFiler/Controllers/QfcFormController.Deactivate.cs + M QuickFiler/Controllers/QfcFormController.EventHandlers.cs + M QuickFiler/Controllers/QfcHomeController.cs + M QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs + M QuickFiler/Interfaces/IQfcDatamodel.cs +``` + +The four `A` entries are the four new test files, visible to the anchored diff only because of the +`git add --intent-to-add` companion. + +## The set against the Write Set + +CHANGED-SOURCE-PATH-COUNT: 17 + +**Seven Write Set production paths (all present, none missing, none extra):** + +1. `QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs` +2. `QuickFiler/Interfaces/IQfcDatamodel.cs` +3. `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` +4. `QuickFiler/Controllers/QfcDatamodel.cs` +5. `QuickFiler/Controllers/QfcFormController.EventHandlers.cs` +6. `QuickFiler/Controllers/QfcFormController.Deactivate.cs` +7. `QuickFiler/Controllers/QfcHomeController.cs` + +**Four new test paths under `QuickFiler.Test/Controllers`:** + +8. `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs` +9. `QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs` +10. `QuickFiler.Test/Controllers/QfcHomeControllerCleanupTests.cs` +11. `QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs` + +**Five modified test paths under `QuickFiler.Test/Controllers`:** + +12. `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs` +13. `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs` +14. `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs` +15. `QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs` +16. `QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs` + +**One project file:** + +17. `QuickFiler.Test/QuickFiler.Test.csproj` — four `` entries only. + +`QuickFiler/QuickFiler.csproj` is **not** in the set. The Write Set says an entry there is required +only if implementation introduces a new production file, and it introduces none: every production +change is an edit to an existing file, including the relocated +`TryQueueRemainingMailItemAsync` and the new synchronous +`TryCreateRemainingQueueAdmission`, both of which live in an existing partial. + +## The five named exclusions + +None of the five files AC5 names appears in either output: + +| Path named by AC5 | In anchored diff? | In porcelain? | +|---|---|---| +| `QuickFiler/Controllers/QfcCollectionController.cs` | No | No | +| `QuickFiler/Controllers/QfcHomeController.Iteration.cs` | No | No | +| `TaskMaster/Ribbon/RibbonController.cs` | No | No | +| `TaskMaster/Properties/Settings.Designer.cs` | No | No | +| `TaskMaster/AppGlobals/AppQuickFilerSettings.cs` | No | No | + +`QuickFiler/Controllers/QfcHomeController.Iteration.cs` was additionally verified unmodified by +[P2-T3] with its own path-scoped anchored diff and porcelain pair, both of which returned empty. +That is the #446 AC-6 preservation evidence AC6 cites. + +## R7 reading + +AC5 says the branch diff "touches no file outside the Write Set". Read literally over the whole tree +that is unsatisfiable, because this plan is required to write evidence artifacts under +`/evidence/` and to check AC boxes in `spec.md`. R7 therefore evaluates AC5 over the source +pathspec `'*.cs' '*.csproj'` only, which is the footprint the Write Set actually describes. The +narrower evaluation is recorded here and in the AC5 check-off note so a reviewer does not read it as +an unstated relaxation. Outside that pathspec the branch also changes this plan file, this feature +folder's `spec.md` and `issue.md`, and the evidence artifacts under +`/evidence/`, all of which are the plan's own required outputs. + +## Determination + +AC5 holds under the R7 pathspec: the enumerated set contains only the seven Write Set production +paths, the four new and five modified test paths under `QuickFiler.Test/Controllers`, and +`QuickFiler.Test/QuickFiler.Test.csproj`, and none of the five named exclusions appears in either +output. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t13-ac3-test-inventory.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t13-ac3-test-inventory.md new file mode 100644 index 000000000..ea0a9d8e1 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t13-ac3-test-inventory.md @@ -0,0 +1,98 @@ +# [P3-T13] AC3 test inventory + +Timestamp: 2026-09-06T15-14 + +AC3 requires that every regression test named in `spec.md` Test Strategy exists in the file listed +for it and passes, and that fail-before/pass-after evidence is recorded for two named tests. + +Every result below is read from the `TestResults\791-p2-t14` TRX, which is the [P2-T14] run against +the delivered build (`EXIT_CODE: 0`, 76 of 76 passed). Class names are resolved through the TRX +`TestDefinitions` element rather than the bare `testName`, because `testName` is the method name +alone and one name in this inventory collides across three classes. + +## AC1 tests — `spec.md` lines 222-228 + +`spec.md` names these as new tests in +`QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs`. All seven exist in +that file and pass. + +| Test named in Test Strategy | File it now lives in | Result | +|---|---|---| +| `DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance` | `QfcStreamingDequeueConfidenceGateTests.Part4.cs` | Passed | +| `DequeueAsync_ZeroAcceptedAndSourceDrained_ReportsSourceExhausted` | `QfcStreamingDequeueConfidenceGateTests.Part4.cs` | Passed | +| `DequeueAsync_ZeroAcceptedAndCapReached_StopsAndReportsScanCapReached` | `QfcStreamingDequeueConfidenceGateTests.Part4.cs` | Passed | +| `DequeueAsync_ZeroAcceptedAndCeilingReached_StopsWhileSourceStillRefilling` | `QfcStreamingDequeueConfidenceGateTests.Part4.cs` | Passed | +| `DequeueAsync_CheckpointExpiry_LogsCutoffAndCounts` | `QfcStreamingDequeueConfidenceGateTests.Part4.cs` | Passed | +| `DequeueAsync_Launch_LogsCutoffQuantityAndBounds` | `QfcStreamingDequeueConfidenceGateTests.Part4.cs` | Passed | +| `DequeueAsync_NonEmptyPrefix_UnchangedByCheckpoint` | `QfcStreamingDequeueConfidenceGateTests.Part4.cs` | Passed | + +## AC1 retargeting obligations — `spec.md` lines 230-234 + +| Obligation named in Test Strategy | Outcome | File | Result | +|---|---|---|---| +| `...Part3.cs` lines 174-208, `DequeueAsync_DeadlineExpiresWithZeroAccepted_ReportsDeadlineExpiredStop` | retargeted to `DequeueAsync_ZeroAcceptedAndCapReached_ReportsScanCapReachedStop` | `QfcStreamingDequeueConfidenceGateTests.Part3.cs` | Passed | +| `QfcQueuePurePathsTests.cs` lines 201-260, `DequeueNextItemGroupWithOutcomeAsync_DeadlineExpiredGate_ReportsDeadlineExpiredStop` | retargeted to `DequeueNextItemGroupWithOutcomeAsync_ZeroAcceptanceCeilingGate_ReportsScanCapReachedStop` | `QfcQueuePurePathsTests.cs` | Passed | +| `...GateTests.cs` lines 27-92, the fail-closed reflection helper updated for the two new optional parameters | `CreateGate` now looks up an eleven-type constructor and keeps its `constructor.Should().NotBeNull(...)` guard | `QfcStreamingDequeueConfidenceGateTests.cs` | exercised by every gate test in the run | +| `QfcHomeControllerIterationTests.cs` gains a sibling pin that `ScanCapReached` also leaves the queue open | `IterateQueueAsync_EmptyBatchWithScanCapReached_DoesNotCompleteAdding` | `QfcHomeControllerIterationTests.cs` | Passed | + +The three further retargets D2 identified beyond these four — the four in `...Part2.cs` and the +`DequeueAsync_ProgressCallback_StopsReportingOnceTheMethodReturns` rebase in `...Part3.cs` — are +also all green and are enumerated in `evidence/regression-testing/p2-t14-pass-after.md`. +`DequeueAsync_ProgressCallback_StopsReportingOnceTheMethodReturns` is recorded here explicitly: +`QfcStreamingDequeueConfidenceGateTests.Part3.cs`, Passed. + +## AC2 tests — `spec.md` lines 236-239 + +| Test named in Test Strategy | File named by Test Strategy | File it now lives in | Result | +|---|---|---|---| +| `ActionCancelAsync_ResetsKbdActive_WhenKeyboardDialogActive` | `QfcFormControllerCancelTeardownTests.cs` | same | Passed | +| `ActionCancelAsync_DoesNotToggle_WhenInactive` | `QfcFormControllerCancelTeardownTests.cs` | same | Passed | +| `ActionCancelAsync_ParksFocusAndCancelsBreadcrumbSelectors` | `QfcFormControllerCancelTeardownTests.cs` | same | Passed | +| `ActionCancelAsync_UnregistersHandlersBeforeGroupsCleanup` | `QfcFormControllerCancelTeardownTests.cs` | same | Passed | +| `ActionCancelAsync_AwaitsLoaderQuiesceBeforeGroupsCleanup` | `QfcFormControllerCancelTeardownTests.cs` | same | Passed | +| `ActionCancelAsync_GroupsCleanupThrows_StillInvokesParentCleanup` | `QfcFormControllerCancelTeardownTests.cs` | same | Passed | +| `ButtonCancel_Click_ActionThrows_DoesNotRethrow` | `QfcFormControllerCancelTeardownTests.cs` | same | Passed | +| `Cleanup_DatamodelCleanupThrows_StillInvokesParentCleanup` | `QfcHomeControllerCleanupTests.cs` | same | Passed | +| `Cleanup_DisposesTokenSourceAndDetachesWorkerCompleted` | `QfcHomeControllerCleanupTests.cs` | same | Passed | +| `TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing` | `QfcDatamodelTeardownTests.cs` | same | Passed | +| `QuiesceLoaderAsync_LoaderCompletes_ReturnsBeforeTimeout` | `QfcDatamodelTeardownTests.cs` | same | Passed | +| `QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs` | `QfcDatamodelTeardownTests.cs` | same | Passed | +| `Cleanup_CalledTwice_DoesNotThrow` | `QfcDatamodelTeardownTests.cs` | same | Passed | + +`Cleanup_CalledTwice_DoesNotThrow` is a method name shared by three test classes in this assembly: +`QfcDatamodelTeardownTests`, `EfcItemControllerCleanupTests` and `QfcFormControllerCleanupTests`. +The row above is resolved through the TRX `TestDefinitions` `className` and refers to +`QuickFiler.Controllers.Tests.QfcDatamodelTeardownTests.Cleanup_CalledTwice_DoesNotThrow`, which is +the one AC3 names. The other two are pre-existing tests in other classes and both also passed in the +[P2-T15] whole-assembly run. + +Two tests exist in `QfcFormControllerCancelTeardownTests.cs` beyond the seven Test Strategy names — +`ActionCancelAsync_CalledTwice_InvokesParentCleanupOnce` (the D6 repeat-invocation capture pin) — and +one in `QfcDatamodelTeardownTests.cs` — `Worker_DoWork_CapturesRemainingLoadTask` (the loader-task +capture pin). Both passed. They are additions beyond AC3's requirement, not substitutes for it. + +"Not proposed: any test of `RibbonController.ReleaseQuickFiler`" is honoured: no such test exists. +The guarantee is asserted at the `ParentCleanup` boundary by +`ActionCancelAsync_GroupsCleanupThrows_StillInvokesParentCleanup` and +`Cleanup_DatamodelCleanupThrows_StillInvokesParentCleanup`. + +TEST-STRATEGY-NAMES-TOTAL: 26 (7 AC1 new + 4 AC1 retargeting obligations + 1 further retarget recorded explicitly + 13 AC2 named + 1 explicitly not proposed) +NAMES-MAPPED-TO-AN-EXISTING-FILE: 26 +NAMES-WITH-A-PASSING-RESULT: 25 (the 26th is the deliberately-not-proposed RibbonController test) + +## Required fail-before / pass-after evidence + +AC3 requires this pair for two named tests. Both are recorded under this feature folder's +`evidence/regression-testing/` directory: + +| Test | Fail-before | Pass-after | +|---|---|---| +| `DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance` | `evidence/regression-testing/p1-t16-gate-fail-before.md` — `ExpectedExitCode: 1`, `EXIT_CODE: 1`, entry 1 of 12, `Expected batch.Accepted to contain a single item ... but the collection is empty.` | `evidence/regression-testing/p2-t14-pass-after.md` — `PASS-AFTER` line present | +| `TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing` | `evidence/regression-testing/p1-t19-datamodel-teardown-fail-before.md` — `ExpectedExitCode: 1`, `EXIT_CODE: 1`, entry 1 of 5, `System.ArgumentException: Delegate to an instance method cannot have null 'this'` (the exact message from the field log) | `evidence/regression-testing/p2-t14-pass-after.md` — `PASS-AFTER` line present | + +FAIL-BEFORE-PASS-AFTER-EVIDENCE-COMPLETE: YES + +## Determination + +Every test name `spec.md` Test Strategy states maps to an existing file and to a passing result in +the [P2-T14] run, and the two required fail-before/pass-after pairs are recorded. AC3 holds. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t19-ac-status-summary.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t19-ac-status-summary.md new file mode 100644 index 000000000..2dbe0971c --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t19-ac-status-summary.md @@ -0,0 +1,51 @@ +# [P3-T19] AC Status Summary + +Timestamp: 2026-09-06T15-18 + +AC source: `docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/spec.md`, "Acceptance Criteria". +It is the sole authoritative acceptance-criteria source for this work; `user-story.md` is narrative +context only and `issue.md` carries a narrative copy of AC1 and AC2 that is deliberately left +unchecked so there is one place of record. + +AC-TOTAL: 6 +AC-CHECKED: 6 +AC-REMAINING: 0 + +## Rows + +Every checkbox state below was read back from `spec.md` after the check-off tasks ran, at the line +number given. + +| AC | `spec.md` line | State in `spec.md` | Checked off by | Justifying artifacts (all exist) | +|---|---|---|---|---| +| AC1 | 255 | `- [x]` | [P3-T11] | `evidence/regression-testing/p1-t16-gate-fail-before.md`; `evidence/regression-testing/p2-t14-pass-after.md` | +| AC2 | 257 | `- [x]` | [P3-T12] | `evidence/regression-testing/p1-t17-cancel-teardown-fail-before.md`; `evidence/regression-testing/p1-t18-home-cleanup-fail-before.md`; `evidence/regression-testing/p1-t19-datamodel-teardown-fail-before.md`; `evidence/regression-testing/p2-t14-pass-after.md` | +| AC3 | 260 | `- [x]` | [P3-T13] | `evidence/qa-gates/p3-t13-ac3-test-inventory.md` | +| AC4 | 262 | `- [x]` | [P3-T14] | `evidence/qa-gates/p3-t5-tests-coverage.md`; `evidence/qa-gates/p3-t6-loop-closure.md`; `evidence/qa-gates/p3-t7-changed-line-coverage.md`; `evidence/qa-gates/p3-t8-coverage-delta.md` | +| AC5 | 266 | `- [x]` | [P3-T15] | `evidence/qa-gates/p3-t10-scope-boundary.md` | +| AC6 | 269 | `- [x]` | [P3-T16] | `evidence/qa-gates/p3-t10-scope-boundary.md`; `evidence/regression-testing/p2-t14-pass-after.md` | + +Six rows are present, each names at least one existing artifact path, and every row's checkbox state +matches the corresponding line in `spec.md`. + +## Outstanding human follow-up (does not gate the automated review) + +AC2 records human-interaction exception **HI-1**: the live-Outlook confirmation — keyboard usable +after Cancel, the new Cancel-stage log lines present, and no +`Delegate to an instance method cannot have null 'this'` loader error following a Cancel — is +performed by a human per +`docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/runbooks/live-outlook-cancel-teardown-verification.runbook.md` +and is recorded afterwards at `evidence/other/manual-verification.yyyy-MM-ddTHH-mm.md`. It is +outstanding at the time of this summary. AC2 states explicitly that it does not gate the automated +review, so its being outstanding does not qualify the AC2 check-off. + +## Deviations recorded + +Four deviations from the spec's own prose are recorded by name under `spec.md` +"Rollout & Follow-up" -> "Outcome" by [P3-T17]: the `ActionCancelAsync` trigger discriminator being a +call-site log rather than a parameter; `QfcDatamodel.QuiesceDebugLog` being an added internal test +seam; the retargeting surface being seven tests rather than the four Test Strategy names; and the +coverage run using `dotnet-coverage collect --output-format cobertura` rather than +`vstest /EnableCodeCoverage`. A fifth, smaller divergence — which line of the two +`QuiesceLoaderAsync` tests reports first at the end of Phase 1 — is recorded in +`evidence/regression-testing/p1-t19-datamodel-teardown-fail-before.md`. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t2-csharpier-check.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t2-csharpier-check.md new file mode 100644 index 000000000..5d96eed62 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t2-csharpier-check.md @@ -0,0 +1,33 @@ +# [P3-T2] CSharpier check (read-only) + +Timestamp: 2026-09-06T15-03 + +Command: `dotnet tool run csharpier check .`, with `DOTNET_ROOT` bound to the repository-local +`.dotnet-sdk` directory. + +EXIT_CODE: 0 + +Verbatim printed line: + +``` +Checked 1587 files in 4198ms. +``` + +FINAL-CSHARPIER-CHECKED-FILES: 1587 +BASELINE-CSHARPIER-CHECKED-FILES: 1583 (from [P0-T7]) +DELTA: 4 + +The exit code is the gate here, because `check` is read-only and returns non-zero on drift. Exit 0 +with the single success line and no drifting-path list means the whole tree agrees with the +manifest-pinned CSharpier 1.2.6, which is the version `.github/workflows/_format-check.yml` runs +after `dotnet tool restore`. + +The delta of 4 is the expected observation stated by the task: this plan creates exactly four new +`.cs` files — `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs`, +`QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs`, +`QuickFiler.Test/Controllers/QfcHomeControllerCleanupTests.cs` and +`QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs`. No file was added to or removed from the +formatter's scope by any other route. + +This is step 1 of the uninterrupted toolchain pass; steps 2 through 4 are [P3-T3], [P3-T4] and +[P3-T5], and [P3-T6] records the closure. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t3-msbuild-analyzers.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t3-msbuild-analyzers.md new file mode 100644 index 000000000..24f219446 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t3-msbuild-analyzers.md @@ -0,0 +1,38 @@ +# [P3-T3] Analyzer gate + +Timestamp: 2026-09-06T15-04 + +Command: `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` + +EXIT_CODE: 0 + +FINAL-ANALYZER-WARNINGS: 0 +FINAL-ANALYZER-ERRORS: 0 + +Output Summary: + +``` +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:14.01 +``` + +## Comparison against the [P0-T8] baseline + +| Measure | Baseline [P0-T8] | This run | Delta | +|---|---|---|---| +| Warnings | 0 | 0 | 0 | +| Errors | 0 | 0 | 0 | + +The error count is 0, which is this task's acceptance, and no warning was introduced by the change. +The five-package analyzer stack (Meziantou, SonarAnalyzer.CSharp, Roslynator, AsyncFixer, +BannedApiAnalyzers) reported nothing against the new gate loop, the new logging helpers, the +relocated admission guard, the extracted deactivate routine, the teardown stage helpers or either +rewritten `Cleanup()`. + +`/t:Rebuild` is used rather than `/t:Build`: analyzer diagnostics are produced during compilation, +and MSBuild's incremental up-to-date check does not invalidate on a command-line `/p:` change, so a +warm `/t:Build` would return exit 0 with `CoreCompile` skipped on every project and run no +analyzers. This is step 2 of the uninterrupted toolchain pass. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t4-msbuild-nullable.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t4-msbuild-nullable.md new file mode 100644 index 000000000..6c974b179 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t4-msbuild-nullable.md @@ -0,0 +1,52 @@ +# [P3-T4] Nullable gate + +Timestamp: 2026-09-06T15-05 + +Command: `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` + +EXIT_CODE: 0 + +FINAL-NULLABLE-WARNINGS: 0 +FINAL-NULLABLE-ERRORS: 0 + +Output Summary: + +``` +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:13.33 +``` + +## Comparison against the [P0-T9] baseline + +| Measure | Baseline [P0-T9] | This run | Delta | +|---|---|---|---| +| Warnings | 0 | 0 | 0 | +| Errors | 0 | 0 | 0 | + +The error count is 0, which is this task's acceptance. + +## Command form + +The command is character-for-character the CLAUDE.md nullable gate and the command in +`.github/workflows/_build-nullable.yml`. Two properties were preserved rather than "restored": + +- `/p:Nullable=enable` was **not** added. No project in this repository carries a `` + element and there is no `Directory.Build.props`, so the property is a solution-wide opt-in that + conscripts every file which has never adopted the `#nullable enable` pragma. Adding it would + produce hundreds of errors unrelated to this change, and CI omits it deliberately. +- `/t:Build` was **not** substituted. MSBuild's up-to-date check does not invalidate on a + command-line `/p:` change, so a warm `/t:Build` would return exit 0 having skipped `CoreCompile` + on every project, and the gate could not fail. + +## Relevance to this change + +`/p:TreatWarningsAsErrors=true` is what makes D9 load-bearing: an unread `private readonly` field +raises CS0414, a warning, which this command would promote to an error. The two new gate bounds are +therefore internal get-only auto-properties, whose compiler-generated backing fields are read by +their getters. This run's zero-warning result confirms that choice across the whole change, not just +at the point [P1-T4] first observed it. + +This is step 3 of the uninterrupted toolchain pass. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t5-tests-coverage.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t5-tests-coverage.md new file mode 100644 index 000000000..89139a006 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t5-tests-coverage.md @@ -0,0 +1,94 @@ +# [P3-T5] Final test run with coverage + +Timestamp: 2026-09-06T15-06 + +Command: + +``` +dotnet-coverage collect --output artifacts\csharp\coverage.xml --output-format cobertura --settings coverage\791-effective-coverage.config -- $vstest '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p3-t5' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests' +``` + +`$vstest` was re-bound inside this command block by the two R10 resolution lines; the resolved value +reduced per R3 is `\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe`. +The nine assemblies, the runsettings, the isolation and blame switches, the filter, the collector and +the settings file are all identical to [P0-T11], so the two sides of the [P3-T8] comparison are +produced by one collector, one configuration, one selection and one filter (D13, D14). + +EXIT_CODE: 0 + +FINAL-TOTAL-TESTS: 7023 +FINAL-FAILED-TESTS: 0 + +Output Summary: `A total of 9 test files matched the specified pattern.` then +`Test Run Successful. Total tests: 7023, Passed: 7023, Total time: 48.6519 Seconds.` and +`Code coverage results: artifacts\csharp\coverage.xml.` + +`artifacts/csharp/coverage.xml` exists on disk (verified by `Test-Path`, which returned `True`), +which is AC4's substantive requirement. `artifacts/` is git-ignored at `.gitignore` line 57, so the +document is a local tool output rather than committed evidence, and the acceptance is on-disk +existence and the recorded counters rather than `git ls-files`. +`.claude/hooks/enforce-evidence-locations.ps1` lines 22-26 name `artifacts/csharp/` as an explicitly +permitted path and it is absent from the forbidden prefix list at lines 64-74. + +## Test-count comparison against [P0-T11] + +| Measure | Baseline [P0-T11] | This run | Delta | +|---|---|---|---| +| Total | 7000 | 7023 | +23 | +| Passed | 7000 | 7023 | +23 | +| Failed | 0 | 0 | 0 | + +The +23 is exactly the tests this plan added, all in `QuickFiler.Test`, and matches the +23 [P2-T15] +observed on that assembly alone. + +## Aggregated first-party counters + +Aggregated from `artifacts/csharp/coverage.xml` by the same pinned all-descendant `.//line` +selection over the same nine first-party package names [P0-T11] used: + +```powershell +$CoberturaPath = 'artifacts\csharp\coverage.xml' +$doc = New-Object System.Xml.XmlDocument +$doc.Load((Resolve-Path -LiteralPath $CoberturaPath).Path) +$firstParty = @('Tags','ToDoModel','TaskVisualization','UtilitiesCS','QuickFiler','TaskTree','TaskMaster','SVGControl','VBFunctions') +$lc = 0; $lv = 0; $bc = 0; $bv = 0 +foreach ($pkg in $doc.SelectNodes('/coverage/packages/package')) { + if ($firstParty -notcontains $pkg.GetAttribute('name')) { continue } + foreach ($ln in $pkg.SelectNodes('.//line')) { + $lv++ + $h = $ln.GetAttribute('hits') + if ($h -and [int]$h -gt 0) { $lc++ } + $cc = $ln.GetAttribute('condition-coverage') + if ($cc -and $cc -match '\((\d+)/(\d+)\)') { $bc += [int]$Matches[1]; $bv += [int]$Matches[2] } + } +} +"LINES_COVERED=$lc LINES_VALID=$lv BRANCHES_COVERED=$bc BRANCHES_VALID=$bv" +``` + +printed, verbatim: + +```text +LINES_COVERED=112551 LINES_VALID=133187 BRANCHES_COVERED=26584 BRANCHES_VALID=33568 +``` + +FINAL-LINES-COVERED: 112551 +FINAL-LINES-VALID: 133187 +FINAL-BRANCHES-COVERED: 26584 +FINAL-BRANCHES-VALID: 33568 +FINAL-LINE-PERCENT: 84.51 +FINAL-BRANCH-PERCENT: 79.19 + +All four `FINAL-` counter lines are numeric. [P3-T8] performs the comparison against the [P0-T11] +baseline counters, including the `lines-valid` comparability precondition. + +## Collector substitution (D13) + +AC4's toolchain step 4 names `vstest.console.exe /EnableCodeCoverage`. +`/EnableCodeCoverage` writes a binary `.coverage` file, not the Cobertura XML AC4 also requires at +`artifacts/csharp/coverage.xml`, and the two collectors conflict when combined. The run therefore +uses `dotnet-coverage collect --output-format cobertura -- ...`, wrapping the same +`vstest.console.exe` with the same assemblies and switches. The substantive requirement — a +Cobertura document at that path, produced by running the full suite — is met. This substitution is +recorded as a deviation by [P3-T17] and cited in the AC4 check-off. + +This is step 4 of the uninterrupted toolchain pass; [P3-T6] records the closure. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t6-loop-closure.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t6-loop-closure.md new file mode 100644 index 000000000..b54178160 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t6-loop-closure.md @@ -0,0 +1,50 @@ +# [P3-T6] Toolchain loop closure + +Timestamp: 2026-09-06T15-07 + +The CLAUDE.md toolchain is format, lint, type-check, test, run in that exact order, restarting from +step 1 if any step fails or auto-fixes anything. This artifact records the ordered pass and states +explicitly whether any step failed or rewrote a file. + +## The restart + +The first execution of [P3-T1] **rewrote files**. `dotnet tool run csharpier format .` exited 0 +after rewriting, which is why the task's acceptance is the pair of before/after tree observations +rather than the exit code: `PATH_SETS_IDENTICAL: True` but `DIFFSTAT_IDENTICAL: False`, the anchored +diffstat moving from 1705 to 1721 insertions with the file set unchanged. + +Per the restart rule the loop was **restarted from step 1** rather than continued. Steps 2 through 4 +were not run against the repairing pass. + +## The uninterrupted pass, in order + +| # | Step | Task | Command | Artifact | EXIT_CODE | Changed files? | +|---|---|---|---|---|---|---| +| 1 | Format | [P3-T1] pass 2 | `dotnet tool run csharpier format .` | `evidence/qa-gates/p3-t1-csharpier-format.md` | 0 | No — `PATH_SETS_IDENTICAL: True` and `DIFFSTAT_IDENTICAL: True` | +| 1b | Format verify | [P3-T2] | `dotnet tool run csharpier check .` | `evidence/qa-gates/p3-t2-csharpier-check.md` | 0 | No — read-only; `Checked 1587 files` | +| 2 | Lint | [P3-T3] | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` | `evidence/qa-gates/p3-t3-msbuild-analyzers.md` | 0 | No — 0 warnings, 0 errors | +| 3 | Type-check | [P3-T4] | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` | `evidence/qa-gates/p3-t4-msbuild-nullable.md` | 0 | No — 0 warnings, 0 errors | +| 4 | Test | [P3-T5] | `dotnet-coverage collect --output artifacts\csharp\coverage.xml --output-format cobertura --settings coverage\791-effective-coverage.config -- $vstest ...` | `evidence/qa-gates/p3-t5-tests-coverage.md` | 0 | No — 7023 passed, 0 failed | + +LOOP-RESTARTS: 1 (caused by the [P3-T1] pass-1 format rewriting files) +FINAL-PASS-STEPS: 5 +FINAL-PASS-ALL-GREEN: YES +FINAL-PASS-ANY-FILE-REWRITTEN: NO + +## Determination + +All five steps completed with exit code 0 in one uninterrupted pass, and no step in that pass +failed or rewrote a file. The restart that preceded it is recorded above rather than elided, and the +subsequent clean pass is the one recorded in the table. + +Ordering was preserved exactly: no gate build was run before the tree was formatter-clean, and the +test run was performed against the assemblies produced by the two gate `/t:Rebuild` builds, so the +coverage document and the test result describe the same formatted, analyzer-clean, +nullable-clean tree. + +Two earlier restarts occurred inside Phases 1 and 2 rather than in this loop, and are recorded in +their own artifacts rather than here, because they are iterative build failures and not toolchain-gate +steps: the [P1-T15] build failed once on two compile errors, and the [P2-T13] build failed once on +two compile errors. In both cases the failing command was re-run from the start after repair. The +[P2-T15] suite run also failed once on one newly-failing architecture pin, was repaired, and was +re-run from the start. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t7-changed-line-coverage.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t7-changed-line-coverage.md new file mode 100644 index 000000000..0bb5812d0 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t7-changed-line-coverage.md @@ -0,0 +1,138 @@ +# [P3-T7] Changed-line coverage on the production Write Set + +Timestamp: 2026-09-06T15-09 + +Command: the anchored `git diff --unified=0 $BaseSha -- ` in the task's command block, +preceded in the same block by the R10 `$BaseSha` binding (resolved to +`51b557dfe35702090fec778febfd4049e0e0fed4`), a `git add --intent-to-add -- '*.cs'` companion and a +`git status --porcelain --untracked-files=all` companion over +`QuickFiler/Controllers` and `QuickFiler/Interfaces`. The porcelain output listed the seven Write +Set production paths as ` M` and nothing else. + +EXIT_CODE: 0 + +## Method + +For each measurable path, changed line numbers are the added lines of every hunk. `hits` is read +from `artifacts/csharp/coverage.xml` ([P3-T5]) through a **de-duplicated per-line map** that merges +`./lines/line` with `./methods/method/lines/line` for every `class` element whose `filename` ends +with a directory separator followed by the file name, keyed by line number and resolved by maximum +`hits`. The merge is required because a Cobertura document produced by this collector emits the same +source line under both branches, and an async method's state machine emits lines under `./lines` +that have no `` parent. + +Where a hunk's added and removed line counts are **equal**, new line `c+i` maps to old line `a+i` +and the baseline `hits` is read from `coverage\791-baseline.cobertura.xml` ([P0-T11]) through the +same merged map. Where the counts are unequal no one-to-one mapping exists, so the line is recorded +`baseline=none` and excluded from the regression count rather than being attributed borrowed +coverage. + +A changed line carrying no `line` element in either branch of the merged map is non-executable — +an XML doc comment, a blank line, a `using` directive, a brace, an enum member or an interface +method declaration — and is recorded `hits=non-executable`, excluded from both the `hits = 0` count +and the regression count. + +## Scope: the [P0-T12] determination is authoritative + +[P0-T12] reported five `MEASURABLE:` paths and two `UNMEASURABLE:` paths. That set is identical to +the five paths this task's command block enumerates, so there is no divergence to record. + +### UNMEASURABLE paths + +CHANGED-LINE-COVERAGE: NOT MEASURABLE — `QuickFiler/Controllers/QfcDatamodel.cs` +CHANGED-LINE-COVERAGE: NOT MEASURABLE — `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` + +Citation (D1): `QuickFiler/Controllers/QfcDatamodel.cs` line 25 carries `[ExcludeFromCodeCoverage]` +on the partial class declaration. The attribute applies to the whole type, so members declared in +`QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs`, which declares +`public partial class QfcDatamodel` at line 12, are excluded too. +`QuickFiler/Controllers/QfcScanProgressBandMapper.cs` line 12 records the same fact in prose. +[P0-T12] confirmed the consequence empirically: both files produce **zero** `class` elements in the +baseline Cobertura document. Changed-line coverage for them is structurally unmeasurable, not merely +low. + +Substitute evidence — the passing tests that exercise those changed lines, all recorded +`PASS-AFTER` by [P2-T14]: + +| Changed member | Exercising test | +|---|---| +| `QuiesceLoaderAsync` completion path | `QfcDatamodelTeardownTests.QuiesceLoaderAsync_LoaderCompletes_ReturnsBeforeTimeout` | +| `QuiesceLoaderAsync` bound path and `LogQuiesceOutcome` | `QfcDatamodelTeardownTests.QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs` | +| `TryQueueRemainingMailItemAsync` and `TryCreateRemainingQueueAdmission` refusal | `QfcDatamodelTeardownTests.TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing` | +| `TryQueueRemainingMailItemAsync` admission (non-refusal) | `QfcDatamodelTests.TryQueueRemainingMailItemAsync_HighConfidenceEnabled_AddsBelowThresholdCandidate` (green in [P2-T15]) | +| `Worker_DoWork` loader-task capture | `QfcDatamodelTeardownTests.Worker_DoWork_CapturesRemainingLoadTask` | +| `Cleanup()` null guards | `QfcDatamodelTeardownTests.Cleanup_CalledTwice_DoesNotThrow` | +| `_remainingLoadActive` liveness, unchanged | `QfcDatamodelLivenessTests.DequeueNextItemGroupAsync_WhileLoaderStillProducing_KeepsPollingAfterWorkerIdle` (green in [P2-T15]) | + +## Measurable paths — per-file result + +| Path | Changed lines | Non-executable | Executable | `hits = 0` | Baseline-mapped | Regressions | +|---|---|---|---|---|---|---| +| `QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs` | 121 | 77 | 44 | 0 | 5 | 0 | +| `QuickFiler/Interfaces/IQfcDatamodel.cs` | 36 | 36 | 0 | 0 | 0 | 0 | +| `QuickFiler/Controllers/QfcFormController.EventHandlers.cs` | 88 | 31 | 57 | 8 | 0 | 0 | +| `QuickFiler/Controllers/QfcFormController.Deactivate.cs` | 16 | 15 | 1 | 0 | 2 | 0 | +| `QuickFiler/Controllers/QfcHomeController.cs` | 33 | 4 | 29 | 4 | 0 | 0 | +| **Total** | **294** | **163** | **131** | **12** | **7** | **0** | + +CHANGED-LINES-TOTAL: 294 +CHANGED-LINES-NON-EXECUTABLE: 163 +CHANGED-LINES-EXECUTABLE: 131 +CHANGED-EXECUTABLE-LINES-WITH-ZERO-HITS: 12 +CHANGED-LINES-WITH-COVERAGE-REGRESSION: 0 + +Every changed production line in the measurable set is recorded with either a post-change `hits` +value or the `hits=non-executable` marker. The `hits = 0` count is stated over executable lines only. + +## `QuickFiler/Interfaces/IQfcDatamodel.cs` — every changed line is non-executable + +All 36 changed lines in this file are non-executable, which is the case [P3-T7] predicts explicitly. +[P1-T1] adds only the `ScanCapReached` enum member inside `QfcDequeueStop`, the +`QuiesceLoaderAsync` declaration on the interface, and XML documentation including the rewritten +`DeadlineExpired` doc. None of those emits IL. The file still reports a `class` element — the +`QfcDequeueBatch` struct does — which is why [P0-T12] reports it `MEASURABLE:` at file level; the +per-line marker resolves the question one level lower and the two determinations do not contradict. + +## The 12 zero-hit executable changed lines, named + +None is a regression: each is a line with no baseline counterpart, so `hits = 0` here means "new +code not reached by a test", not "coverage lost". + +**`QuickFiler/Controllers/QfcFormController.EventHandlers.cs` (8 lines)** + +- 139-141 — the `{ await uiContext; }` block inside `ActionCancelAsync`. Reached only when + `_formViewer?.UiSyncContext` is non-null. Every test in + `QfcFormControllerCancelTeardownTests` drives a `Mock` whose `UiSyncContext` + resolves to null, because a real `SynchronizationContext` on the viewer would require a WinForms + message loop, which the headless-test policy forbids. The guard around it is covered; the marshal + itself is host-bound. +- 160-163 — the `catch (System.Exception e)` around the awaited quiesce. Reached only when + `IQfcDatamodel.QuiesceLoaderAsync` returns a **faulted** task. The interface contract states it + never throws for the timeout case, and `QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs` + pins that, so this catch is defence against a future contract violation rather than a reachable + path from any current implementation. +- 289 — the completion-path `log.Debug` line [P2-T11] adds inside `MoveAndIterate`. That branch is + reached only after a real `BackGroundMoveAsync` over live Outlook items, which the headless test + suite does not drive; the surrounding `MoveAndIterate` else-branch is uncovered at baseline for + the same reason. + +**`QuickFiler/Controllers/QfcHomeController.cs` (4 lines)** + +- 382-385 — the `catch (System.Exception e)` around the worker-completed detach. Reached only when + detaching a `BackgroundWorker` event handler throws. + `Cleanup_DisposesTokenSourceAndDetachesWorkerCompleted` covers the success path of that block; a + throwing `-=` on a real `BackgroundWorker` has no seam to inject. + +## Regression determination + +CHANGED-LINES-WITH-COVERAGE-REGRESSION: 0 + +Seven changed lines had an equal-count hunk and therefore a one-to-one baseline mapping (five in the +gate, two in the deactivate partial). For none of them is the post-change `hits` lower than the +baseline `hits`. The remaining 287 changed lines sit in unequal-count hunks — overwhelmingly pure +insertions — and are recorded `baseline=none` and excluded from the regression count, because +attributing a baseline `hits` value across an unequal hunk would be borrowed coverage rather than a +measurement. + +The count of changed lines whose post-change `hits` is lower than their baseline `hits` is **0**, +which is this task's acceptance. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t8-coverage-delta.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t8-coverage-delta.md new file mode 100644 index 000000000..98d77558b --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t8-coverage-delta.md @@ -0,0 +1,78 @@ +# [P3-T8] Coverage delta, baseline versus post-change + +Timestamp: 2026-09-06T15-10 + +Both sides are produced by one collector (`dotnet-coverage collect --output-format cobertura`), one +settings file (`coverage\791-effective-coverage.config`), one test-assembly list (the same nine), +one `/TestCaseFilter`, and one aggregation — the pinned all-descendant `.//line` selection over the +same nine first-party package names. The baseline is [P0-T11] +(`coverage\791-baseline.cobertura.xml`); the post-change side is [P3-T5] +(`artifacts\csharp\coverage.xml`). + +## Comparability precondition, recorded first + +FINAL-LINES-VALID: 133187 +BASELINE-LINES-VALID: 132961 +RELATION: FINAL-LINES-VALID > BASELINE-LINES-VALID (by 226) +DENOMINATORS-EQUAL: NO +COMPARISON-USED: derived percentages + +The denominators are **not** equal, so the two absolute covered-line counts are not directly +comparable and the comparison used below is the one between the two derived percentages, as D14 and +this task require. The growth of 226 valid lines is expected and is attributable to this change: it +is the new production code added to `QuickFiler` — the gate's bound checks and three logging +helpers, the reordered `ActionCancelAsync` with its four support members, the extracted +`ParkFocusAndCancelSelectors`, and the rewritten `QfcHomeController.Cleanup()`. The two +`QfcDatamodel` partials contribute nothing to the denominator in either document because the type +carries `[ExcludeFromCodeCoverage]` (D1, confirmed by [P0-T12]). + +## The four counters + +| Counter | Baseline [P0-T11] | Final [P3-T5] | Delta | +|---|---|---|---| +| Lines covered | 112355 | 112551 | +196 | +| Lines valid | 132961 | 133187 | +226 | +| Branches covered | 26496 | 26584 | +88 | +| Branches valid | 33480 | 33568 | +88 | + +## Derived percentages — the operative comparison + +| Metric | Baseline | Final | Change | +|---|---|---|---| +| First-party line coverage | 84.50 % | 84.51 % | **+0.01 pp** | +| First-party branch coverage | 79.14 % | 79.19 % | **+0.05 pp** | + +REPOSITORY-WIDE-FIRST-PARTY-LINE-PERCENTAGE-DECREASED: NO + +The repository-wide first-party line percentage did not decrease. It rose from 84.50 % to 84.51 %. +The branch percentage also rose, from 79.14 % to 79.19 %. Both remain at or above the CLAUDE.md UT2 +80 percent line floor that [P0-T11] recorded as `BASELINE_FLOOR: MET`. + +Of the 226 newly valid lines, 196 are covered — 86.7 % of the newly added executable surface, which +is above the repository-wide rate and is why the aggregate percentage moved upward rather than being +diluted. Every one of the 88 newly valid branches is covered. + +## New and changed-code coverage determination, from [P3-T7] + +- CHANGED-LINES-TOTAL: 294 across the five measurable production paths. +- CHANGED-LINES-EXECUTABLE: 131. The other 163 are non-executable (XML doc comments, blank lines, + `using` directives, braces, an enum member, an interface method declaration) and carry no `hits` + value in either branch of the merged per-line map. +- CHANGED-EXECUTABLE-LINES-WITH-ZERO-HITS: 12, which is 119 of 131 executable changed lines covered, + or **90.8 %** — at or above the `>= 90 %` target the repository unit-test policy sets for new and + changed methods. All 12 are named individually in [P3-T7] and fall into three host-bound or + contract-defence classes: the UI `SynchronizationContext` marshal, two defensive `catch` blocks + whose throw sources have no injectable seam, and one `log.Debug` on the live-Outlook + `MoveAndIterate` completion branch. +- CHANGED-LINES-WITH-COVERAGE-REGRESSION: **0**. Seven changed lines had an equal-count hunk and a + one-to-one baseline mapping; none of them lost coverage. The remainder are pure insertions with no + baseline counterpart. +- Two production paths are structurally unmeasurable — the two `QfcDatamodel` partials, excluded by + the type-level `[ExcludeFromCodeCoverage]` — and [P3-T7] records named passing tests as the + substitute evidence for each of their changed members. + +## Determination + +Coverage did not regress at either scope. Repository-wide first-party line and branch percentages +both increased; no changed line lost coverage; and coverage on the executable changed lines is +90.8 %, above the policy target for new and changed code. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t9-file-size-audit.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t9-file-size-audit.md new file mode 100644 index 000000000..6295d5e68 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/qa-gates/p3-t9-file-size-audit.md @@ -0,0 +1,67 @@ +# [P3-T9] File-size audit (post-format) + +Timestamp: 2026-09-06T15-11 + +Command: `foreach ($p in ) { (Get-Content -LiteralPath $p).Count }` + +EXIT_CODE: 0 + +CEILING: 500 (applies to *.cs only) + +This audit is taken **after** the final CSharpier format ([P3-T1] pass 2 and the [P3-T2] check), +because CSharpier can change line counts. It supersedes the pre-format interim measurement in +[P2-T16]. + +## Production `.cs` (seven Write Set paths) + +| Path | Baseline ([P0-T13]) | Post-format | Headroom | +|---|---|---|---| +| `QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs` | 262 | 373 | 127 | +| `QuickFiler/Interfaces/IQfcDatamodel.cs` | 133 | 168 | 332 | +| `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` | 298 | 413 | 87 | +| `QuickFiler/Controllers/QfcDatamodel.cs` | 480 | 483 | 17 | +| `QuickFiler/Controllers/QfcFormController.EventHandlers.cs` | 408 | 490 | 10 | +| `QuickFiler/Controllers/QfcFormController.Deactivate.cs` | 60 | 73 | 427 | +| `QuickFiler/Controllers/QfcHomeController.cs` | 469 | 496 | 4 | + +## Test `.cs` (five modified, four created) + +| Path | Baseline ([P0-T13]) | Post-format | Headroom | +|---|---|---|---| +| `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs` | 477 | 487 | 13 | +| `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs` | 465 | 498 | 2 | +| `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs` | 280 | 289 | 211 | +| `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs` | 0 (new) | 347 | 153 | +| `QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs` | 0 (new) | 393 | 107 | +| `QuickFiler.Test/Controllers/QfcHomeControllerCleanupTests.cs` | 0 (new) | 118 | 382 | +| `QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs` | 0 (new) | 235 | 265 | +| `QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs` | 413 | 418 | 82 | +| `QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs` | 477 | 497 | 3 | + +MAX-CS-LINE-COUNT: 498 +ALL-CS-AT-OR-BELOW-500: YES +SMALLEST-REMAINING-HEADROOM: 2 lines, at `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs` (498 of 500) + +Every listed `.cs` count is at or below 500, which is this task's acceptance. The three next-tightest +files are `QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs` at 497 (headroom 3), +`QuickFiler/Controllers/QfcHomeController.cs` at 496 (headroom 4), and +`QuickFiler/Controllers/QfcFormController.EventHandlers.cs` at 490 (headroom 10). + +## PROJECT-FILE (exempt) + +PROJECT-FILE (exempt): QuickFiler.Test/QuickFiler.Test.csproj = 528 (baseline 524, +4) + +Recorded but not asserted against the ceiling, for the reason [P0-T13] states under the same heading +(R8): `.claude/rules/general-code-change.md` caps production code, test code and reusable script +files, and `.csharpierignore` lines 9-14 record project files as owned by Visual Studio and not C# +source, listing `*.csproj` at line 12. The file was already 524 lines at `BASE-SHA`, so asserting it +against the ceiling would be unsatisfiable whatever this plan did. The +4 is exactly the four +`` entries [P1-T7] added. + +## Effect of the format on the counts + +Comparing this audit with the [P2-T16] pre-format measurement, CSharpier changed six of the sixteen +`.cs` counts: the gate lost one line and five test files gained between one and six lines each. The +two production files that had been trimmed to fit — `QfcFormController.EventHandlers.cs` at 490 and +`QfcHomeController.cs` at 496 — were unchanged by the format and stayed inside the ceiling, so no +post-format trimming and no further toolchain restart was required. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t15-test-build.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t15-test-build.md new file mode 100644 index 000000000..dc0043e2d --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t15-test-build.md @@ -0,0 +1,53 @@ +# [P1-T15] Test build with the new and retargeted tests in place + +Timestamp: 2026-09-06T14-45 + +Command: `msbuild TaskMaster.sln /t:Build /m /p:Configuration=Debug "/p:Platform=Any CPU"` + +EXIT_CODE: 0 + +Output Summary: `Build succeeded. 0 Warning(s) 0 Error(s).` `QuickFiler.Test.dll` was rewritten at +14:44:48 by this build, so the assembly the Phase 1 expect-fail runs load is the one containing the +new and retargeted tests. + +This proves every new test compiles against the Phase 1 seams: the four new files +(`QfcStreamingDequeueConfidenceGateTests.Part4.cs`, `QfcFormControllerCancelTeardownTests.cs`, +`QfcHomeControllerCleanupTests.cs`, `QfcDatamodelTeardownTests.cs`) are wired by the four +`` entries [P1-T7] added, and the retargeted tests in `.cs`, `.Part2.cs`, +`.Part3.cs`, `QfcQueuePurePathsTests.cs` and `QfcHomeControllerIterationTests.cs` bind to the +widened seams. + +## First attempt and its two compile errors + +The first invocation of this command exited 1 with two distinct diagnostics. Both were repaired as +micro-actions inside the tasks that introduced them, and the command was then re-run from the start: + +1. `QfcStreamingDequeueConfidenceGateTests.Part2.cs` — three `error CS0103: The name 'QfcDequeueStop' + does not exist in the current context`. The retargeted assertions in [P1-T8] are the first uses of + that enum in this file, and the file carried no `using QuickFiler.Interfaces;`. The directive was + added; the three sibling parts of the class already carried it. One assertion was collapsed onto a + single line at the same time so the file lands at 498 lines rather than 500, keeping headroom + under the ceiling for the final format. +2. `QfcDatamodelTeardownTests.cs` — one `error CS0104: 'Action' is an ambiguous reference between + 'Microsoft.Office.Interop.Outlook.Action' and 'System.Action'`. The file has + `using Microsoft.Office.Interop.Outlook;` for `MailItem`, which brings the interop `Action` type + into scope. The declaration was qualified as `System.Action` with an explanatory comment, + following the identical convention already recorded at + `QfcStreamingDequeueConfidenceGateTests.Part2.cs` in + `Constructor_NonPositiveNonSentinelDeadline_IsRejectedByGuardClause`. + +Neither repair changed an assertion's meaning or an acceptance target. + +## File sizes after the repairs (`.cs` ceiling 500) + +| Path | Lines | +|---|---| +| `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs` | 487 | +| `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs` | 498 | +| `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs` | 287 | +| `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs` | 341 | +| `QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs` | 391 | +| `QuickFiler.Test/Controllers/QfcHomeControllerCleanupTests.cs` | 118 | +| `QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs` | 231 | +| `QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs` | 418 | +| `QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs` | 497 | diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t16-gate-fail-before.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t16-gate-fail-before.md new file mode 100644 index 000000000..858b28541 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t16-gate-fail-before.md @@ -0,0 +1,81 @@ +# [P1-T16] [expect-fail] Gate and datamodel-projection tests, before the fix + +Timestamp: 2026-09-06T14-46 + +Command: + +``` +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p1-t16' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:FullyQualifiedName~QfcStreamingDequeueConfidenceGateTests|FullyQualifiedName~QfcQueuePurePathsTests' +``` + +`$vstest` was re-bound inside this command block by the two R10 resolution lines; the resolved value +reduced per R3 is `\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe`. + +ExpectedExitCode: 1 +EXIT_CODE: 1 + +Output Summary: `Total tests: 46, Passed: 34, Failed: 12. Test Run Failed. Total time: 1.7412 +Seconds.` A failing run is the expected and required outcome of this task: the production behaviour +these tests assert is supplied by Phase 2. + +FAIL-BEFORE-COUNT: 12 + +## Failing tests, by fully qualified name and classification + +Failure messages are reproduced from the TRX `ErrorInfo/Message` first line only. No raw TRX content +is pasted (R3); no message below carries a host path. + +### NEW — the seven AC1 tests in `QfcStreamingDequeueConfidenceGateTests.Part4.cs` (6 of 7 red) + +1. NEW `QuickFiler.Controllers.Tests.QfcStreamingDequeueConfidenceGateTests.DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance` + — `Expected batch.Accepted to contain a single item ... but the collection is empty.` This is the + named fail-before evidence AC3 requires. +2. NEW `...DequeueAsync_ZeroAcceptedAndSourceDrained_ReportsSourceExhausted` + — `Expected batch.Scanned to be 5 ... but found 2.` +3. NEW `...DequeueAsync_ZeroAcceptedAndCapReached_StopsAndReportsScanCapReached` + — `Expected batch.Stop to be QfcDequeueStop.ScanCapReached {value: 3} ... but found QfcDequeueStop.SourceExhausted {value: 1}.` +4. NEW `...DequeueAsync_ZeroAcceptedAndCeilingReached_StopsWhileSourceStillRefilling` + — `Expected batch.Stop to be QfcDequeueStop.ScanCapReached {value: 3} ... but found QfcDequeueStop.DeadlineExpired {value: 2}.` +5. NEW `...DequeueAsync_CheckpointExpiry_LogsCutoffAndCounts` + — `Expected checkpoints to contain 3 item(s) ... but found 0: {empty}.` +6. NEW `...DequeueAsync_Launch_LogsCutoffQuantityAndBounds` + — `Expected logs to contain a single item matching log.Contains("High-confidence dequeue launch"), but no such item was found.` + +`...DequeueAsync_NonEmptyPrefix_UnchangedByCheckpoint` is the seventh Part4 test and passes already. +That is correct and intended: it is the #608 regression pin, so it asserts behaviour this change must +*not* alter. A pin that were red before the change would be pinning the wrong thing. + +### RETARGETED — the four tests in `...Part2.cs` (all 4 red) + +7. RETARGETED `...DequeueAsync_LowYieldStream_ContinuesPastDefaultDeadlineToTheQualifier` + — `Expected takeCount to be 51 ... but found 12 (difference of -39).` The 12 is the pre-change + 12-second bound at one second per score, which is exactly the superseded behaviour. +8. RETARGETED `...DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesToSourceExhaustion` + — `Expected takeCounter() to be 21 ... but found 3 (difference of -18).` +9. RETARGETED `...DequeueAsync_AfterScanCapReached_StopsTakingAndLeavesUnscannedCandidates` + — `Expected batch.Stop to be QfcDequeueStop.ScanCapReached {value: 3} ... but found QfcDequeueStop.SourceExhausted {value: 1}.` +10. RETARGETED `...DequeueAsync_CheckpointExpiry_EmitsCheckpointLineAndKeepsPerCandidateLogging` + — `Expected checkpoints to contain 3 item(s) ... but found 0: {empty}.` + +### RETARGETED — the two tests in `...Part3.cs` (1 of 2 red) + +11. RETARGETED `...DequeueAsync_ZeroAcceptedAndCapReached_ReportsScanCapReachedStop` + — `Expected batch.Stop to be QfcDequeueStop.ScanCapReached {value: 3} ... but found QfcDequeueStop.DeadlineExpired {value: 2}.` + +`...DequeueAsync_ProgressCallback_StopsReportingOnceTheMethodReturns` is the second Part3 retarget and +passes already. Its bound was changed from a 3-second deadline to an injected scan cap of 3, and at +one second per score both bounds admit the same three candidates, so its report sequence is +unchanged by construction. It is recorded here as retargeted-but-green rather than omitted. + +### RETARGETED — the `QfcQueuePurePathsTests` projection test (red) + +12. RETARGETED `QuickFiler.Controllers.Tests.QfcQueuePurePathsTests.DequeueNextItemGroupWithOutcomeAsync_ZeroAcceptanceCeilingGate_ReportsScanCapReachedStop` + — `Expected batch.Stop to be QfcDequeueStop.ScanCapReached {value: 3} ... but found QfcDequeueStop.DeadlineExpired {value: 2}.` + +## Relation to the [P0-T14] baseline + +All seven tests [P0-T14] recorded as `BASELINE-PASS` were retargeted or superseded by Phase 1, and +six of the seven are now red under their new names. The seventh, +`DequeueAsync_ProgressCallback_StopsReportingOnceTheMethodReturns`, kept its name and is green for +the reason stated above. No test outside the deliberately reddened set is failing in this run: the +34 passing tests include every unaffected gate test the plan's Citation table lists. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t17-cancel-teardown-fail-before.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t17-cancel-teardown-fail-before.md new file mode 100644 index 000000000..b8537b751 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t17-cancel-teardown-fail-before.md @@ -0,0 +1,65 @@ +# [P1-T17] [expect-fail] `QfcFormControllerCancelTeardownTests`, before the fix + +Timestamp: 2026-09-06T14-47 + +Command: + +``` +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p1t17' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:FullyQualifiedName~QfcFormControllerCancelTeardownTests' +``` + +`$vstest` was re-bound inside this command block by the two R10 resolution lines; the resolved value +reduced per R3 is `\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe`. + +ExpectedExitCode: 1 +EXIT_CODE: 1 + +Output Summary: `Total tests: 8, Passed: 2, Failed: 6. Test Run Failed. Total time: 1.7859 Seconds.` + +FAIL-BEFORE-COUNT: 6 + +## Failing tests, by fully qualified name, with failure messages reduced per R3 + +All names are in `QuickFiler.Controllers.Tests.QfcFormControllerCancelTeardownTests`. No message +below contains a host path, a user profile segment or a machine name. + +1. `ActionCancelAsync_UnregistersHandlersBeforeGroupsCleanup` + — `Expected FirstIndexOf(MarkerUnregisterNavigation) to be greater than or equal to 0 because the + navigation ledger must be drained on Cancel, but found -1 (difference of -1).` The marker index + is -1 because `IQfcCollectionController.UnregisterNavigation()` is never called on the Cancel + path at all today. This is the test [P1-T17]'s acceptance names. +2. `ActionCancelAsync_ResetsKbdActive_WhenKeyboardDialogActive` + — `Moq.MockException: an active keyboard dialog must be toggled off before the form goes away / + Expected invocation on the mock once, but was 0 times: x => x.ToggleKeyboardDialog()`. +3. `ActionCancelAsync_ParksFocusAndCancelsBreadcrumbSelectors` + — `Moq.MockException: Expected invocation on the mock once, but was 0 times: + x => x.ParkFocusOffWebView2()`. +4. `ActionCancelAsync_AwaitsLoaderQuiesceBeforeGroupsCleanup` + — `Moq.MockException: Expected invocation on the mock once, but was 0 times: + x => x.QuiesceLoaderAsync(It.IsAny())`. +5. `ActionCancelAsync_GroupsCleanupThrows_StillInvokesParentCleanup` + — `Did not expect any exception because a failing stage must not abort the teardown, but found + System.InvalidOperationException: groups cleanup failed`. The throw from `_groups.Cleanup()` + escapes `ActionCancelAsync` today, so `Cleanup()` — and through it the ribbon release callback — + never runs. +6. `ButtonCancel_Click_ActionThrows_DoesNotRethrow` + — `Expected capturing.Captured to be empty because a teardown failure must be logged, not + rethrown into the Outlook UI thread, but found at least one item {System.NullReferenceException: + Object reference not set to an instance of an object.` This confirms D12 directly: the throw + originates inside the handler's own `try`, and the `throw;` at the end of the catch re-raises it. + Because the handler is `async void`, the re-raise is posted to the captured + `SynchronizationContext` rather than returned to the caller, which is precisely why the test + installs a capturing context: without it the escape would land on the thread pool and the + assertion could not observe it. + +## Passing tests in this run, and why that is correct + +- `ActionCancelAsync_DoesNotToggle_WhenInactive` — a negative control. `ToggleKeyboardDialog()` is + not called today for any reason, so `Times.Never` holds vacuously before the fix and + substantively after it. It is the control that keeps test 2 from being satisfiable by an + unconditional toggle. +- `ActionCancelAsync_CalledTwice_InvokesParentCleanupOnce` — the capture pin for D6. Repeat + invocation is already inert today, because the first pass nulls `_parent`, `_groups`, + `_formViewer` and `_parentCleanup`. The test exists to pin that property so the Phase 2 rewrite + cannot lose it, not to describe a defect, so it is green on both sides by design. This is the + claim D6 says is pinned by an added test rather than asserted. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t18-home-cleanup-fail-before.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t18-home-cleanup-fail-before.md new file mode 100644 index 000000000..4bd643a5f --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t18-home-cleanup-fail-before.md @@ -0,0 +1,40 @@ +# [P1-T18] [expect-fail] `QfcHomeControllerCleanupTests`, before the fix + +Timestamp: 2026-09-06T14-47 + +Command: + +``` +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p1t18' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:FullyQualifiedName~QfcHomeControllerCleanupTests' +``` + +`$vstest` was re-bound inside this command block by the two R10 resolution lines; the resolved value +reduced per R3 is `\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe`. + +ExpectedExitCode: 1 +EXIT_CODE: 1 + +Output Summary: `Total tests: 2, Failed: 2. Test Run Failed. Total time: 1.7644 Seconds.` Both tests +in the class are red, which is the required outcome for this task. + +FAIL-BEFORE-COUNT: 2 + +## Failing tests, by fully qualified name, with failure messages reduced per R3 + +Both names are in `QuickFiler.Controllers.Tests.QfcHomeControllerCleanupTests`. + +1. `Cleanup_DatamodelCleanupThrows_StillInvokesParentCleanup` + — `Did not expect any exception because a failing cleanup stage must be logged, not propagated, + but found System.InvalidOperationException: datamodel cleanup failed`. `Cleanup()` calls + `_datamodel.Cleanup()` unguarded at the top of the method, so the throw escapes and + `ParentCleanup.Invoke()` on the last line never runs. This is the mechanism by which + `RibbonController.ReleaseQuickFiler` is skipped and both ribbon buttons become no-ops. +2. `Cleanup_DisposesTokenSourceAndDetachesWorkerCompleted` + — `Expected a to be thrown because the token source must be + disposed during cleanup, but no exception was thrown.` Reading + `CancellationTokenSource.Token` after `Cleanup()` succeeds, which proves the source was never + disposed. The companion assertion on the viewer's `Worker` getter is not reached in this run + because the disposal assertion fails first; it becomes the operative assertion once [P2-T12] + supplies the disposal. + +Both are the tests [P1-T18]'s acceptance names. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t19-datamodel-teardown-fail-before.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t19-datamodel-teardown-fail-before.md new file mode 100644 index 000000000..a81713be7 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t19-datamodel-teardown-fail-before.md @@ -0,0 +1,80 @@ +# [P1-T19] [expect-fail] `QfcDatamodelTeardownTests`, before the fix + +Timestamp: 2026-09-06T14-47 + +Command: + +``` +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p1t19' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:FullyQualifiedName~QfcDatamodelTeardownTests' +``` + +`$vstest` was re-bound inside this command block by the two R10 resolution lines; the resolved value +reduced per R3 is `\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe`. + +ExpectedExitCode: 1 +EXIT_CODE: 1 + +Output Summary: `Total tests: 5, Failed: 5. Test Run Failed. Total time: 1.6116 Seconds.` All five +tests in the class are red, which is the required outcome for this task. + +FAIL-BEFORE-COUNT: 5 + +## Failing tests, by fully qualified name, with exception types recorded + +All names are in `QuickFiler.Controllers.Tests.QfcDatamodelTeardownTests`. No message below carries +a host path. + +1. `TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing` + — exception type `System.ArgumentException`. + `Did not expect any exception because a released field must be a refusal at the accept point, not + a throw, but found System.ArgumentException: Delegate to an instance method cannot have null + 'this'.` + This is character-for-character the failure mode `issue.md` records in the production log + (`Delegate to an instance method cannot have null 'this'`, raised from + `TryQueueRemainingMailItemAsync` while constructing `QfcRemainingQueueAdmission` over + `_masterQueue.AddLast` and `_moveMonitor.HookItem`). The reported defect is reproduced + deterministically and without Outlook. This is the named fail-before evidence AC3 requires. + +2. `Cleanup_CalledTwice_DoesNotThrow` + — exception type `System.NullReferenceException`. + `Did not expect any exception because repeat teardown must be inert, not a fault on released + fields, but found System.NullReferenceException: Object reference not set to an instance of an + object.` The unguarded `_globals.Ol.App.NewMailEx -=` and `_moveMonitor.UnhookAll()` in + `Cleanup()` raise on the first call once those fields are already released. + +3. `QuiesceLoaderAsync_LoaderCompletes_ReturnsBeforeTimeout` + — assertion failure: `Expected field not to be because private field + '_remainingLoadTask' should exist on QfcDatamodel.` + +4. `QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs` + — assertion failure: `Expected field not to be because private field + '_remainingLoadTask' should exist on QfcDatamodel.` + +5. `Worker_DoWork_CapturesRemainingLoadTask` + — assertion failure: `Expected field not to be because private field + '_remainingLoadTask' should exist on QfcDatamodel.` + +All five names appear in the failure set with the cause recorded, which is this task's acceptance. + +## Divergence from the failure mode this task predicted + +[P1-T19] states that the two `QuiesceLoaderAsync` tests would fail with `NotImplementedException` +from the [P1-T2] seam. They fail one step earlier than that, and the reason is structural rather +than a defect in either the plan or the tests. + +Both tests inject `_remainingLoadTask` before calling `QuiesceLoaderAsync`, because +[P1-T14] specifies that injection as part of their arrangement — a completed task for the completion +case and a never-completing `TaskCompletionSource` task for the bound case. That field is added by +[P2-T4], not by the Phase 1 seams, so at the end of Phase 1 the reflective field lookup in the +shared `SetPrivateField` helper returns null and its fail-closed +`.Should().NotBeNull(...)` guard fires during Arrange. The seam's +`NotImplementedException` sits in Act, which is never reached. + +`Worker_DoWork_CapturesRemainingLoadTask` is red for the same reason on its read side, which is what +[P1-T20] tags `SEAM-BLOCKED`. + +The consequence is confined to which line of these two tests reports first. Both remain red before +the change and both must be green after it, so the fail-before/pass-after evidence they carry is +unaffected, and no acceptance condition of this task or of AC2 or AC3 depends on the exception type +being `NotImplementedException`. The divergence is recorded here rather than silently absorbed, and +is repeated in the execution report. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t20-expected-red-inventory.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t20-expected-red-inventory.md new file mode 100644 index 000000000..7b99bf419 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t20-expected-red-inventory.md @@ -0,0 +1,97 @@ +# [P1-T20] Expected-red inventory at the end of Phase 1 + +Timestamp: 2026-09-06T14-48 + +This is the complete set of tests that are red at the end of Phase 1, consolidated from the four +fail-before artifacts. It is the set Phase 2 must turn green, and nothing else. Every entry carries +one of the three tags `NEW`, `RETARGETED` or `SEAM-BLOCKED`. + +## Count reconciliation + +| Source task | Artifact | Failure count | +|---|---|---| +| [P1-T16] | `p1-t16-gate-fail-before.md` | 12 | +| [P1-T17] | `p1-t17-cancel-teardown-fail-before.md` | 6 | +| [P1-T18] | `p1-t18-home-cleanup-fail-before.md` | 2 | +| [P1-T19] | `p1-t19-datamodel-teardown-fail-before.md` | 5 | +| **Sum of the four recorded failure counts** | | **25** | +| **Entries in this inventory** | | **25** | + +INVENTORY-COUNT: 25 +SUM-OF-FAIL-BEFORE-COUNTS: 25 +RECONCILES: YES + +## Inventory + +All names below are in namespace `QuickFiler.Controllers.Tests`. + +### From [P1-T16] — 12 entries + +| # | Tag | Class | Method | +|---|---|---|---| +| 1 | NEW | `QfcStreamingDequeueConfidenceGateTests` | `DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance` | +| 2 | NEW | `QfcStreamingDequeueConfidenceGateTests` | `DequeueAsync_ZeroAcceptedAndSourceDrained_ReportsSourceExhausted` | +| 3 | NEW | `QfcStreamingDequeueConfidenceGateTests` | `DequeueAsync_ZeroAcceptedAndCapReached_StopsAndReportsScanCapReached` | +| 4 | NEW | `QfcStreamingDequeueConfidenceGateTests` | `DequeueAsync_ZeroAcceptedAndCeilingReached_StopsWhileSourceStillRefilling` | +| 5 | NEW | `QfcStreamingDequeueConfidenceGateTests` | `DequeueAsync_CheckpointExpiry_LogsCutoffAndCounts` | +| 6 | NEW | `QfcStreamingDequeueConfidenceGateTests` | `DequeueAsync_Launch_LogsCutoffQuantityAndBounds` | +| 7 | RETARGETED | `QfcStreamingDequeueConfidenceGateTests` | `DequeueAsync_LowYieldStream_ContinuesPastDefaultDeadlineToTheQualifier` | +| 8 | RETARGETED | `QfcStreamingDequeueConfidenceGateTests` | `DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesToSourceExhaustion` | +| 9 | RETARGETED | `QfcStreamingDequeueConfidenceGateTests` | `DequeueAsync_AfterScanCapReached_StopsTakingAndLeavesUnscannedCandidates` | +| 10 | RETARGETED | `QfcStreamingDequeueConfidenceGateTests` | `DequeueAsync_CheckpointExpiry_EmitsCheckpointLineAndKeepsPerCandidateLogging` | +| 11 | RETARGETED | `QfcStreamingDequeueConfidenceGateTests` | `DequeueAsync_ZeroAcceptedAndCapReached_ReportsScanCapReachedStop` | +| 12 | RETARGETED | `QfcQueuePurePathsTests` | `DequeueNextItemGroupWithOutcomeAsync_ZeroAcceptanceCeilingGate_ReportsScanCapReachedStop` | + +### From [P1-T17] — 6 entries + +| # | Tag | Class | Method | +|---|---|---|---| +| 13 | NEW | `QfcFormControllerCancelTeardownTests` | `ActionCancelAsync_ResetsKbdActive_WhenKeyboardDialogActive` | +| 14 | NEW | `QfcFormControllerCancelTeardownTests` | `ActionCancelAsync_ParksFocusAndCancelsBreadcrumbSelectors` | +| 15 | NEW | `QfcFormControllerCancelTeardownTests` | `ActionCancelAsync_UnregistersHandlersBeforeGroupsCleanup` | +| 16 | NEW | `QfcFormControllerCancelTeardownTests` | `ActionCancelAsync_AwaitsLoaderQuiesceBeforeGroupsCleanup` | +| 17 | NEW | `QfcFormControllerCancelTeardownTests` | `ActionCancelAsync_GroupsCleanupThrows_StillInvokesParentCleanup` | +| 18 | NEW | `QfcFormControllerCancelTeardownTests` | `ButtonCancel_Click_ActionThrows_DoesNotRethrow` | + +### From [P1-T18] — 2 entries + +| # | Tag | Class | Method | +|---|---|---|---| +| 19 | NEW | `QfcHomeControllerCleanupTests` | `Cleanup_DatamodelCleanupThrows_StillInvokesParentCleanup` | +| 20 | NEW | `QfcHomeControllerCleanupTests` | `Cleanup_DisposesTokenSourceAndDetachesWorkerCompleted` | + +### From [P1-T19] — 5 entries + +| # | Tag | Class | Method | +|---|---|---|---| +| 21 | NEW | `QfcDatamodelTeardownTests` | `TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing` | +| 22 | SEAM-BLOCKED | `QfcDatamodelTeardownTests` | `QuiesceLoaderAsync_LoaderCompletes_ReturnsBeforeTimeout` | +| 23 | SEAM-BLOCKED | `QfcDatamodelTeardownTests` | `QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs` | +| 24 | NEW | `QfcDatamodelTeardownTests` | `Cleanup_CalledTwice_DoesNotThrow` | +| 25 | SEAM-BLOCKED | `QfcDatamodelTeardownTests` | `Worker_DoWork_CapturesRemainingLoadTask` | + +## Tag definitions used here + +- **NEW** — a test this plan created that asserts behaviour Phase 2 supplies. It is red because the + production behaviour does not exist yet. +- **RETARGETED** — a pre-existing test whose assertion was rewritten against the superseding + behaviour. It was green at `BASE-SHA` under its old name or old assertion ([P0-T14] recorded all + seven) and is red now. +- **SEAM-BLOCKED** — red because it reads or writes `QfcDatamodel._remainingLoadTask`, a field + [P2-T4] adds. Its fail-closed reflective field lookup fires during Arrange, before the assertion + the test exists for is reached. See the divergence note in + `p1-t19-datamodel-teardown-fail-before.md`. + +## Tests deliberately NOT in this inventory + +Three tests touched by Phase 1 are green at the end of Phase 1 and must stay green. They are listed +so a reader does not read their absence as an omission: + +- `QfcStreamingDequeueConfidenceGateTests.DequeueAsync_NonEmptyPrefix_UnchangedByCheckpoint` (NEW) — + the #608 regression pin; it asserts behaviour this change must not alter. +- `QfcStreamingDequeueConfidenceGateTests.DequeueAsync_ProgressCallback_StopsReportingOnceTheMethodReturns` + (RETARGETED) — its bound moved from a 3 s deadline to a cap of 3, which admits the same three + candidates at one second per score. +- `QfcFormControllerCancelTeardownTests.ActionCancelAsync_DoesNotToggle_WhenInactive` and + `...ActionCancelAsync_CalledTwice_InvokesParentCleanupOnce` (both NEW) — a negative control and a + D6 capture pin respectively. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t4-seam-build.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t4-seam-build.md new file mode 100644 index 000000000..378fa623d --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p1-t4-seam-build.md @@ -0,0 +1,43 @@ +# [P1-T4] Seam build + +Timestamp: 2026-09-06T14-34 + +Command: `msbuild TaskMaster.sln /t:Build /m /p:Configuration=Debug "/p:Platform=Any CPU"` + +EXIT_CODE: 0 + +Output Summary: `Build succeeded. 0 Warning(s) 0 Error(s). Time Elapsed 00:00:08.57.` Seventeen +projects built. + +This is an iterative build, not a gate build (R9). It uses `/t:Build` with no `/p:` gate switches, +because its purpose is to produce assemblies the test project compiles against, not to run a gate; +every source edit in [P1-T1] through [P1-T3] changed a file timestamp, so `CoreCompile` is not +skipped. The two gate builds in Phase 3 use `/t:Rebuild` with the CLAUDE.md switches. + +## Seam declarations proved available by this build + +- `QfcDequeueStop.ScanCapReached` and `IQfcDatamodel.QuiesceLoaderAsync(TimeSpan)` in + `QuickFiler/Interfaces/IQfcDatamodel.cs` ([P1-T1]). +- `QfcDatamodel.QuiesceDebugLog` and the declaration-only `QfcDatamodel.QuiesceLoaderAsync` + in `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` ([P1-T2]). +- `DefaultMaxScanWithoutAcceptance`, `DefaultZeroAcceptanceCeiling`, the two new optional + constructor parameters, and the `MaxScanWithoutAcceptance` / `ZeroAcceptanceCeiling` internal + get-only auto-properties in `QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs` + ([P1-T3]). + +## Constructor shape, read from the built assembly by reflection + +``` +ctor params=5 : tryTakeNext, scoreLoader, threshold, timeProvider, debugLog +ctor params=11 : tryTakeNext, scoreLoader, threshold, timeProvider, debugLog, sourceActive, + firstBatchDeadline, progressCallback, onRejected, maxScanWithoutAcceptance, + zeroAcceptanceCeiling +``` + +The wide constructor now declares exactly eleven parameters, which is the [P1-T3] acceptance and the +shape [P1-T5] must widen the fail-closed reflection helper to. + +The bounds are stored in internal get-only auto-properties rather than `private readonly` fields +(D9). A private field assigned and never read raises CS0414, which the Phase 3 nullable gate's +`/p:TreatWarningsAsErrors=true` would promote to an error; this build's zero-warning result confirms +the auto-property form is warning-clean before [P2-T1] reads the values. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p2-t13-post-fix-build.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p2-t13-post-fix-build.md new file mode 100644 index 000000000..be82e6915 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p2-t13-post-fix-build.md @@ -0,0 +1,52 @@ +# [P2-T13] Post-fix build + +Timestamp: 2026-09-06T14-56 + +Command: `msbuild TaskMaster.sln /t:Build /m /p:Configuration=Debug "/p:Platform=Any CPU"` + +EXIT_CODE: 0 + +Output Summary: `Build succeeded. 0 Warning(s) 0 Error(s). Time Elapsed 00:00:08.02.` + +This is an iterative build, not a gate build (R9): `/t:Build` with no `/p:` gate switches, run to +produce the assemblies [P2-T14] and [P2-T15] load. The two gate builds with `/t:Rebuild` and the +CLAUDE.md switches run in Phase 3 as [P3-T3] and [P3-T4]. + +## First attempt and its two compile errors + +The first invocation exited 1 with two diagnostics, both introduced by [P2-T5] and [P2-T6] and both +repaired as micro-actions inside those tasks before the command was re-run from the start: + +1. `QuickFiler/Controllers/QfcDatamodel.cs` — `error CS0029: Cannot implicitly convert type 'void' + to 'object'`. [P2-T6] assigned the loader task to the non-generic `Task _remainingLoadTask` field + and then awaited that field, but `Worker_DoWork` needs the awaited value for `e.Result`, and + awaiting a non-generic `Task` yields no value. Repaired by capturing the `Task` the loader + returns into a typed local, assigning that local to the field, and awaiting the local. The field + stays non-generic because `QuiesceLoaderAsync` only needs to observe completion, and a + `Task` is assignable to a `Task` field. +2. `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` — `error CS0246: The type or namespace + name 'LockingLinkedList<>' could not be found`. The relocated + `TryQueueRemainingMailItemAsync` names the master queue's type in the local it snapshots, and the + QueueProcessing partial carried `using UtilitiesCS;` but not + `using UtilitiesCS.ReusableTypeClasses;`, which the file it came from does carry. The directive + was added. + +Neither repair changed the behaviour either task specifies. + +## Production files at this point (`.cs` ceiling 500) + +| Path | Baseline ([P0-T13]) | Now | +|---|---|---| +| `QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs` | 262 | 374 | +| `QuickFiler/Interfaces/IQfcDatamodel.cs` | 133 | 168 | +| `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` | 298 | 390 | +| `QuickFiler/Controllers/QfcDatamodel.cs` | 480 | 469 | +| `QuickFiler/Controllers/QfcFormController.EventHandlers.cs` | 408 | 490 | +| `QuickFiler/Controllers/QfcFormController.Deactivate.cs` | 60 | 73 | +| `QuickFiler/Controllers/QfcHomeController.cs` | 469 | 496 | + +`QfcDatamodel.cs` shrank because [P2-T5] relocated `TryQueueRemainingMailItemAsync` out of it. +`QfcFormController.EventHandlers.cs` and `QfcHomeController.cs` were both first written over the +ceiling (521 and 505) and were brought back under it by condensing the added XML documentation and +comments, with no assertion, log line, guard or ordering changed. The exact counts are re-measured +by [P2-T16] before the final format and by [P3-T9] after it. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p2-t14-pass-after.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p2-t14-pass-after.md new file mode 100644 index 000000000..a21829a04 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p2-t14-pass-after.md @@ -0,0 +1,84 @@ +# [P2-T14] Pass-after for every test in the [P1-T20] inventory + +Timestamp: 2026-09-06T14-57 + +Command: + +``` +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p2-t14' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:FullyQualifiedName~QfcStreamingDequeueConfidenceGateTests|FullyQualifiedName~QfcQueuePurePathsTests|FullyQualifiedName~QfcFormControllerCancelTeardownTests|FullyQualifiedName~QfcHomeControllerCleanupTests|FullyQualifiedName~QfcDatamodelTeardownTests|FullyQualifiedName~QfcHomeControllerIterationTests' +``` + +`$vstest` was re-bound inside this command block by the two R10 resolution lines; the resolved value +reduced per R3 is `\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe`. + +EXIT_CODE: 0 + +Output Summary: `Test Run Successful. Total tests: 76, Passed: 76, Total time: 1.8104 Seconds.` + +P2-T14-TOTAL-RUN: 76 +P2-T14-TOTAL-PASSED: 76 +P2-T14-TOTAL-FAILED: 0 + +The run totals are recorded separately and are deliberately not asserted against the inventory +count: the filter selects whole classes, so it also runs the tests that were already green at the +end of Phase 1 (the #608 pin, the negative controls, the D6 capture pin, and every unaffected +pre-existing test in the six classes). + +## `PASS-AFTER` lines, one per [P1-T20] inventory entry + +Each line below was derived by looking the method name up in the `TestResults\791-p2-t14` TRX and +reading its `outcome` attribute. All names are in namespace `QuickFiler.Controllers.Tests`. + +PASS-AFTER: QfcStreamingDequeueConfidenceGateTests.DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance +PASS-AFTER: QfcStreamingDequeueConfidenceGateTests.DequeueAsync_ZeroAcceptedAndSourceDrained_ReportsSourceExhausted +PASS-AFTER: QfcStreamingDequeueConfidenceGateTests.DequeueAsync_ZeroAcceptedAndCapReached_StopsAndReportsScanCapReached +PASS-AFTER: QfcStreamingDequeueConfidenceGateTests.DequeueAsync_ZeroAcceptedAndCeilingReached_StopsWhileSourceStillRefilling +PASS-AFTER: QfcStreamingDequeueConfidenceGateTests.DequeueAsync_CheckpointExpiry_LogsCutoffAndCounts +PASS-AFTER: QfcStreamingDequeueConfidenceGateTests.DequeueAsync_Launch_LogsCutoffQuantityAndBounds +PASS-AFTER: QfcStreamingDequeueConfidenceGateTests.DequeueAsync_LowYieldStream_ContinuesPastDefaultDeadlineToTheQualifier +PASS-AFTER: QfcStreamingDequeueConfidenceGateTests.DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesToSourceExhaustion +PASS-AFTER: QfcStreamingDequeueConfidenceGateTests.DequeueAsync_AfterScanCapReached_StopsTakingAndLeavesUnscannedCandidates +PASS-AFTER: QfcStreamingDequeueConfidenceGateTests.DequeueAsync_CheckpointExpiry_EmitsCheckpointLineAndKeepsPerCandidateLogging +PASS-AFTER: QfcStreamingDequeueConfidenceGateTests.DequeueAsync_ZeroAcceptedAndCapReached_ReportsScanCapReachedStop +PASS-AFTER: QfcQueuePurePathsTests.DequeueNextItemGroupWithOutcomeAsync_ZeroAcceptanceCeilingGate_ReportsScanCapReachedStop +PASS-AFTER: QfcFormControllerCancelTeardownTests.ActionCancelAsync_ResetsKbdActive_WhenKeyboardDialogActive +PASS-AFTER: QfcFormControllerCancelTeardownTests.ActionCancelAsync_ParksFocusAndCancelsBreadcrumbSelectors +PASS-AFTER: QfcFormControllerCancelTeardownTests.ActionCancelAsync_UnregistersHandlersBeforeGroupsCleanup +PASS-AFTER: QfcFormControllerCancelTeardownTests.ActionCancelAsync_AwaitsLoaderQuiesceBeforeGroupsCleanup +PASS-AFTER: QfcFormControllerCancelTeardownTests.ActionCancelAsync_GroupsCleanupThrows_StillInvokesParentCleanup +PASS-AFTER: QfcFormControllerCancelTeardownTests.ButtonCancel_Click_ActionThrows_DoesNotRethrow +PASS-AFTER: QfcHomeControllerCleanupTests.Cleanup_DatamodelCleanupThrows_StillInvokesParentCleanup +PASS-AFTER: QfcHomeControllerCleanupTests.Cleanup_DisposesTokenSourceAndDetachesWorkerCompleted +PASS-AFTER: QfcDatamodelTeardownTests.TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing +PASS-AFTER: QfcDatamodelTeardownTests.QuiesceLoaderAsync_LoaderCompletes_ReturnsBeforeTimeout +PASS-AFTER: QfcDatamodelTeardownTests.QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs +PASS-AFTER: QfcDatamodelTeardownTests.Cleanup_CalledTwice_DoesNotThrow +PASS-AFTER: QfcDatamodelTeardownTests.Worker_DoWork_CapturesRemainingLoadTask + +PASS-AFTER-COUNT: 25 +P1-T20-INVENTORY-COUNT: 25 +COUNTS-EQUAL: YES + +## Additional named acceptances satisfied by this run + +The tests the Phase 2 tasks name as "still passes" controls are in the same six classes and are +among the 76 that passed: + +- [P2-T2]: `DequeueAsync_UsesDequeueTimeScoreSelection_AndLogsScoreContext` — its filtered + `ContainSingle` predicate still selects exactly the per-candidate score line, so the added launch + line did not break it. +- [P2-T3]: `IterateQueueAsync_EmptyBatchWithScanCapReached_DoesNotCompleteAdding` and + `IterateQueueAsync_EmptyBatchWithSourceExhausted_CompletesAddingOnce` — the new stop reason is not + routed into the queue-closing branch, and genuine exhaustion still closes it. +- [P1-T6] / #608: `DequeueAsync_NonEmptyPrefix_UnchangedByCheckpoint` — green before and after. +- [P1-T9]: `DequeueAsync_ProgressCallback_StopsReportingOnceTheMethodReturns` — green before and + after under its rebased bound. + +## Re-run after the [P2-T15] topology repair + +[P2-T15] surfaced one newly-failing pre-existing test and its repair changed +`QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs`, so this command was re-run verbatim +against the repaired build to keep this artifact's result attributable to the delivered code rather +than to an intermediate one. The re-run printed `Test Run Successful. Total tests: 76, Passed: 76, +Total time: 1.7424 Seconds` with exit code 0 — identical counts — and overwrote +`TestResults\791-p2-t14`. Every `PASS-AFTER` line above therefore describes the final build. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p2-t15-quickfiler-suite.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p2-t15-quickfiler-suite.md new file mode 100644 index 000000000..3a203043c --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/p2-t15-quickfiler-suite.md @@ -0,0 +1,78 @@ +# [P2-T15] Whole `QuickFiler.Test` assembly after the fix + +Timestamp: 2026-09-06T14-59 + +Command: + +``` +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p2-t15b' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:TestCategory!=LiveOutlook' +``` + +`$vstest` was re-bound inside this command block by the two R10 resolution lines; the resolved value +reduced per R3 is `\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe`. +This is the same command and the same switches as the [P0-T10] baseline, differing only in the +results directory. + +EXIT_CODE: 0 + +POST-QFT-TOTAL: 1362 +POST-QFT-PASSED: 1362 +POST-QFT-FAILED: 0 + +NEWLY-FAILING: NONE + +Output Summary: `Test Run Successful. Total tests: 1362, Passed: 1362, Total time: 12.1509 Seconds.` + +## Comparison against the [P0-T10] baseline + +| Measure | Baseline [P0-T10] | This run | Relation | +|---|---|---|---| +| Total | 1339 | 1362 | +23 | +| Passed | 1339 | 1362 | +23 | +| Failed | 0 | 0 | `POST-QFT-FAILED <= BASELINE-QFT-FAILED` holds (0 <= 0) | + +The +23 is exactly the tests this plan added: seven in +`QfcStreamingDequeueConfidenceGateTests.Part4.cs` ([P1-T6]), eight in +`QfcFormControllerCancelTeardownTests.cs` ([P1-T12]), two in `QfcHomeControllerCleanupTests.cs` +([P1-T13]), five in `QfcDatamodelTeardownTests.cs` ([P1-T14]) and one added to +`QfcHomeControllerIterationTests.cs` ([P1-T11]). The retargeted tests were renamed rather than +added, so they do not change the total. + +`NEWLY-FAILING: NONE` is a substantive determination, not a vacuous one: the baseline failure set was +empty, so any failure in this run would be newly failing by construction, and the first execution of +this task did surface one (below). + +## The one newly-failing test surfaced by the first execution, and its repair + +The first execution of this command exited 1 with `Total tests: 1362, Passed: 1361, Failed: 1`. The +single failure was a pre-existing architecture pin, not one of this plan's tests: + +`QuickFiler.Controllers.Tests.QfcMoveMonitorTopologyTests.NoTypeDeclaresMoreThanOneEmailMoveMonitorField` +— `Expected declaringTypes to contain 3 item(s) because issue #731 finding 1 pins the three-owner +topology ... but found 4: {"QuickFiler.Controllers.QfcCollectionController", +"QuickFiler.Controllers.QfcDatamodel", "QuickFiler.Controllers.QfcQueue", +"QuickFiler.Controllers.QfcDatamodel+d__58"}`. + +Cause: that test reflects over every type in the QuickFiler assembly and counts types declaring an +`IEmailMoveMonitor` field. [P2-T5] introduced an `IEmailMoveMonitor moveMonitor` local inside the +`async` `TryQueueRemainingMailItemAsync`, and the C# compiler hoists locals in an `async` method into +the generated state-machine type as fields. The state machine +`QfcDatamodel+d__58` therefore became a fourth declaring type. The +production topology was never actually changed — no new monitor instance exists — but the pin reads +declared fields, not instances, so it reported a real violation of what it pins. + +Repair, applied as a micro-action inside [P2-T5]: the snapshot and the guard were moved into a new +private **synchronous** helper `TryCreateRemainingQueueAdmission(CancellationToken)`, which returns +the constructed `QfcRemainingQueueAdmission` or `null`. `TryQueueRemainingMailItemAsync` calls it, +returns `false` on null, and otherwise awaits `TryQueueAsync` as before. A synchronous method has no +state machine, so the `IEmailMoveMonitor` local is a stack slot and declares no field. The guard +semantics, the snapshot semantics and the three-delegate +`QfcRemainingQueueAdmission` constructor shape are all unchanged; only where the two locals live +changed. + +`EachOwnerDeclaresExactlyOneEmailMoveMonitorInitializer`, the source-text sibling pin in the same +class, passed in both executions. + +After the repair the assembly was rebuilt (`Build succeeded. 0 Warning(s) 0 Error(s)`) and this +command was re-run from the start, producing the 1362/1362 result recorded above. [P2-T14] was also +re-run verbatim against the repaired build and reproduced its 76/76 result. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/feature-audit.2026-09-06T15-31.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/feature-audit.2026-09-06T15-31.md new file mode 100644 index 000000000..041173d26 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/feature-audit.2026-09-06T15-31.md @@ -0,0 +1,309 @@ +# Feature Audit — Issue #791 (quickfiler-high-confidence-cancel-teardown-and-deadline-defects) + +- **Date:** 2026-09-06 +- **Reviewer:** feature-review agent (cycle 1) +- **Companion artifacts:** `policy-audit.2026-09-06T15-31.md`, `code-review.2026-09-06T15-31.md` + +## Scope and Baseline + +- **Base branch:** `main`, resolved to `origin/main` @ `7c8ac9ae34b8b3dda9134a5e310f39742fd2f0b6`. +- **Merge base:** recomputed by this reviewer with `git merge-base HEAD origin/main` = + `7c8ac9ae34b8b3dda9134a5e310f39742fd2f0b6`, identical to the caller-supplied value and to the value + recorded in `artifacts/pr_context.summary.txt`. +- **Head:** `bug/quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791` @ + `59536368756d979f3f72268dfb4dfd0d4b2f7d9f`, 11 commits ahead of the base. +- **Diff scope:** the full branch diff against the merge base — 72 changed paths: 7 production `.cs`, + 9 test `.cs`, 1 test `.csproj`, 40 documentation and evidence markdown files, 6 agent-memory + markdown files, 2 promoted potential entries, and the atomic plan. No caller instruction narrowed + this scope, and none was disregarded on scope grounds. +- **Work mode:** `full-bug`, from the persisted marker at `issue.md:12`. +- **Authoritative acceptance-criteria source:** `docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/spec.md`, + section `## Acceptance Criteria`, criteria AC1 through AC6 at lines 255, 257, 260, 262, 266 and 269. + Per the `full-bug` rule in `.claude/skills/acceptance-criteria-tracking/SKILL.md`, `spec.md` is the + only AC source; `user-story.md` is narrative operator context and carries no criteria, and + `issue.md` carries a narrative copy of AC1 and AC2 that `spec.md:9` and the delivery's own + `issue.md:154-156` deliberately leave unchecked so there is a single place of record. This reviewer + verified `user-story.md` contains no `- [ ]` or `- [x]` acceptance item. +- **PR context artifacts:** `artifacts/pr_context.summary.txt` and `artifacts/pr_context.appendix.txt`, + generated 2026-09-06 19:19:04 UTC with `Head SHA: 59536368756d979f3f72268dfb4dfd0d4b2f7d9f`, which + equals `git rev-parse HEAD`. Not stale; no regeneration was required. +- **Baseline for behavioral comparison:** the pre-fix state at `7c8ac9ae` as captured by the four + fail-before artifacts under `evidence/regression-testing/` and by the baseline test and coverage + runs under `evidence/baseline/`. + +## Acceptance Criteria Inventory + +| ID | Source | Line | Criterion (abbreviated) | Checkbox state at review time | +|---|---|---|---|---| +| AC1 | `spec.md` | 255 | The zero-acceptance first-batch deadline becomes an advisory checkpoint; the scan continues to first acceptance, genuine exhaustion, or a hard bound (item cap plus time ceiling); an empty dialog is permitted only on exhaustion or at the bound; the bound decision, the cutoff, and the scanned/accepted counts are logged at launch and at each deadline decision; covered by deterministic MSTest tests using a fake time provider. | `- [x]` | +| AC2 | `spec.md` | 257 | The Cancel teardown completes cleanly and in order: loader stopped and awaited before any datamodel field is nulled; form and item keyboard handlers unregistered before item rows are removed; keyboard-active flag reset; WebView2 focus parked and breadcrumb dropdown cancelled on the Cancel path; ribbon release callback under a `finally`; every stage including any exception logged. Live-Outlook confirmation is human-interaction exception HI-1 and does not gate the automated review. | `- [x]` | +| AC3 | `spec.md` | 260 | Every regression test named in Test Strategy exists in the file listed for it and passes; fail-before/pass-after evidence recorded under `evidence/regression-testing/` for at least the two named tests. | `- [x]` | +| AC4 | `spec.md` | 262 | The C# toolchain passes in the CLAUDE.md order with no failures in the final pass; coverage XML produced at `artifacts/csharp/coverage.xml`; coverage on the changed files at or above the policy target with no regression on changed lines. | `- [x]` | +| AC5 | `spec.md` | 266 | The branch diff touches no file outside the Write Set other than test files under `QuickFiler.Test/Controllers` and `` entries; the five named files are unmodified. | `- [x]` | +| AC6 | `spec.md` | 269 | The superseded #424 and #608 criteria are recorded as superseded in this spec under both named sections, and #446 AC-6 is verifiably preserved by an unmodified `QfcHomeController.Iteration.cs`. | `- [x]` | + +Total AC items: **6**. Non-checkbox criteria: none. Phantom criteria added by any agent: none. + +## Acceptance Criteria Evaluation + +### AC1 — advisory checkpoint with two hard bounds — **PASS** + +Every clause was verified against the code and against a test, not against the delivery's summary. + +- *Continues to first acceptance.* `QfcStreamingDequeueConfidenceGate.cs:236-240` replaces the return + with a log-and-reset-interval that falls through to the take. Pinned by + `DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance`, which places the single + qualifier at position 41 behind 40 below-cutoff candidates at 1 s per score against the default 12 s + interval, and asserts `Scanned == 41` and `QuantitySatisfied`. Under the pre-change code the same + fixture returned empty after 12 scans; `evidence/regression-testing/p1-t16-gate-fail-before.md` + records it failing with an empty accepted collection. +- *Genuine exhaustion.* `DequeueAsync_ZeroAcceptedAndSourceDrained_ReportsSourceExhausted` — neither + bound reached, producer dead, `SourceExhausted`, source empty. +- *Item cap.* `:230` checks `scanned >= MaxScanWithoutAcceptance` **before** the take. + `DequeueAsync_ZeroAcceptedAndCapReached_StopsAndReportsScanCapReached` asserts `Scanned == 4`, + `takeCount == 4` and `source.HaveCount(6)`, so the bounded scan provably does not consume an extra + candidate. +- *Time ceiling.* `DequeueAsync_ZeroAcceptedAndCeilingReached_StopsWhileSourceStillRefilling` drives + `sourceActive: () => true` with `tryTakeNext` always null and asserts the task is incomplete before + the fake clock advances past 120 s. This reviewer confirmed the ceiling is necessary rather than + redundant: the empty-queue wait path at `:244-257` does not increment `scanned`, so the item cap + alone cannot terminate that loop. +- *Empty dialog only on exhaustion or at the bound.* The only two `return` statements that can produce + an empty batch inside the `accepted.Count == 0` region are `ScanCapReached` at `:233` and + `SourceExhausted` at `:249`. There is no third exit. +- *Bound decision logged.* `LogScanBoundReached` at `:346-361` emits the bound name, the counts, the + cutoff, the elapsed time and `Decision=stop`. The line executes on both bound paths and is covered. + The one gap is that no test asserts its *content*; see finding N3 in + `code-review.2026-09-06T15-31.md`. The criterion's requirement is that the decision be logged, which + is implemented and executed, so this is a verification gap rather than an unmet clause. +- *Cutoff and counts logged at launch and at each decision.* `LogLaunch` at `:310-324` and + `LogZeroAcceptanceCheckpoint` at `:326-344`, both pinned on content: + `DequeueAsync_Launch_LogsCutoffQuantityAndBounds` asserts `Cutoff=900`, `0.9`, `Quantity=7`, + `ScanCap=250` and `Ceiling=00:02:00`; `DequeueAsync_CheckpointExpiry_LogsCutoffAndCounts` asserts + `Accepted=0`, `Scanned=3` and `Cutoff=900` and that exactly three checkpoints occur in a + ten-candidate scan across a 3 s interval. The reported defect was in part that "the cutoff (900) is + never logged"; it is now logged twice. +- *Deterministic MSTest with a fake time provider.* All seven AC1 tests use `FakeTimeProvider`. The + ceiling test asserts incompleteness before advancing the clock, which proves the fake clock is what + releases the wait rather than a real delay. + +### AC2 — ordered, logged, exception-safe teardown — **PASS** + +- *Loader stopped and awaited before any datamodel field is nulled.* `ActionCancelAsync` awaits + `_parent?.DataModel?.QuiesceLoaderAsync(LoaderQuiesceBound)` at `EventHandlers.cs:150-164`, before + the `groups-cleanup` and `controller-cleanup` stages that lead to field release. + `QuiesceLoaderAsync` cancels the token, snapshots `_remainingLoadTask`, and awaits + `Task.WhenAny(loader, bound)`. Pinned by `ActionCancelAsync_AwaitsLoaderQuiesceBeforeGroupsCleanup` + (ordering) and by `QuiesceLoaderAsync_LoaderCompletes_ReturnsBeforeTimeout` and + `QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs` (both outcomes). `Worker_DoWork` now captures + the task so there is something to await, pinned by `Worker_DoWork_CapturesRemainingLoadTask`. +- *Handlers unregistered before rows are removed.* `UnregisterCancelPathHandlers` runs at stage 6, + `_groups?.Cleanup()` at stage 9. `ActionCancelAsync_UnregistersHandlersBeforeGroupsCleanup` asserts + both the navigation-ledger drain and the form-handler unregistration precede the row removal, by + first index of each marker, with each marker's presence separately asserted so it cannot pass + vacuously. +- *Keyboard-active flag reset.* `ResetKeyboardActive` toggles only when the flag is set, pinned by + `ActionCancelAsync_ResetsKbdActive_WhenKeyboardDialogActive` and its negative control + `_DoesNotToggle_WhenInactive` — the negative control matters, because an unconditional toggle would + *activate* an inactive dialog. +- *WebView2 focus parked and breadcrumb dropdown cancelled on the Cancel path.* + `ParkFocusAndCancelSelectors()` is extracted from `FormViewer_Deactivated` and called at stage 5, + while the item groups still exist. Pinned by + `ActionCancelAsync_ParksFocusAndCancelsBreadcrumbSelectors`, which verifies both the viewer's + `ParkFocusOffWebView2()` and `CancelBreadcrumbSelector()` on each of two item controllers. The #677 + bodies and the `Form.Deactivate` wiring are unchanged; only the extraction is new. +- *Ribbon release callback under a `finally`.* `RunTeardownStage("controller-cleanup", Cleanup)` sits + in the `finally` at `EventHandlers.cs:168-172`, and `QfcHomeController.Cleanup()` invokes + `ParentCleanup` in its own `finally` at `:396-402`. Pinned by + `ActionCancelAsync_GroupsCleanupThrows_StillInvokesParentCleanup` and + `Cleanup_DatamodelCleanupThrows_StillInvokesParentCleanup`, and by + `ActionCancelAsync_CalledTwice_InvokesParentCleanupOnce` for the "exactly once" half. A residual gap + in the middle link is recorded as finding N2 in the code review: `QfcFormController.Cleanup()` calls + `_parentCleanup?.Invoke()` without a `finally`. That file is an explicit AC5 non-goal, so the + criterion as scoped is met; the residual is tracked rather than counted against AC2. +- *Every stage, including any exception, logged.* `RunTeardownStage` logs completion at DEBUG and any + escaping exception at ERROR with the stage name, for all seven wrapped stages plus the two + `QfcHomeController.Cleanup()` blocks and the quiesce await. Entry and completion are logged at INFO. + The 37-minute silent gap the issue reports is directly addressed. +- *Deterministic MSTest.* All eight Cancel-teardown tests and all five datamodel-teardown tests are + headless: mocked viewer, no window shown, no handle created, `FormatterServices.GetUninitializedObject` + to bypass COM constructors, `FakeTimeProvider` for the quiesce bound. +- *HI-1.* The live-Outlook confirmation is outstanding. AC2's own text states it "does not gate the + automated review", the runbook exists at + `runbooks/live-outlook-cancel-teardown-verification.runbook.md`, and it is carried forward as an + unchecked item at `issue.md:109` and in `spec.md` Rollout & Follow-up. It is correctly excluded from + this evaluation and is recorded as PA-5 in the policy audit as owed follow-up. + +### AC3 — every Test Strategy test exists and passes, with the two named fail-before/pass-after pairs — **PASS** + +`evidence/qa-gates/p3-t13-ac3-test-inventory.md` maps all 26 Test Strategy names: 26 mapped to an +existing file, 25 with a passing result, and the 26th being the RibbonController test that Test +Strategy explicitly declines to propose (`spec.md:240`, "Not proposed: any test of +RibbonController.ReleaseQuickFiler"). This reviewer spot-checked the mapping by reading the four new +test files and confirming every named method exists in the file the strategy assigns it to, including +`ActionCancelAsync_DoesNotToggle_WhenInactive`, which is named as a suffix in `spec.md:237` and is +present at `QfcFormControllerCancelTeardownTests.cs:164`. + +Both required fail-before/pass-after pairs are recorded under +`evidence/regression-testing/`: + +- `DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance` — + `p1-t16-gate-fail-before.md` (exit 1, 12 failures, this test failing with an empty accepted + collection) paired with `p2-t14-pass-after.md` (exit 0). +- `TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing` — + `p1-t19-datamodel-teardown-fail-before.md` (exit 1, 5 of 5 red, this test failing with + `System.ArgumentException: Delegate to an instance method cannot have null 'this'`) paired with the + same pass-after artifact. + +The second pair is the stronger form of RED-first evidence: the failure message reproduces the +production log at `issue.md:65` character-for-character, deterministically and without Outlook. The +delivery also discloses honestly that two of the five datamodel tests fail one step earlier than the +plan predicted — in Arrange, on the fail-closed reflective field lookup, rather than in Act on a +`NotImplementedException`. Both remain red before and green after, so the pair's validity is +unaffected, but this reviewer notes those two carry weaker RED-first proof than the two above. + +`p2-t14-pass-after.md` was re-run verbatim against the final build after the `[P2-T15]` architecture +repair, with identical counts, so its `PASS-AFTER` lines describe the delivered code rather than an +intermediate one. This reviewer independently re-ran the whole `QuickFiler.Test` assembly at head: +`Test Run Successful. Total tests: 1362`, exit 0. + +### AC4 — toolchain green, coverage XML produced, changed-file coverage at target with no regression — **PASS** + +- *Toolchain in the CLAUDE.md order with no failure in the final pass.* + `evidence/qa-gates/p3-t6-loop-closure.md` records one restart caused by the first + `csharpier format` rewriting files, then five green steps in one uninterrupted pass with + `FINAL-PASS-ANY-FILE-REWRITTEN: NO`. This reviewer independently re-executed three of the four + steps at head: `csharpier check` (`Checked 1587 files`, exit 0), the analyzer `/t:Rebuild` (exit 0), + the nullable `/t:Rebuild` (`0 Warning(s) 0 Error(s)`, exit 0). `/t:Rebuild` was used rather than + `/t:Build`, so neither gate was skipped by MSBuild incrementality. +- *Coverage XML at `artifacts/csharp/coverage.xml`.* Present, Cobertura, 18,167,952 bytes, written + 2026-09-06 15:05:41, parsed successfully by this reviewer. The path is explicitly permitted by + `.claude/hooks/enforce-evidence-locations.ps1` and is git-ignored, so it is a tool output rather + than committed evidence. +- *Coverage on the changed files at or above the policy target.* 131 executable changed lines, 12 with + zero hits, **90.8%** covered, at or above the `>= 90%` target the repository unit-test policy sets + for new and changed code. All 12 uncovered lines were individually checked by this reviewer against + the code and each is host-bound or contract-defence: the UI `SynchronizationContext` marshal, two + defensive `catch` blocks with no injectable throw source, and one `log.Debug` on the live-Outlook + completion branch. +- *No regression on changed lines.* `CHANGED-LINES-WITH-COVERAGE-REGRESSION: 0`. This reviewer + corroborated it independently at file granularity: aggregating both Cobertura documents with the + same selection, all five measurable changed production files improved or held both their line and + their branch rate (gate 97.54% -> 98.10%, deactivate 100% -> 100% with branch 90% -> 91.67%, + interface 100% -> 100%, `EventHandlers.cs` 49.61% -> 58.12%, `QfcHomeController.cs` 75.85% -> + 76.36%). No file fell. +- *Deviation.* Coverage is collected with `dotnet-coverage collect --output-format cobertura -- + ...` rather than `vstest /EnableCodeCoverage`, because the latter writes a binary + `.coverage` file and not the Cobertura XML this same criterion requires, and the two collectors + conflict when combined. The wrapper uses the same `vstest.console.exe`, the same nine assemblies and + the same switches, and both sides of the comparison were produced by one collector and one + configuration. Disclosed by name as deviation 4 in `spec.md` Rollout & Follow-up. This reviewer + reproduced the delivery's derived percentages from the resulting document by an independent + selection, which is the substantive check. Accepted; AC4's substantive requirement is met. +- *Recorded but not counted against AC4.* Repository-wide first-party line coverage is 84.51%, below + the 85% floor in `.claude/rules/quality-tiers.md` though above the 80% floor in `CLAUDE.md` UT2. + AC4 is scoped to "coverage on the changed files", not to the repository figure, and the branch moves + the repository figure upward (84.50% -> 84.51% line, 79.14% -> 79.19% branch). The FAIL row and its + non-blocking disposition are in `policy-audit.2026-09-06T15-31.md` section 1.2.1. + +### AC5 — scope boundary — **PASS** + +`git diff --name-only 7c8ac9ae..HEAD -- '*.cs' '*.csproj'` returns exactly 17 paths, independently +re-derived by this reviewer: the seven Write Set production files, four new and five modified test +files under `QuickFiler.Test/Controllers`, and `QuickFiler.Test/QuickFiler.Test.csproj` with four +`` additions. `QuickFiler/QuickFiler.csproj` is unchanged, correctly, because the +implementation introduces no new production file. + +All five named exclusions are verifiably absent from the diff: +`QuickFiler/Controllers/QfcCollectionController.cs`, +`QuickFiler/Controllers/QfcHomeController.Iteration.cs`, +`TaskMaster/Ribbon/RibbonController.cs`, +`TaskMaster/Properties/Settings.Designer.cs`, +`TaskMaster/AppGlobals/AppQuickFilerSettings.cs`. +`QuickFiler/Controllers/QfcFormController.SetupDisposal.cs`, the sixth non-goal named in +`spec.md:85`, is also absent. + +The criterion is evaluated over the pathspec `'*.cs' '*.csproj'`. That narrowing is disclosed in the +criterion's own evidence bullet at `spec.md:268` rather than left implicit, and it is necessary: +delivering the fix requires writing evidence artifacts and checking these very boxes, so the criterion +read literally over the whole tree is unsatisfiable by construction. Outside the pathspec the branch +changes only the plan, the spec, `issue.md`, the runbook, the research note, the evidence artifacts, +two promoted potential entries and six agent-memory notes — all of which are the plan's own required +outputs. This reviewer judges the disclosed narrowing correct handling of an over-broad criterion +rather than an unstated relaxation, and records it as Observation N15 in the code review so a later +reader evaluating AC5 literally does not misread it. + +### AC6 — superseded criteria recorded, #446 AC-6 preserved — **PASS** + +- *Recorded under Proposed Fix.* `spec.md:103-105` carries the heading "Superseded prior criteria, + stated deliberately rather than regressed silently" and names both: the #424 criterion at + `docs/features/archive/2026-08-06-quickfiler-high-confidence-queue-init-stall-424/spec.md:231` and + the #608 criterion at + `docs/features/active/2026-08-25-quickfiler-high-confidence-partial-screen-backfill-608/spec.md:184`. +- *Recorded under Data / API / Config Impact.* `spec.md:214` repeats both citations for the reviewer. + Both sections are present, as the criterion requires, and this reviewer read both. +- *#608's surviving criteria protected.* `spec.md:105` states that #608's other criteria (`:181-183`, + `:185`) concern the non-empty prefix and must remain green. + `DequeueAsync_NonEmptyPrefix_UnchangedByCheckpoint` is the pin, and it has real force: it injects a + cap of 2 that is deliberately smaller than the 21 candidates it scans, so a guard widened to + evaluate the bounds after an acceptance would stop early and fail the test. +- *#446 AC-6 preserved.* `QuickFiler/Controllers/QfcHomeController.Iteration.cs` is absent from + `git diff --name-only` at head, independently confirmed by this reviewer, so it is byte-identical to + the base and `CompleteAddingAsync` remains reachable only under `SourceExhausted`. The behavioral + pin is `IterateQueueAsync_EmptyBatchWithScanCapReached_DoesNotCompleteAdding`, which asserts the new + stop reason does not close the queue, alongside its negative control + `IterateQueueAsync_EmptyBatchWithSourceExhausted_CompletesAddingOnce`, which asserts genuine + exhaustion still does. The pair is what makes the preservation verifiable rather than merely + asserted. + +## Summary + +| AC | Verdict | Basis | +|---|---|---| +| AC1 | PASS | Seven deterministic tests covering continuation, exhaustion, both bounds, both log lines, and the #608 pin; the bound-log content is unpinned (finding N3) but the clause is implemented and executed | +| AC2 | PASS | Eight Cancel-teardown and five datamodel-teardown tests covering ordering, the quiesce boundary in both outcomes, the keyboard reset with its negative control, focus parking, exception safety and repeat invocation; HI-1 excluded by the criterion's own text | +| AC3 | PASS | 26 of 26 Test Strategy names mapped to an existing file; 25 passing and the 26th explicitly not proposed; both required fail-before/pass-after pairs recorded, one reproducing the production exception verbatim | +| AC4 | PASS | Toolchain green in one uninterrupted pass with three of four steps re-executed by this reviewer at head; Cobertura present and parsed; 90.8% changed-line coverage; zero changed-line regressions, corroborated at file granularity by an independent aggregation | +| AC5 | PASS | 17 code paths, exactly the Write Set plus permitted test paths; all six named exclusions absent; evaluation pathspec disclosed in the spec | +| AC6 | PASS | Both supersession statements present in both required sections; `QfcHomeController.Iteration.cs` unmodified; the preservation pinned by a test and its negative control | + +**6 of 6 acceptance criteria PASS. 0 PARTIAL. 0 FAIL. 0 UNVERIFIED.** + +Two items are owed but do not affect any verdict: + +1. **HI-1**, the live-Outlook confirmation, is outstanding. AC2 states explicitly that it does not + gate the automated review, and it is carried as an unchecked item at `issue.md:109`. Until it is + performed, the claim that the Outlook keyboard is usable after Cancel in the field is supported by + the mechanism and by unit tests, not by observation. +2. Three defect classes surfaced by this review have no tracked issue and should be promoted before + merge: the disposed-but-not-nulled `_tokenSource` (finding N1), the unprotected + `_parentCleanup?.Invoke()` in `QfcFormController.Cleanup()` (finding N2), and the coverage + exclusion on `QfcDatamodel` (finding N5). All three are recorded with recommendations in + `code-review.2026-09-06T15-31.md`. + +**Recommendation: GO for PR.** + +## Acceptance Criteria Check-off + +All six criteria in the authoritative source file were already `- [x]` at review time, checked off by +the executor with per-criterion evidence bullets. This reviewer re-verified each check-off against the +code and the evidence independently and found every one of them accurate. No criterion required +checking off by this reviewer, and none required un-checking. + +No source file was modified by this review. Per rule 5 of the check-off protocol, no criterion was +added. Per rule 3, no criterion text was altered. + +The narrative copies of AC1 and AC2 at `issue.md:101-102` remain `- [ ]` by design, so that `spec.md` +is the single place of record for `full-bug` work mode. This reviewer confirms that is the correct +state and did not check them. + +### Acceptance Criteria Status + +``` +### Acceptance Criteria Status +- Source: docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/spec.md +- Total AC items: 6 +- Checked off (delivered): 6 +- Remaining (unchecked): 0 +- Items remaining: none +``` diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/issue.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/issue.md new file mode 100644 index 000000000..3307dc1c6 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/issue.md @@ -0,0 +1,167 @@ +# quickfiler-high-confidence-cancel-teardown-and-deadline-defects (Issue #791) + +- Date captured: 2026-09-06 +- Author: Dan Moisan +- Status: Promoted -> docs/features/active/quickfiler-high-confidence-cancel-teardown-and-deadline-defects/ (Issue #791) + +> Automation note: Keep the section headings below unchanged; the promotion tooling maps each of them into the GitHub bug issue template. + +- Issue: #791 +- Issue URL: https://github.com/drmoisan/TaskMaster/issues/791 +- Last Updated: 2026-09-06 +- Work Mode: full-bug + +## Summary + +Two defects observed while running QuickFiler in High Confidence mode on 2026-09-06 against the build of `7c8ac9ae`. (1) A High Confidence run whose first 12 seconds of scanning finds no item at or above the cutoff opens an empty dialog, and because scan order follows the Explorer view the same view produces the same empty dialog on every rerun. (2) The Cancel teardown does not shut QuickFiler down cleanly: the background queue loader outlives Cancel and crashes on fields that cleanup has already nulled, the keyboard-active flag and WebView2 focus are never reset on the Cancel path, the teardown chain has no `try`/`finally`, and the whole path emits no log output, which left a 37 minute unexplained gap during which the Outlook keyboard was locked. + +## Environment + +- OS/version: Windows 11 Pro 10.0.26200 +- Python version: n/a (C# / .NET Framework 4.8 VSTO add-in) +- Command/flags used: QuickFiler launched from the ribbon High Confidence button; `HighConfidenceThreshold` at the designer default 0.9 (never changed in any `user.config` on the machine); `HighConfidenceModeEnabled` toggled by the ribbon launch path +- Data source or fixture: live Outlook Inbox view; add-in loaded from `TaskMaster\bin\Debug` built 2026-09-06 08:51 from `7c8ac9ae` + +## Steps to Reproduce + +Defect 1 (deadline policy, deterministic for a given view): +1. Arrange an Explorer view whose first roughly 40 items in view order all score below 900 per-mille while later items score above it. +2. Launch QuickFiler via the High Confidence ribbon button. +3. Observe the dialog open with zero rows after roughly 20 seconds. +4. Cancel and relaunch via the same button; observe the same empty dialog. + +Defect 2 (Cancel teardown, sporadic): +1. Launch QuickFiler via the High Confidence ribbon button and file one round of suggestions. +2. File a second round, then press Undo repeatedly (24 undo clicks were logged between 09:04:05 and 09:05:53). +3. Press Cancel. +4. Observe the Outlook keyboard is unusable in the native Outlook window. In a separate run the same Cancel left the background loader running until it crashed 4 seconds after the next launch. + +## Expected Behavior + +- A High Confidence run that has scored items but found none at or above the cutoff within the first-batch deadline keeps scanning until the first acceptance or until the candidate queue is exhausted, subject to a hard cap on scanned items, and reports progress. It never opens an empty dialog while unscanned candidates remain. +- The cutoff in effect and the scan progress are logged at launch and at every deadline decision. +- Cancel performs a complete, ordered teardown: cancellation is signalled, the background loader is stopped and awaited before any datamodel field is nulled, form and item keyboard handlers are unregistered before the item rows are removed, the keyboard-active flag is reset, WebView2 focus is parked and any open breadcrumb dropdown is cancelled (the same routine that `FormViewer_Deactivated` runs), and the ribbon release callback runs even if an earlier step throws. +- Every stage of the Cancel teardown writes a log line through the existing log4net pattern, including any exception, so a future sporadic occurrence can be read from the log. + +## Actual Behavior + +- `QfcStreamingDequeueConfidenceGate.DequeueAsync` returns `DeadlineExpired` with an empty accepted list when `accepted.Count == 0` after 12 seconds, and `QfcHomeController.RunAsync` loads zero rows. Three runs today logged `First-batch deadline expired [DequeueAsync] Accepted=0 Scanned=38|44|42 Deadline=00:00:12` (09:43:54, 09:45:36, 10:08:06). Scores were real, not zero: those runs peaked at 928 and 960 after the deadline had already expired, while accepting runs peaked at 997 to 1000. The cutoff (900) is never logged. +- After Cancel, `QfcDatamodel.Cleanup()` cancels the token and calls `worker.CancelAsync()` but does not await `LoadRemainingEmailsToQueueAsync`, then nulls `_moveMonitor`, `_globals`, `_masterQueue`, and `_worker`. The still-running loader then throws at `QfcDatamodel.cs:355-358` (`new QfcRemainingQueueAdmission(_masterQueue.AddLast, _moveMonitor.HookItem, ...)`): `ERROR QfcDatamodel - LoadRemainingEmailsToQueue Error. Delegate to an instance method cannot have null 'this'.` followed by `Error in Worker_DoWork` (log 2026-09-06 10:08:10.910 and 10:08:10.985, the last two lines of the file). +- `ActionCancelAsync` (`QfcFormController.EventHandlers.cs:84-93`) calls `_parent?.TokenSource?.Cancel()`, awaits the UI sync context, hides the form, then `_groups?.Cleanup()` and `Cleanup()`. It does not reset `KbdActive` (the OK path does, `EventHandlers.cs:125-128`), does not call `ParkFocusOffWebView2()` or `CancelBreadcrumbSelector()` (both exist only in `QfcFormController.Deactivate.cs:26-58`, wired to `FormDeactivated`, which the Cancel path unsubscribes), and has no `try`/`finally`. `ButtonCancel_Click` is `async void`, so an exception escaping `ActionCancelAsync` is lost. +- `QfcFormController.Cleanup()` (`SetupDisposal.cs:213-261`) calls `UnregisterFormEventHandlers()` after `_groups.Cleanup()` has already removed the item rows from the table layout, so the recursive `Controls.ForAllControls` unsubscribe no longer reaches the item controls' `PreviewKeyDown`/`KeyDown` subscriptions added at `SetupDisposal.cs:156-168`. The guard at `:180-183` also returns early when `_formViewer?.Controls` or `_parent?.KeyboardHandler` is already null. +- `QfcHomeController.Cleanup()` (`QfcHomeController.cs:370-379`) calls `_datamodel.Cleanup()` and then `ParentCleanup.Invoke()` with no `try`/`finally`; if the datamodel cleanup throws, `RibbonController.ReleaseQuickFiler()` never runs, `_quickFilerLoaded` stays true, and both ribbon buttons become no-ops. `_tokenSource` is never disposed and `Worker_RunWorkerCompleted` is never detached. +- The Cancel path, `QfcDatamodel.Cleanup()`, and `ParkFocusOffWebView2()` contain no logging. After the 09:05:53 undo burst the log is silent for 37 minutes 39 seconds until the next launch at 09:43:32 (no restart; Outlook restarted only at 09:53:24). + +## Logs / Screenshots + +- [x] Attached minimal logs or screenshot +- Snippet (from `TaskMaster\bin\Debug\logs\debug_2026-09-06.log`): + +``` +2026-09-06 09:43:54,214 [44] DEBUG QfcStreamingDequeueConfidenceGate - First-batch deadline expired [DequeueAsync] Accepted=0 Scanned=38 Deadline=00:00:12 +2026-09-06 09:45:36,727 [53] DEBUG QfcStreamingDequeueConfidenceGate - First-batch deadline expired [DequeueAsync] Accepted=0 Scanned=44 Deadline=00:00:12 +2026-09-06 10:08:06,149 [29] DEBUG QfcStreamingDequeueConfidenceGate - First-batch deadline expired [DequeueAsync] Accepted=0 Scanned=42 Deadline=00:00:12 +2026-09-06 10:08:10,910 [5] ERROR QuickFiler.Controllers.QfcDatamodel - LoadRemainingEmailsToQueue Error. + Delegate to an instance method cannot have null 'this'. + at System.MulticastDelegate.CtorClosed(Object target, IntPtr methodPtr) + at QuickFiler.Controllers.QfcDatamodel.d__41.MoveNext() ... QfcDatamodel.cs:line 355 + at QuickFiler.Controllers.QfcDatamodel.d__40.MoveNext() ... QfcDatamodel.cs:line 330 +2026-09-06 10:08:10,985 [5] ERROR QuickFiler.Controllers.QfcDatamodel - Error in Worker_DoWork Delegate to an instance method cannot have null 'this'. +``` + +Timeline evidence (same log): launches at 08:52:09 (accepted, rows at 08:53:39), 09:43:32 (Accepted=0), 09:45:14 (Accepted=0), 10:04:26 (accepted), 10:05:12 (accepted, relaunch 46 s after the previous), 10:07:45 (Accepted=0). Undo burst 09:04:05 to 09:05:53, then no log output until 09:43:32. + +## Impact / Severity + +- [ ] Blocker +- [x] High +- [ ] Medium +- [ ] Low + +High: the deadline defect makes High Confidence mode unusable for any view whose top-scoring items are not near the front, with no message and no recovery other than filing items some other way. The teardown defect can leave the whole Outlook keyboard unusable until Outlook is restarted, and the surviving background loader crashes against the next launch's state. + +## Suspected Cause / Notes + +- Gate loop `QfcStreamingDequeueConfidenceGate.cs:168-237`: the deadline is checked only while `accepted.Count == 0`; `scanned++` at `:205` runs only after `_scoreLoader` returns, so `Scanned=N Accepted=0` means N real scores all below `_cutoff` (`:129`, per-mille). Scan order is `_masterQueue.TryTakeFirst()` (`QfcDatamodel.QueueProcessing.cs:185`), populated from the Explorer view (`QfcDatamodel.FrameBuilding.cs:13-67`), so the outcome is a function of view order and scoring throughput (about 2 to 3 items per second observed). Rejected items are dropped from the queue for the session (`:211-222`). +- The 12 second first-batch deadline was introduced by #424 and adjusted by #446 and #608; those changes handled the post-UI iteration and the undersized-batch cases, not the zero-accepted first batch. +- `Worker_DoWork` (`QfcDatamodel.cs:175-213`) is `async void`; `BackgroundWorker.IsBusy` goes false at its first await while production continues, and `LoadRemainingEmailsToQueueAsync` observes the token only at `:322` and `:324`. +- Keyboard mechanism: no `SetWindowsHookEx`, `AddMessageFilter`, or `KeyPreview` exists anywhere in the repo (confirmed again today). #677 identified WebView2 focus retention and an open breadcrumb `ToolStripDropDown` as the mechanism and fixed it on the `Form.Deactivate` path only. `AlwaysOnKeyActionsAsync` (`KeyboardHandler.cs:155-160`) suppresses keys regardless of `KbdActive`. +- The breadcrumb WebView2 failed to initialize twice today (`WebView2BreadcrumbHost - Breadcrumb CoreWebView2 initialization failed ... 0x8007139F` at 08:55:22 and 10:06:51). That is a separate defect, filed as its own potential entry, and is not in scope here. +- Related closed issues: #424, #446, #608 (deadline lineage), #677 (Deactivate focus fix), #731 (controller lifecycle disposal), #737 (breadcrumb keyboard navigation). All are closed and their fixes are on `7c8ac9ae`. +- Unknown: whether the 09:05 keyboard lock cleared on Escape, on focus change, or only on restart. The user could not reproduce it. + +## Proposed Fix / Validation Ideas + +- [ ] Unit coverage areas: gate behavior when the deadline expires with zero accepted and candidates remain (continue, hard cap, exhaustion); cutoff and progress logging; `ActionCancelAsync` ordering (token, loader awaited, handlers unregistered before rows removed, `KbdActive` reset, focus parked, breadcrumb selector cancelled, release callback invoked under exception); `QfcDatamodel.Cleanup()` awaiting the loader before nulling fields; `QfcHomeController.Cleanup()` invoking `ParentCleanup` under a `finally`. +- [ ] Integration scenario to retest: High Confidence launch against a view whose first 40 items score below cutoff; Cancel after an undo burst; relaunch after Cancel. +- [ ] Manual verification notes: record a live-Outlook evidence note as was done for #677; confirm the new Cancel-path log lines appear and that no `null 'this'` error follows a Cancel. + +## Acceptance Criteria + +- [ ] AC1: A High Confidence run that has found no item at or above the cutoff when the first-batch deadline expires continues scanning until the first acceptance, until the candidate queue is genuinely exhausted, or until a hard bound is reached (a cap on items scanned without acceptance, plus a time ceiling that bounds the wait while the background loader is still refilling). An empty dialog is permitted only on exhaustion or at the bound, and the bound decision is logged. The cutoff in effect and the scanned/accepted counts are logged at launch and at each deadline decision. Covered by deterministic MSTest regression tests using a fake time provider. +- [ ] AC2: The Cancel teardown completes cleanly and in order: the background loader is stopped and awaited before any datamodel field is nulled; form and item keyboard handlers are unregistered before item rows are removed; the keyboard-active flag is reset; WebView2 focus is parked and any open breadcrumb dropdown is cancelled on the Cancel path; the ribbon release callback runs under a `finally`; and every stage, including any exception, is logged. Covered by deterministic MSTest regression tests. The live-Outlook confirmation (keyboard usable after Cancel, new log lines present, no null-`this` loader error) is a human follow-up performed per `runbooks/live-outlook-cancel-teardown-verification.runbook.md`, recorded as human-interaction exception HI-1, and does not gate the automated review. + +## Next Step + +- [x] Promote to GitHub issue (bug-report template) +- [x] Move to active fix folder / branch +- [x] Implement the fix and record evidence (2026-09-06; see Outcome below) +- [ ] Human live-Outlook confirmation per `runbooks/live-outlook-cancel-teardown-verification.runbook.md` (human-interaction exception HI-1; does not gate the automated review) + +## Outcome + +Implemented on 2026-09-06 on branch +`bug/quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791`. + +Both reported defects are fixed and pinned by deterministic MSTest regression tests. + +**Defect 1 — the deadline policy.** The first-batch deadline is now an advisory checkpoint rather +than a return. When it expires with zero acceptances the gate logs the cutoff, the scanned and +accepted counts, the elapsed time and the remaining headroom on both bounds, resets the checkpoint +interval, and keeps scanning. Two hard bounds terminate the extended scan — a cap of 250 candidates +scored without an acceptance, and a 120-second ceiling that bounds the wait while the background +loader is still refilling — and a bounded exit is reported as the new stop reason +`QfcDequeueStop.ScanCapReached`, which callers treat exactly as they treated `DeadlineExpired`: the +UI queue stays open. A launch line now records the cutoff (the reported 900 was never logged), the +requested quantity, the checkpoint interval and both bounds. Both bounds are internal constants with +constructor test seams and introduce no settings surface. + +**Defect 2 — the Cancel teardown.** `ActionCancelAsync` is reordered and made exception-safe: it +logs entry, cancels the token before its first await, marshals to the UI context, resets the +keyboard-active flag, parks WebView2 focus and cancels every breadcrumb selector through a routine +extracted from the `Form.Deactivate` handler, unregisters navigation and form handlers before the +item rows are removed, hides the form, awaits the new `IQfcDatamodel.QuiesceLoaderAsync` before any +datamodel field is nulled, cleans up the groups, and reaches `Cleanup()` — and through it +`RibbonController.ReleaseQuickFiler` — from a `finally`. Every stage runs through a helper that logs +its completion at DEBUG and any escaping exception at ERROR with the stage name, so no stage is +silent and a throwing stage cannot skip a later one. `Worker_DoWork` now captures the loader task so +there is something to await; `TryQueueRemainingMailItemAsync` snapshots and guards `_masterQueue` +and `_moveMonitor` and returns `false` instead of constructing a delegate over a null instance, which +is the exact `ArgumentException` the attached log records; `QfcDatamodel.Cleanup()` is null-guarded +so a second Cancel is inert; and `QfcHomeController.Cleanup()` is two guarded blocks under a +`finally` that also disposes the token source and detaches the worker-completed handler. +`ButtonCancel_Click` no longer rethrows — a deliberate behaviour change, since an `async void` +rethrow becomes an unhandled Outlook UI-thread exception that reports nothing actionable, which the +stage-level ERROR logging replaces. + +**Verification.** 7023 tests passed with 0 failures across the nine first-party test assemblies; +`QuickFiler.Test` alone went from 1339 to 1362 passing with no newly-failing test. The toolchain +passed in the CLAUDE.md order in one uninterrupted final pass: 1587 files formatter-clean, 0 +analyzer warnings and errors, 0 nullable warnings and errors. First-party line coverage moved from +84.50 % to 84.51 % and branch coverage from 79.14 % to 79.19 %; no changed line lost coverage, and +90.8 % of the executable changed lines are covered. + +**Acceptance criteria.** All six are checked off in this feature folder's `spec.md`, which is the +sole authoritative acceptance-criteria source for this work. The two criteria restated in this file +above are a narrative copy and are deliberately left unchecked so there is one place of record. + +**Superseded criteria**, stated deliberately rather than regressed silently: the #424 criterion at +`docs/features/archive/2026-08-06-quickfiler-high-confidence-queue-init-stall-424/spec.md:231` and +the #608 criterion at +`docs/features/active/2026-08-25-quickfiler-high-confidence-partial-screen-backfill-608/spec.md:184` +are both superseded by #791 AC1. #446 AC-6 is preserved: `QfcHomeController.Iteration.cs` is +unmodified and `CompleteAddingAsync` remains reachable only under `SourceExhausted`. + +**Still open.** The live-Outlook confirmation is a human follow-up (HI-1) and does not gate the +automated review. Issue #792 tracks the breadcrumb WebView2 initialization failure (0x8007139F), +which is out of scope here. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/plan.2026-09-06T12-57.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/plan.2026-09-06T12-57.md new file mode 100644 index 000000000..e3eaf7ff8 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/plan.2026-09-06T12-57.md @@ -0,0 +1,719 @@ +# 2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects (Plan) + +- **Issue:** #791 +- **Parent (optional):** none +- **Owner:** drmoisan +- **Last Updated:** 2026-09-06T12-57 +- **Status:** Ready for preflight +- **Version:** 1.0 +- **Work Mode:** full-bug (resolved from `issue.md` line 12 and `spec.md` line 9) +- **Language in scope:** C# only (`QuickFiler`, `QuickFiler.Test`; legacy non-SDK projects with explicit `` items) +- **Authoritative AC source:** `docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/spec.md` — "Acceptance Criteria", AC1 through AC6. `user-story.md` is narrative context only and is not an AC source or a check-off target. + +**Fail-closed evidence rule:** Every baseline, regression, and QA artifact named by a task must exist with all required fields before that task may be checked off. A missing or field-incomplete artifact makes the outcome BLOCKED or INCOMPLETE, never PASS. + +**Evidence accounting rule:** Each evidence-producing task names its exact artifact path. Work is not complete without the artifact. + +--- + +## Plan-wide rules + +**R1 — Evidence location (non-overridable).** Every evidence artifact is written under +`docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence//` +with `` in `baseline`, `regression-testing`, `qa-gates`, `issue-updates`, `other`. Below, `` abbreviates +`docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791`. No caller supplied a +non-canonical evidence path, so no `EVIDENCE_LOCATION_OVERRIDE_REJECTED` record is required by this plan. +`artifacts/csharp/coverage.xml` is a tool output document, not an evidence artifact; `.claude/hooks/enforce-evidence-locations.ps1` +lines 22-26 name `artifacts/csharp/` as an explicitly permitted path, and it is not in the forbidden prefix list at lines 64-74. + +**R2 — Evidence artifact schema.** Every command-bearing task writes an artifact containing, at minimum, the literal field +lines `Timestamp:` (format `yyyy-MM-ddTHH-mm`), `Command:`, `EXIT_CODE:`, and `Output Summary:`. A task whose command is +expected to exit non-zero additionally writes `ExpectedExitCode: 1`. + +**R3 — Evidence path hygiene.** No artifact may contain an absolute host path or a host account name. Replace a repository +root with ``, a user profile segment with ``, and a machine name with ``. This applies to tool +stdout, MSBuild logs, stack traces, Cobertura `filename` values, and TRX content alike. `QuickFiler.Test/QuickFiler.Test.csproj` +line 34 sets `full`, so Debug stack traces carry full source paths. TRX files carry `runUser` and +`computerName` attributes; never paste raw TRX content into an artifact — record only parsed counter values. The one +deliberate exception is the `vswhere`-resolved `vstest.console.exe` path that [P0-T6] is required to record; that value is +recorded in full because the task exists to pin it. + +**R4 — Token-assertion case rule.** Every token-presence or token-absence assertion in this plan is case-sensitive. +Use `Select-String -CaseSensitive -SimpleMatch` or `git grep` without `-i`. PowerShell `-match` and a bare `Select-String` +are case-insensitive and must not be used for these gates. + +**R5 — Named tests before phrase searches.** Where an acceptance condition can be carried by a named MSTest method, the +condition is stated as that method passing. Phrase searches are used only where no test can express the condition, and +every such literal is quoted verbatim in this document outside its command span. + +**R6 — Base reference.** [P0-T2] records `BASE-SHA` (the commit at plan start) into +`/evidence/baseline/p0-t2-branch-commit.md`. Every later `git diff` in this plan uses that recorded value as its +ref operand. No SHA is pinned as a literal expectation in this document. + +**R7 — Scope pathspec for AC5.** AC5 says the branch diff "touches no file outside the Write Set". Read literally over the +whole tree it is unsatisfiable, because this plan is required to write evidence artifacts under `/evidence/` and +to check off AC boxes in `spec.md`. AC5 is therefore evaluated over the source pathspec `'*.cs' '*.csproj'` only. Every +AC5 gate in this plan carries that pathspec, a `git add --intent-to-add` companion so newly created files are visible to +an anchored diff, and a `git status --porcelain --untracked-files=all` companion. + +**R8 — 500-line ceiling, `.cs` only.** `.claude/rules/general-code-change.md` caps production code, test code and reusable +script files at 500 lines. It does not reach `*.csproj`: `.csharpierignore` lines 9-14 record that project files are owned +by Visual Studio and are not C# source, and `QuickFiler.Test/QuickFiler.Test.csproj` already stands at 524 lines. Every +ceiling assertion in this plan is therefore scoped to `.cs` paths, and the project file's count is recorded as an exempt +observation rather than asserted. Baseline counts are captured by [P0-T13] and re-measured after the final format by +[P3-T9]. The tightest `.cs` files are named in the Decisions Record. + +**R9 — MSBuild command forms.** The two gate builds use exactly the CLAUDE.md commands, with `/t:Rebuild` and without +`/p:Nullable=enable`. Iterative builds inside Phases 1 and 2 use `/t:Build` with no `/p:` gate switches; those builds +exist to produce test assemblies, not to run gates, and every source edit changes a timestamp so `CoreCompile` is not +skipped. A project-file build, if ever needed, must use `/p:Platform=AnyCPU`; the quoted `"/p:Platform=Any CPU"` form is a +solution-level alias only. + +**R10 — Shell-variable re-binding (non-optional).** No variable survives between tasks: every command block runs in its own +shell. A block that uses `$vstest` must be preceded, in that same block, by the two resolution lines that [P0-T6] pins. A +block that uses `$BaseSha` must be preceded, in that same block, by a binding that resolves to the 40-hexadecimal value +[P0-T2] recorded as `BASE-SHA`, with no placeholder token left in the command. An unbound `$BaseSha` degrades an anchored +`git diff --name-only` into the ref-less form, which compares the worktree against the index and passes vacuously once the +change is committed, so the binding is load-bearing rather than cosmetic. The two preambles are: + +```powershell +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vstest = & $vswhere -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 +$BaseSha = (Select-String -Path 'docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t2-branch-commit.md' -CaseSensitive -Pattern '^BASE-SHA: ([0-9a-f]{40})$').Matches[0].Groups[1].Value +``` + +The `$BaseSha` binding reads the recorded value out of the [P0-T2] artifact rather than carrying a hand-typed literal. That +substitutes the recorded value exactly, leaves no placeholder in the plan text, and fails loudly if [P0-T2] never ran or +recorded a malformed value — which a pasted literal would not. An executor that prefers to paste the recorded 40-hexadecimal +value directly after [P0-T2] has run satisfies this rule equally. Each affected task's `Output Summary:` records the +resolved `$vstest` path reduced per R3 and the `$BaseSha` value it bound. + +--- + +## Decisions Record + +**D1 — `QfcDatamodel` is excluded from coverage measurement.** `QuickFiler/Controllers/QfcDatamodel.cs` line 25 carries +`[ExcludeFromCodeCoverage]` on the partial class declaration. The attribute applies to the whole type, so members declared +in `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` (which declares `public partial class QfcDatamodel` at line 12) +are excluded too. `QuickFiler/Controllers/QfcScanProgressBandMapper.cs` line 12 records the same fact in prose. Changed-line +coverage for those two files is therefore structurally unmeasurable rather than merely low. [P0-T11] turns this into a +decidable determination against the baseline Cobertura document instead of an assumption, and [P3-T8] compares only the +files that determination reports as measurable. Named-test evidence is the substitute for the unmeasurable files. + +**D2 — Retargeting surface is larger than `spec.md` Test Strategy names.** `spec.md` lines 230-234 name four retargeting +obligations. Reading every deadline-dependent gate test against the AC1 design found three more that also encode the +superseded behaviour and will fail after the change. The complete set, re-derived in this pass, is recorded in the Citation +table and drives tasks [P1-T8] through [P1-T11]. Retargeting a test that AC3 does not name is permitted: AC3 requires the +named tests to exist and pass, and AC5 permits changes to test files under `QuickFiler.Test/Controllers`. +Two further test files reference the deadline surface and are deliberately excluded from the retargeting set: +`QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.cs` lines 171 and 221 assert that `RunAsync` +forwards `DefaultFirstBatchDeadline`, which this plan does not change, and +`QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.Part2.cs` line 198 returns +`QfcDequeueStop.DeadlineExpired` from a `Mock`, which stays a valid enum member. Neither constructs the gate, +so neither observes the AC1 behaviour change. + +**D3 — `IFilerFormController.cs` line 11 declares `Task ActionCancelAsync();`.** An optional `trigger` parameter would not +satisfy that interface member (C# requires an exact signature match), and `QuickFiler/Interfaces/IFilerFormController.cs` +is outside the Write Set, so AC5 forbids changing it. The Logging Plan's "trigger (button vs. completion path)" discriminator +is therefore supplied by call-site logging inside the Write Set instead: the error path already logs at +`QuickFiler/Controllers/QfcFormController.EventHandlers.cs` lines 167-168, and [P2-T11] adds one `log.Debug` line +immediately before the completion-path call at line 208. `ActionCancelAsync` keeps its zero-parameter signature. + +**D4 — Token cancellation must stay before the first `await`.** +`QuickFiler.Test/Controllers/QfcFormControllerSeamTests.cs` lines 163-179 +(`CancelClicked_WhenRaised_CancelsParentTokenSource`) raises the viewer's `CancelClicked` event and asserts the parent +token is cancelled by the time `Mock.Raise` returns. That holds today only because `ActionCancelAsync` reaches +`_parent?.TokenSource?.Cancel()` synchronously. The reordered method must keep the cancel stage ahead of the +`await _formViewer.UiSyncContext` marshal. + +**D5 — Null-conditional access throughout the Cancel path.** +`QuickFiler.Test/Controllers/QfcFormControllerTests.cs` lines 392-403 (`ButtonCancel_Click_ShouldCancelAction`) awaits +`ActionCancelAsync()` against loose mocks in which `IQfcHomeController.KeyboardHandler` and `IQfcHomeController.DataModel` +both resolve to `null`. Every new dereference on the Cancel path must be null-conditional, and the awaited quiesce must be +captured into a local and awaited only when non-null, or that existing test starts throwing. + +**D6 — No `_cancelTeardownStarted` flag.** Repeat invocation is already inert after the fix: the first pass nulls +`_parent`, `_groups`, `_formViewer` and `_parentCleanup` (`QuickFiler/Controllers/QfcFormController.SetupDisposal.cs` +lines 250-260), the unregister guard at lines 180-183 returns early, and `undoQueue?.CompleteAdding()` is already wrapped +against `ObjectDisposedException` at lines 223-230. Adding a flag would add state for a property the type already has. The +claim is pinned by an added test rather than asserted, and the extracted deactivate routine is null-guarded (see D7) so the +second pass cannot raise an ERROR line. + +**D7 — Extraction invalidates its own sibling remark.** +`QuickFiler/Controllers/QfcFormController.Deactivate.cs` lines 22-25 state that no `_formViewer` null guard is written +"because this handler is reachable only through `_formViewer.FormDeactivated`". Calling the extracted routine from the +Cancel path makes that sentence false, so [P2-T8] both adds the guard and rewrites the remark. + +**D8 — Bounds are gated by `deadlineEnabled`.** The scan cap and the time ceiling are evaluated inside the same +`deadlineEnabled` guard as the checkpoint. `Timeout.InfiniteTimeSpan` therefore continues to mean "no bound at all", which +is what `QfcStreamingDequeueConfidenceGateTests.Part2.cs` lines 271-312 +(`DequeueAsync_DisabledSentinel_ReproducesUnboundedPreChangeBehavior`) pins. Production never passes the sentinel: +`QuickFiler/Controllers/QfcHomeController.cs` line 303, `QuickFiler/Controllers/QfcHomeController.Iteration.cs` line 25 and +`QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` line 75 (the two-argument `DequeueNextItemGroupAsync` overload) all +pass `DefaultFirstBatchDeadline`. + +**D9 — New gate bounds are exposed as internal get-only auto-properties, not private fields.** The Phase 1 seam adds the two +constructor parameters before the Phase 2 loop reads them. A `private readonly` field assigned and never read raises CS0414, +which `/p:TreatWarningsAsErrors=true` would promote to an error. An internal get-only auto-property has a compiler-generated +backing field read by its getter and raises no such warning, so the seam is warning-clean at every point in the plan. + +**D10 — `QuiesceLoaderAsync` needs an injected log seam.** `spec.md` line 239 requires +`QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs` to observe the log. `QfcDatamodel` logs through `log4net`, and no +memory-appender convention exists anywhere in `QuickFiler.Test`; attaching one would mutate a process-global logger +repository and break test independence. The gate already establishes the alternative convention — an injected +`Action debugLog` asserted directly (`QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs` lines 69, 131, +248, 258). [P1-T2] adds `internal Action QuiesceDebugLog { get; set; }` to +`QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs`, mirroring that convention. It is `internal`, the assembly already +grants `InternalsVisibleTo("QuickFiler.Test")` at `QuickFiler/Controllers/QfcHomeController.cs` line 15, and it widens no +public surface. + +**D11 — `QfcHomeController.cs` headroom forces two guarded blocks, not three.** The file is 469 lines, leaving 31 lines to +the ceiling. Three separate `try`/`catch` blocks plus the `finally` measure out at roughly 505 lines. [P2-T12] therefore +uses two guarded blocks — one for the worker-completed detach, one covering the datamodel cleanup, the token-source +dispose and the field nulling — with `ParentCleanup` under `finally`. That satisfies AC2's two testable requirements (the +release callback runs under `finally`; every stage including any exception is logged) and both named tests, within the +ceiling. + +**D12 — `ButtonCancel_Click_ActionThrows_DoesNotRethrow` is driven from the click handler's own body.** After [P2-T10] +every teardown stage is individually caught, so `ActionCancelAsync` no longer offers a throw source. The test instead nulls +the private `_formViewer` field so `SynchronizationContext.SetSynchronizationContext(_formViewer.UiSyncContext)` at +`QuickFiler/Controllers/QfcFormController.EventHandlers.cs` line 74 raises `NullReferenceException` inside the handler's +own `try`. Today line 80 rethrows it and the raise escapes; after [P2-T11] it is logged and swallowed. The test is +false-before and true-after against exactly the line the fix changes. + +**D13 — `dotnet-coverage`, not `/EnableCodeCoverage`.** AC4 requires Cobertura XML at `artifacts/csharp/coverage.xml`. +`vstest.console.exe /EnableCodeCoverage` writes a binary `.coverage` file, and the two collectors conflict, so the coverage +runs use `dotnet-coverage collect --output-format cobertura -- ...` exactly as the most recent completed C# feature +did (`/docs/features/active/2026-09-05-pr-778-post-merge-review-residuals-782/evidence/qa-gates/r-p4-t5-tests-coverage.md` +lines 21-35 for the command, and lines 80-87 plus +`.../evidence/remediation-baseline/r-p0-t10-tests-coverage.md` lines 85-108 for the aggregation snippet and its observed +success-case output `LINES_COVERED=112351 LINES_VALID=132961 BRANCHES_COVERED=26498 BRANCHES_VALID=33480`). + +**D14 — Comparability, not a repository-wide rate.** The repository-wide Cobertura `line-rate` is not a stable gate on this +harness. The coverage comparison in [P3-T8] is made on the four first-party counters produced by one pinned aggregation +over both documents, exactly as issue #782 did, with `lines-valid` equality as the comparability precondition. + +**D15 — Assembly discovery excludes `\.claude\` by construction.** The nine first-party test assemblies are named +explicitly on every run command. A path that is never enumerated cannot be loaded, so no worktree under a `.claude` +segment can enter a run. + +--- + +## Citation table (re-derived against the current tree in this pass) + +| Repository-relative path | Locator re-derived | +|---|---| +| `QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs` | 262 lines; `DefaultFirstBatchDeadline` :56; nine-parameter ctor :111-125; `_cutoff` :129; scan loop :168-237; zero-accept deadline branch :172-180; empty-queue wait :183-196; `scanned++` :205; `LogDeadlineExpiry` :242-250; `LogScore` :252-260 | +| `QuickFiler/Interfaces/IQfcDatamodel.cs` | 133 lines; `QfcDequeueStop` :30-40 with `DeadlineExpired` :38-39; `QfcDequeueBatch` struct :49-81; `IQfcDatamodel` :83-132; `void Cleanup();` :131 | +| `QuickFiler/Controllers/QfcDatamodel.cs` | 480 lines; `[ExcludeFromCodeCoverage]` :25; `Cleanup()` :75-91 with unguarded `_globals.Ol.App` :79 and `_moveMonitor.UnhookAll()` :80; `RemainingEmailLoader` :130; `Worker_DoWork` :175-213 with `e.Result = await RemainingEmailLoader(_token);` :191; `LoadRemainingEmailsToQueueAsync` :307-348; `TryQueueRemainingMailItemAsync` :350-361 with the admission construction :355-359 | +| `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` | 298 lines; `public partial class QfcDatamodel` :12; `_remainingLoadActive` :23; pre-existing `UndoMove()` :25-29 with `throw new NotImplementedException();` :28; two-argument `DequeueNextItemGroupAsync` delegating with `DefaultFirstBatchDeadline` :75; `DequeueWithHighConfidenceGateWithOutcomeAsync` :177-200 with the gate construction :184-194, which passes neither new bound; `WaitForQueue` :289-296 | +| `QuickFiler/Controllers/QfcFormController.EventHandlers.cs` | 408 lines; `ButtonCancel_Click` :70-82 with `throw;` :80; `ActionCancelAsync` :84-94; OK-path keyboard reset :125-128; `MoveAndIterate` error-path cancel :169 and completion-path cancel :208 | +| `QuickFiler/Controllers/QfcFormController.Deactivate.cs` | 60 lines; remark "a null-viewer branch would be unreachable code" :24; `FormViewer_Deactivated` :26-58; `_formViewer.IsWebView2Focused` :28; per-item catch :45-56 | +| `QuickFiler/Controllers/QfcHomeController.cs` | 469 lines; `InternalsVisibleTo("QuickFiler.Test")` :15; `Worker_RunWorkerCompleted` subscription :91 and :131; `RunAsync` outcome call :300-305; `Worker_RunWorkerCompleted` :343-368; `Cleanup()` :370-379; `_tokenSource` :442 | +| `QuickFiler/Controllers/QfcHomeController.Iteration.cs` | `IterateQueueAsync` :12-65; `CompleteAddingAsync` only under `SourceExhausted` :39-48 (unmodified by this plan) | +| `QuickFiler/Interfaces/IFilerFormController.cs` | `Task ActionCancelAsync();` :11 | +| `QuickFiler/Interfaces/IQfcCollectionController.cs` | `ItemGroups` :17; `UnregisterNavigation()` :109; `Cleanup()` :116 | +| `QuickFiler/Interfaces/IQfcKeyboardHandler.cs` | `KbdActive` :11; `ToggleKeyboardDialog()` :12 | +| `QuickFiler/Interfaces/IQfcFormViewer.cs` | `UiSyncContext` :17; `Worker` :18; `IsWebView2Focused` :64; `ParkFocusOffWebView2()` :70 | +| `QuickFiler/Controllers/IQfcHomeController.cs` | `IQfcDatamodel DataModel { get; }` :11 | +| `QuickFiler/Controllers/QfcRemainingQueueAdmission.cs` | three-delegate ctor :14-24; `TryQueueAsync` :26-38 | +| `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs` | 477 lines; `CreateGate` reflection helper :27-92 with the exact nine-type array :56-71 and the fail-closed assert :74-77; second `CreateGate` overload :94-121; `DequeueBatchAsync` :138-158; `DequeueAsync_UsesDequeueTimeScoreSelection_AndLogsScoreContext` :160-179 (filtered `ContainSingle`, unaffected); `DequeuePastDeadlineQualifiersAsync` :448-475 (first candidate qualifies, deadline inert, unaffected) | +| `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs` | 465 lines; `DeadlineConfigurations` :30; `CreateLowYieldGate` :37-70; **breaks:** `DequeueAsync_LowYieldStream_StopsScanningAtDefaultFirstBatchDeadline` :76-121, `DequeueAsync_DeadlineExpiresWithZeroAccepted_ReturnsEmptyListAtTheBound` :124-144, `DequeueAsync_AfterDeadlineReturn_StopsTakingAndLeavesUnscannedCandidates` :205-228, `DequeueAsync_DeadlineExpiry_EmitsOneExpiryLineAndKeepsPerCandidateLogging` :346-385 (total-count assertion `logs.Should().HaveCount(4, ...)` :384); **unaffected:** :150-200, :233-265, :271-312, :319-339, :392-422, :429-463 | +| `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs` | 280 lines; `Scored` helper :32-36; **breaks:** `DequeueAsync_ProgressCallback_StopsReportingOnceTheMethodReturns` :92-127, `DequeueAsync_DeadlineExpiresWithZeroAccepted_ReportsDeadlineExpiredStop` :174-208; **unaffected:** :43-86, :134-165, :215-238, :246-... | +| `QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs` | 413 lines; **breaks:** `DequeueNextItemGroupWithOutcomeAsync_DeadlineExpiredGate_ReportsDeadlineExpiredStop` :201-260, retargeted by [P1-T10] to `DequeueNextItemGroupWithOutcomeAsync_ZeroAcceptanceCeilingGate_ReportsScanCapReachedStop`; the ten-item master queue is built at :209-213 and the scoring-service callback that advances the fake clock is at :230-234 | +| `QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs` | 477 lines; `IterateQueueAsync_EmptyBatchWithDeadlineExpired_DoesNotCompleteAdding` :394-406; `IterateQueueAsync_EmptyBatchWithSourceExhausted_CompletesAddingOnce` :412-424 | +| `QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.Part2.cs` | 101 lines; documents that the base part carries the only `[TestClass]` and the shared `ArrangeIterate` / `VerifyCompleteAdding` helpers :12-21 | +| `QuickFiler.Test/Controllers/QfcFormControllerDeactivateTests.cs` | 248 lines; construction seam `CreateController` :60-70; `SetPrivateField` :72-73; `InjectGroups` :79-92; register-guard setup :47-58; **seven** `[TestMethod]` tests at :96, :113, :134, :153, :172, :194, :227 | +| `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.cs` | `DefaultFirstBatchDeadline` forwarding pinned at :171 (setup) and :221 (verify); the mock is `Mock`, so no gate is constructed | +| `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.Part2.cs` | `QfcDequeueStop.DeadlineExpired` returned from a `Mock` at :198; no gate is constructed | +| `.csharpierignore` | `**/evidence/**` :4; `*.cobertura.xml` :5; the project-file exclusion rationale "not C# source" :9-14 with `*.csproj` :12 | +| `QuickFiler.Test/Controllers/QfcFormControllerSeamTests.cs` | 496 lines; `CancelClicked_WhenRaised_CancelsParentTokenSource` :162-179 | +| `QuickFiler.Test/Controllers/QfcFormControllerTests.cs` | 792 lines; loose-mock setup :89-100; `CreateQfcFormController` :75-87; `ButtonCancel_Click_ShouldCancelAction` :392-403 | +| `QuickFiler.Test/Controllers/QfcHomeControllerPropertyTests.cs` | 345 lines; `Cleanup_ExecutesCorrectly` :79-103 (verifies `_datamodel.Cleanup()` once and `ParentCleanup.Invoke()` once) | +| `QuickFiler.Test/Controllers/QfcDatamodelLivenessTests.cs` | 255 lines; `CreateUninitializedDatamodel` :35-36; `SetPrivateField` :38-45; bounded-condition helper `WaitForState` with its rationale :47-57 | +| `QuickFiler.Test/QuickFiler.Test.csproj` | insertion-ordered `` list; gate parts :165-167; `QfcHomeControllerIterationTests.cs` :169; `QfcQueuePurePathsTests.cs` :117 | +| `QuickFiler.Test/packages.config` | `Microsoft.Extensions.TimeProvider.Testing` 10.9.0 :85-89; `Moq` 4.20.72 :112; `FluentAssertions` 8.10.0 :8; `MSTest.TestFramework` 4.4.0 :120 | +| `coverage.config` | module excludes :12-22; carries no `.*\.Test\.dll$` entry, so the derived config appends one | +| `.gitignore` | `artifacts/` :57; `coverage/*` :144 with `!coverage/.gitkeep` :145 | +| `.github/workflows/_mstest-coverage.yml` | assembly discovery :86-96; run switches `/EnableCodeCoverage /InIsolation /Logger:trx /TestCaseFilter:"TestCategory!=LiveOutlook"` :99 | +| `.claude/hooks/enforce-evidence-locations.ps1` | permitted `artifacts/csharp/` :22-26; forbidden prefixes :64-74 | + +--- + +### Phase 0 — Baseline capture and toolchain bootstrap + +- [x] [P0-T1] Read, in the `policy-compliance-order` sequence, `CLAUDE.md`, `.claude/rules/general-code-change.md`, `.claude/rules/general-unit-test.md`, `.claude/rules/csharp.md`, and `.claude/rules/tonality.md`, then write `/evidence/baseline/phase0-instructions-read.md` containing the literal field lines `Timestamp:`, `Policy Order:`, and an explicit list of the five files read with their line counts. Acceptance: the artifact exists and contains all five paths and the three field lines. + +- [x] [P0-T2] Record the branch and base commit into `/evidence/baseline/p0-t2-branch-commit.md`, including the literal field lines `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:`, and the two derived lines `BASE-BRANCH: ` and `BASE-SHA: <40-hex>`. Acceptance: both derived lines are present and `BASE-SHA` is a 40-character hexadecimal value. + +```powershell +git rev-parse --abbrev-ref HEAD +git rev-parse HEAD +git status --porcelain --untracked-files=all +``` + +- [x] [P0-T3] Restore NuGet packages for the solution and record `/evidence/baseline/p0-t3-nuget-restore.md`. `packages/` is already populated in this worktree, so this step confirms the tree the gates will build against is complete, not a repair. Record the count of `packages/` subdirectories before and after the command and the resolution status of every `` HintPath declared by `QuickFiler/QuickFiler.csproj` and `QuickFiler.Test/QuickFiler.Test.csproj`, because an unresolved analyzer path is CS0006, an error, and would fail [P0-T8] and [P0-T9] for a reason unrelated to this change. Acceptance: the artifact records the restore `EXIT_CODE:`, the before and after subdirectory counts, and one `RESOLVED:`/`UNRESOLVED:` line per analyzer path with zero `UNRESOLVED:` lines. + +```powershell +$before = (Get-ChildItem -Path 'packages' -Directory).Count +msbuild TaskMaster.sln /t:Restore /m /p:RestorePackagesConfig=true /p:Configuration=Debug "/p:Platform=Any CPU" +$after = (Get-ChildItem -Path 'packages' -Directory).Count +"packages-subdirs before=$before after=$after" +foreach ($proj in @('QuickFiler\QuickFiler.csproj', 'QuickFiler.Test\QuickFiler.Test.csproj')) { + [xml]$p = Get-Content -LiteralPath $proj + foreach ($a in $p.SelectNodes('//*[local-name()="Analyzer"]')) { + $hint = $a.GetAttribute('Include') + $full = Join-Path (Split-Path -Parent $proj) $hint + if (Test-Path -LiteralPath $full) { "RESOLVED: $hint" } else { "UNRESOLVED: $hint" } + } +} +``` + +- [x] [P0-T4] Restore the manifest-pinned dotnet tools and record `/evidence/baseline/p0-t4-dotnet-tool-restore.md`. The repository-local SDK marker directory `.dotnet-sdk/sdk/8.0.205` already exists, so `global.json` resolves. Acceptance: the artifact records `EXIT_CODE: 0` and `dotnet tool run csharpier --version` prints `1.2.6`. + +```powershell +$env:DOTNET_ROOT = (Resolve-Path '.dotnet-sdk').Path +$env:PATH = "$env:DOTNET_ROOT;$env:PATH" +dotnet tool restore +dotnet tool run csharpier --version +``` + +- [x] [P0-T5] Resolve `dotnet-coverage` and record `/evidence/baseline/p0-t5-dotnet-coverage.md`. Run `dotnet-coverage --version` first; only if that probe exits non-zero, run `dotnet tool install --global dotnet-coverage` and re-probe. Acceptance: the artifact records a final `dotnet-coverage --version` invocation with `EXIT_CODE: 0` and the printed version string, and states which of the two branches was taken. + +```powershell +$env:DOTNET_ROOT = (Resolve-Path '.dotnet-sdk').Path +$env:PATH = "$env:DOTNET_ROOT;$env:PATH" +dotnet-coverage --version +``` + +- [x] [P0-T6] Resolve `vstest.console.exe` through `vswhere` and record the full resolved path into `/evidence/baseline/p0-t6-vstest-resolution.md` as `VSTEST-PATH: `. This is the one artifact exempted from R3's path reduction, because pinning the resolved path is the task's purpose. Acceptance: `VSTEST-PATH` names an existing file and the artifact records `EXIT_CODE: 0`. + +```powershell +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vstest = & $vswhere -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 +$vstest +Test-Path $vstest +``` + +- [x] [P0-T7] Capture the CSharpier baseline into `/evidence/baseline/p0-t7-csharpier-check.md`, recording the verbatim printed line and the derived line `BASELINE-CSHARPIER-CHECKED-FILES: `. The success-case output of this command on a clean tree is the single line `Checked files in ms.` with exit 0, observed at `/docs/features/active/2026-09-05-pr-778-post-merge-review-residuals-782/evidence/qa-gates/r-p4-t2-format-check.md` line 18. If the check reports drift, the artifact must list every drifting path as a disclosed pre-existing set. Acceptance: the artifact records `EXIT_CODE:`, the printed line, and the `BASELINE-CSHARPIER-CHECKED-FILES` numeral. + +```powershell +$env:DOTNET_ROOT = (Resolve-Path '.dotnet-sdk').Path +$env:PATH = "$env:DOTNET_ROOT;$env:PATH" +dotnet tool run csharpier check . +``` + +- [x] [P0-T8] Capture the analyzer-build baseline into `/evidence/baseline/p0-t8-msbuild-analyzers.md` using exactly the CLAUDE.md analyzer command. Acceptance: the artifact records `EXIT_CODE:` and an `Output Summary:` giving the warning and error counts from the MSBuild summary. + +```powershell +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +``` + +- [x] [P0-T9] Capture the nullable-build baseline into `/evidence/baseline/p0-t9-msbuild-nullable.md` using exactly the CLAUDE.md nullable command. `/p:Nullable=enable` must not be added and `/t:Build` must not be substituted. Acceptance: the artifact records `EXIT_CODE:` and the warning and error counts. + +```powershell +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true +``` + +- [x] [P0-T10] Run `QuickFiler.Test` alone and record its pass/fail counts into `/evidence/baseline/p0-t10-quickfiler-tests.md` as the derived lines `BASELINE-QFT-TOTAL:`, `BASELINE-QFT-PASSED:`, `BASELINE-QFT-FAILED:`, read from the TRX `ResultSummary/Counters` element. Do not paste TRX content (R3). Acceptance: all three derived lines are present and `BASELINE-QFT-FAILED` is recorded whatever its value. + +```powershell +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vstest = & $vswhere -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p0-t10' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:TestCategory!=LiveOutlook' +``` + +- [x] [P0-T11] Run the full nine-assembly suite under `dotnet-coverage` and record `/evidence/baseline/p0-t11-coverage.md` with the derived lines `BASELINE-LINES-COVERED:`, `BASELINE-LINES-VALID:`, `BASELINE-BRANCHES-COVERED:`, `BASELINE-BRANCHES-VALID:`, the two derived percentages, and `BASELINE-TOTAL-TESTS:`. The four counters are aggregated from `coverage\791-baseline.cobertura.xml` by the pinned all-descendant `.//line` selection over the nine first-party packages, whose observed success-case output form is `LINES_COVERED= LINES_VALID= BRANCHES_COVERED= BRANCHES_VALID=`. Record `BASELINE_FLOOR: MET` or `BASELINE_FLOOR: NOT MET` against the 80 percent line floor and continue either way; a pre-existing repository floor never halts this plan. Acceptance: the four `BASELINE-` counter lines and the `BASELINE_FLOOR` line are present and numeric. + +```powershell +$env:DOTNET_ROOT = (Resolve-Path '.dotnet-sdk').Path +$env:PATH = "$env:DOTNET_ROOT;$env:PATH" +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vstest = & $vswhere -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 +$derived = 'coverage\791-effective-coverage.config' +[xml]$cfg = Get-Content -LiteralPath 'coverage.config' +$excl = $cfg.Configuration.CodeCoverage.ModulePaths.Exclude +$node = $cfg.CreateElement('ModulePath'); $node.InnerText = '.*\.Test\.dll$' +$null = $excl.AppendChild($node); $cfg.Save((Join-Path (Get-Location) $derived)) +dotnet-coverage collect --output coverage\791-baseline.cobertura.xml --output-format cobertura --settings coverage\791-effective-coverage.config -- $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll SVGControl.Test\bin\Debug\SVGControl.Test.dll Tags.Test\bin\Debug\Tags.Test.dll TaskMaster.Test\bin\Debug\TaskMaster.Test.dll TaskTree.Test\bin\Debug\TaskTree.Test.dll TaskVisualization.Test\bin\Debug\TaskVisualization.Test.dll ToDoModel.Test\bin\Debug\ToDoModel.Test.dll UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll VBFunctions.Test\bin\Debug\VBFunctions.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p0-t11' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests' +``` + +- [x] [P0-T12] Determine, from `coverage\791-baseline.cobertura.xml`, which Write Set production files are measurable, and write `/evidence/baseline/p0-t12-coverage-measurability.md`. For each of the seven production paths, query for a `class` element whose `filename` attribute ends with a directory separator followed by that file's name, and record one line per file of the form `MEASURABLE: ` or `UNMEASURABLE: `. The trailing-name match must be separator-anchored: an unanchored `QfcDatamodel.cs` suffix also selects `IQfcDatamodel.cs`. Acceptance: exactly seven `MEASURABLE:`/`UNMEASURABLE:` lines are present, one per Write Set production path, and the artifact records the class-element counts the determination was made from. + +```powershell +$doc = New-Object System.Xml.XmlDocument +$doc.Load((Resolve-Path -LiteralPath 'coverage\791-baseline.cobertura.xml').Path) +$names = @('QfcStreamingDequeueConfidenceGate.cs','IQfcDatamodel.cs','QfcDatamodel.QueueProcessing.cs','QfcDatamodel.cs','QfcFormController.EventHandlers.cs','QfcFormController.Deactivate.cs','QfcHomeController.cs') +foreach ($n in $names) { + $hit = 0 + foreach ($c in $doc.SelectNodes('//class')) { + $f = $c.GetAttribute('filename') + if ($f.EndsWith('\' + $n) -or $f.EndsWith('/' + $n)) { $hit++ } + } + "$n classElements=$hit" +} +``` + +- [x] [P0-T13] Record the baseline line count of every file this plan edits or creates into `/evidence/baseline/p0-t13-line-counts.md`, one ` = ` line per file, covering the seven production paths and the five existing test `.cs` paths (`QfcStreamingDequeueConfidenceGateTests.cs`, `.Part2.cs`, `.Part3.cs`, `QfcQueuePurePathsTests.cs`, `QfcHomeControllerIterationTests.cs`), plus a `CEILING: 500 (applies to *.cs only)` line. Record `QuickFiler.Test/QuickFiler.Test.csproj` separately under a `PROJECT-FILE (exempt): = ` heading, with the reason: `.claude/rules/general-code-change.md` caps production code, test code and reusable script files, and `.csharpierignore` lines 9-14 record that project files are owned by Visual Studio and are not C# source, so the 500-line rule does not reach them. The file is 524 lines today and 528 after [P1-T7], so asserting it against the ceiling would be unsatisfiable. Acceptance: every `.cs` path has a numeric count, the csproj count is recorded under the exempt heading with its reason, and the artifact names the three tightest `.cs` files. + +- [x] [P0-T14] Record the pre-change status of the seven deadline-dependent tests named in D2 into `/evidence/baseline/p0-t14-deadline-test-inventory.md`, one line per test of the form `BASELINE-PASS: `, derived from the TRX this task writes to `TestResults\791-p0-t14`, which re-runs the same two classes under the same runsettings, isolation and blame switches as [P0-T10], differing only in the class-scoping `/TestCaseFilter`, so the seven statuses are read from a run whose scope is exactly the affected set. The category clause is omitted from this filter rather than combined with the two `FullyQualifiedName` clauses, because `&` binds tighter than `|` in a vstest filter expression and the combined form would apply the category exclusion to only the first clause; neither class declares a `LiveOutlook` test, so omitting it changes no selected test. This is the set that Phase 1 deliberately turns red and Phase 2 turns green again; recording it now is what makes the Phase 2 no-newly-failing comparison meaningful. Acceptance: seven `BASELINE-PASS:` lines are present, and the artifact records that all seven names also appear as passing in the [P0-T10] whole-assembly TRX. + +```powershell +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vstest = & $vswhere -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p0-t14' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:FullyQualifiedName~QfcStreamingDequeueConfidenceGateTests|FullyQualifiedName~QfcQueuePurePathsTests' +``` + +--- + +### Phase 1 — Declaration seams and failing regression tests + +Phase 1 is test-first. Tasks [P1-T1] through [P1-T3] add only type-level declarations, because the new tests name types and +members that do not exist yet and a missing declaration reddens the whole `QuickFiler.Test` assembly at compile time rather +than producing a targeted failure. No behaviour changes in Phase 1. + +- [x] [P1-T1] In `QuickFiler/Interfaces/IQfcDatamodel.cs`, add the enum member `ScanCapReached` to `QfcDequeueStop` with an XML doc recording that it reports a bounded zero-acceptance exit and is treated exactly as `DeadlineExpired` is; update the `DeadlineExpired` XML doc at line 38 to record that issue #791 made the first-batch deadline advisory and that the member is retained for compatibility; and declare `Task QuiesceLoaderAsync(TimeSpan timeout);` on `IQfcDatamodel` with an XML doc stating that it cancels, awaits the loader against the supplied bound, never throws for the timeout case, and returns when the loader completes or the bound expires. Acceptance: `QuickFiler.csproj` compiles and a case-sensitive search of `QuickFiler/Interfaces/IQfcDatamodel.cs` finds the single-line token `ScanCapReached` and the single-line token `QuiesceLoaderAsync`. + +- [x] [P1-T2] In `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs`, add `internal Action QuiesceDebugLog { get; set; }` with an XML doc naming it the injected diagnostic seam that mirrors the gate's `debugLog` parameter (D10), and add `public Task QuiesceLoaderAsync(TimeSpan timeout) => throw new NotImplementedException("Issue #791: the quiesce body is supplied by [P2-T4].");` as the declaration-only seam. Acceptance: the solution compiles and a case-sensitive search of that file finds the single-line token `QuiesceDebugLog`. + +- [x] [P1-T3] In `QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs`, add `internal static readonly int DefaultMaxScanWithoutAcceptance = 250;` and `internal static readonly TimeSpan DefaultZeroAcceptanceCeiling = TimeSpan.FromSeconds(120);` with XML docs recording that both are implementation quality bounds with a constructor test seam and no settings surface, add the two optional parameters `int? maxScanWithoutAcceptance = null, TimeSpan? zeroAcceptanceCeiling = null` to the end of the wide constructor's parameter list, and store them in the internal get-only auto-properties `MaxScanWithoutAcceptance` and `ZeroAcceptanceCeiling` (D9). Do not change `DequeueAsync`. Acceptance: the solution compiles and the wide constructor has exactly eleven parameters. + +- [x] [P1-T4] Build the solution so the seam declarations are available to the test project, and record `/evidence/regression-testing/p1-t4-seam-build.md`. Acceptance: `EXIT_CODE: 0`. + +```powershell +msbuild TaskMaster.sln /t:Build /m /p:Configuration=Debug "/p:Platform=Any CPU" +``` + +- [x] [P1-T5] Widen the fail-closed reflection helper in `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs` lines 27-92: add `int? maxScanWithoutAcceptance = null, TimeSpan? zeroAcceptanceCeiling = null` to both `CreateGate` overloads, add `typeof(int?)` and `typeof(TimeSpan?)` to the constructor type array, add the two arguments to the `constructor.Invoke` array, and update the assertion message at line 76 from "nine-parameter" to "eleven-parameter". Keep the helper fail-closed: the `constructor.Should().NotBeNull(...)` guard must remain. Acceptance: `QuickFiler.Test` compiles and the file remains at or below 500 lines (baseline 477, budget at most 12 added lines). + +- [x] [P1-T6] Create `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs` declaring `public partial class QfcStreamingDequeueConfidenceGateTests` in namespace `QuickFiler.Controllers.Tests` with no `[TestClass]` attribute (the base part carries the only one; repeating it is CS0579), containing the seven AC1 tests named in `spec.md` lines 222-228: `DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance`, `DequeueAsync_ZeroAcceptedAndSourceDrained_ReportsSourceExhausted`, `DequeueAsync_ZeroAcceptedAndCapReached_StopsAndReportsScanCapReached`, `DequeueAsync_ZeroAcceptedAndCeilingReached_StopsWhileSourceStillRefilling`, `DequeueAsync_CheckpointExpiry_LogsCutoffAndCounts`, `DequeueAsync_Launch_LogsCutoffQuantityAndBounds`, and `DequeueAsync_NonEmptyPrefix_UnchangedByCheckpoint`. All seven use `FakeTimeProvider` as the clock, drive the gate through the widened `CreateGate` helper, and assert through the injected `debugLog` delegate rather than a log4net appender. The two logging tests assert on these exact single-line literals, which [P2-T2] will introduce: `High-confidence dequeue launch` and `Zero-acceptance checkpoint`. `DequeueAsync_ZeroAcceptedAndCapReached_StopsAndReportsScanCapReached` injects `maxScanWithoutAcceptance: 4` over ten candidates and asserts the stop reason is `QfcDequeueStop.ScanCapReached`, exactly four takes occurred, and six candidates remain in the source. Acceptance: the file compiles once [P1-T7] wires it, contains exactly seven `[TestMethod]` attributes, and is at or below 500 lines. + +- [x] [P1-T7] Add four `` entries to `QuickFiler.Test/QuickFiler.Test.csproj` for `Controllers\QfcStreamingDequeueConfidenceGateTests.Part4.cs`, `Controllers\QfcFormControllerCancelTeardownTests.cs`, `Controllers\QfcHomeControllerCleanupTests.cs`, and `Controllers\QfcDatamodelTeardownTests.cs`. The project is legacy `packages.config` and its item list is insertion-ordered, not alphabetical; append the four entries adjacent to the existing gate entries at lines 165-167. Acceptance: `QuickFiler.Test.csproj` contains exactly four new `` lines and the four named files are compiled once they exist. + +- [x] [P1-T8] Retarget the four superseded tests in `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs`, preserving each test's intent against the new behaviour rather than deleting it: `DequeueAsync_LowYieldStream_StopsScanningAtDefaultFirstBatchDeadline` (lines 76-121) becomes `DequeueAsync_LowYieldStream_ContinuesPastDefaultDeadlineToTheQualifier`, asserting the qualifier at position 40 is returned and that the scan runs to source exhaustion; `DequeueAsync_DeadlineExpiresWithZeroAccepted_ReturnsEmptyListAtTheBound` (lines 124-144) becomes `DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesToSourceExhaustion`, asserting the source is drained rather than three candidates taken; `DequeueAsync_AfterDeadlineReturn_StopsTakingAndLeavesUnscannedCandidates` (lines 205-228) becomes `DequeueAsync_AfterScanCapReached_StopsTakingAndLeavesUnscannedCandidates`, replacing the 4-second deadline with `maxScanWithoutAcceptance: 4` so its existing take-count and residual-source assertions stay exactly 4 and 6; and `DequeueAsync_DeadlineExpiry_EmitsOneExpiryLineAndKeepsPerCandidateLogging` (lines 346-385) becomes `DequeueAsync_CheckpointExpiry_EmitsCheckpointLineAndKeepsPerCandidateLogging`, replacing the total-count assertion `logs.Should().HaveCount(4, ...)` at line 384 with per-category counts so the assertion is not brittle against the added launch line. Widen the private helper `CreateLowYieldGate` (Part2.cs lines 37-70) by making `deadline` optional (`TimeSpan? deadline = null`) and adding `int? maxScanWithoutAcceptance = null`, forwarding both to `CreateGate`. The helper has exactly two callers, at Part2.cs lines 129 and 210, both retargeted by this task, so the widening reaches no other test. Acceptance: the four old method names are absent from the file under a case-sensitive search, the four new names are present, the six unaffected tests listed in the Citation table are unchanged, `CreateLowYieldGate` remains a single private static helper with both callers inside this file, and the file is at or below 500 lines. + +- [x] [P1-T9] Retarget the two superseded tests in `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs`: `DequeueAsync_ProgressCallback_StopsReportingOnceTheMethodReturns` (lines 92-127) keeps its name and its "no invocation after the method returns" intent but bounds the run with an injected `maxScanWithoutAcceptance` instead of the 3-second deadline, so the expected report sequence follows the cap; and `DequeueAsync_DeadlineExpiresWithZeroAccepted_ReportsDeadlineExpiredStop` (lines 174-208) becomes `DequeueAsync_ZeroAcceptedAndCapReached_ReportsScanCapReachedStop`, asserting `QfcDequeueStop.ScanCapReached` and an empty accepted list. Acceptance: the old method name `DequeueAsync_DeadlineExpiresWithZeroAccepted_ReportsDeadlineExpiredStop` is absent under a case-sensitive search, the new name is present, and the file is at or below 500 lines. + +- [x] [P1-T10] Retarget `DequeueNextItemGroupWithOutcomeAsync_DeadlineExpiredGate_ReportsDeadlineExpiredStop` in `QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs` lines 201-260 to `DequeueNextItemGroupWithOutcomeAsync_ZeroAcceptanceCeilingGate_ReportsScanCapReachedStop`, preserving its purpose — that the datamodel projects the gate's stop reason verbatim rather than folding it into quantity satisfaction. The datamodel constructs the gate at `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` lines 184-194 without passing either new bound, so the scan cap is unreachable from this seam: the ten-item fixture drains to `SourceExhausted` long before the default cap of 250. The test must drive the time ceiling instead: keep the ten-item master queue built at lines 209-213 and change the scoring-service callback at lines 230-234 to advance the fake clock by `TimeSpan.FromSeconds(61)` per score, so the run origin passes `DefaultZeroAcceptanceCeiling` at the third loop-top bound check. Assert `batch.Stop` is `QfcDequeueStop.ScanCapReached` and `batch.Items` is empty. Acceptance: the old method name is absent under a case-sensitive search, the new name is present, and the file is at or below 500 lines. + +- [x] [P1-T11] Add `IterateQueueAsync_EmptyBatchWithScanCapReached_DoesNotCompleteAdding` to `QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs` as a sibling of the existing `IterateQueueAsync_EmptyBatchWithDeadlineExpired_DoesNotCompleteAdding` pin at lines 394-406, using the same `ArrangeIterate(stop: ...)` and `VerifyCompleteAdding(queue, Times.Never, ...)` helpers. This is the AC6 pin that `#446` AC-6 is preserved: the new stop reason must not be routed into the `SourceExhausted` branch. The file is 477 lines; the addition must not take it past 500. Acceptance: the new method name is present under a case-sensitive search and the file is at or below 500 lines. + +- [x] [P1-T12] Create `QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs` with `[TestClass] public class QfcFormControllerCancelTeardownTests` in namespace `QuickFiler.Controllers.Tests`, modelled on the seam pattern of `QfcFormControllerDeactivateTests.cs` lines 36-92 (mock viewer, mock home controller, mock collection controller injected into `_groups` by private-field reflection, a `Control.ControlCollection` and an empty exclusion list so the register/unregister guard is satisfied). It contains the eight tests: `ActionCancelAsync_ResetsKbdActive_WhenKeyboardDialogActive`, `ActionCancelAsync_DoesNotToggle_WhenInactive`, `ActionCancelAsync_ParksFocusAndCancelsBreadcrumbSelectors`, `ActionCancelAsync_UnregistersHandlersBeforeGroupsCleanup`, `ActionCancelAsync_AwaitsLoaderQuiesceBeforeGroupsCleanup`, `ActionCancelAsync_GroupsCleanupThrows_StillInvokesParentCleanup`, `ButtonCancel_Click_ActionThrows_DoesNotRethrow`, and `ActionCancelAsync_CalledTwice_InvokesParentCleanupOnce`. Ordering is asserted through a shared invocation-order `List` populated by `Callback` handlers, comparing the first index of each marker. `ButtonCancel_Click_ActionThrows_DoesNotRethrow` nulls the private `_formViewer` field so the throw originates at `QfcFormController.EventHandlers.cs` line 74 inside the handler's own `try` (D12). `ActionCancelAsync_AwaitsLoaderQuiesceBeforeGroupsCleanup` sets up `IQfcDatamodel.QuiesceLoaderAsync` to return a completed `Task` — the same shape the timeout path returns, which `QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs` pins independently — and asserts both that the quiesce marker precedes the groups-cleanup marker and that both later stages still ran. Acceptance: the file compiles, contains exactly eight `[TestMethod]` attributes, and is at or below 500 lines. + +- [x] [P1-T13] Create `QuickFiler.Test/Controllers/QfcHomeControllerCleanupTests.cs` with `[TestClass] public class QfcHomeControllerCleanupTests` in namespace `QuickFiler.Controllers.Tests`, constructing the controller through the public `QfcHomeController(IApplicationGlobals, System.Action)` constructor and injecting `_datamodel`, `_formViewer` and `_tokenSource` by private-field reflection, as `QfcHomeControllerPropertyTests.cs` lines 84-95 already does. It contains `Cleanup_DatamodelCleanupThrows_StillInvokesParentCleanup` (the datamodel mock throws; assert `Cleanup()` does not throw and the parent cleanup delegate ran exactly once) and `Cleanup_DisposesTokenSourceAndDetachesWorkerCompleted` (assert that reading `cts.Token` after `Cleanup()` throws `ObjectDisposedException`, and that the viewer mock's `Worker` getter was read at least once, which is the observable proof the detach path executed). Acceptance: the file compiles, contains exactly two `[TestMethod]` attributes, and is at or below 500 lines. + +- [x] [P1-T14] Create `QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs` with `[TestClass] public class QfcDatamodelTeardownTests` in namespace `QuickFiler.Controllers.Tests`, carrying its own `CreateUninitializedDatamodel` and `SetPrivateField` helpers following the existing duplication convention documented at `QfcDatamodelLivenessTests.cs` lines 18-24. It contains the four tests named in `spec.md` line 239 plus one capture pin: `TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing` (both `_masterQueue` and `_moveMonitor` null; assert the call does not throw and returns `false`), `QuiesceLoaderAsync_LoaderCompletes_ReturnsBeforeTimeout` (`_remainingLoadTask` injected as a completed task, `TimeProvider` a `FakeTimeProvider` never advanced; assert the returned task completes and `QuiesceDebugLog` captured a line containing `Loader quiesce completed`), `QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs` (`_remainingLoadTask` injected as a never-completing `TaskCompletionSource` task; start the call, advance the fake clock past the bound, await, and assert `QuiesceDebugLog` captured a line containing `Loader quiesce timed out`), `Cleanup_CalledTwice_DoesNotThrow` (an uninitialized instance whose `_globals` and `_moveMonitor` are null and whose `_tokenSource` and `_worker` are set; assert two successive `Cleanup()` calls do not throw), and `Worker_DoWork_CapturesRemainingLoadTask` (drive `InitEmailQueue(0, worker)` with an injected `RemainingEmailLoader`, then assert `_remainingLoadTask` becomes non-null using a bounded event-driven condition wait carrying the same rationale comment as `QfcDatamodelLivenessTests.cs` lines 47-57). Acceptance: the file compiles, contains exactly five `[TestMethod]` attributes, and is at or below 500 lines. + +- [x] [P1-T15] Build the solution with the new and retargeted tests in place and record `/evidence/regression-testing/p1-t15-test-build.md`. Acceptance: `EXIT_CODE: 0`, proving every new test compiles against the Phase 1 seams. + +```powershell +msbuild TaskMaster.sln /t:Build /m /p:Configuration=Debug "/p:Platform=Any CPU" +``` + +- [x] [P1-T16] [expect-fail] Run the gate and datamodel-projection tests and record `/evidence/regression-testing/p1-t16-gate-fail-before.md` with `ExpectedExitCode: 1`. The artifact must enumerate, by fully qualified name, every failing test and state for each whether it is one of the seven new Part4 tests, one of the six retargeted tests, or the `QfcQueuePurePathsTests` retarget. Acceptance: `EXIT_CODE: 1`, and the recorded failure set includes `DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance`. + +```powershell +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vstest = & $vswhere -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p1-t16' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:FullyQualifiedName~QfcStreamingDequeueConfidenceGateTests|FullyQualifiedName~QfcQueuePurePathsTests' +``` + +- [x] [P1-T17] [expect-fail] Run `QfcFormControllerCancelTeardownTests` and record `/evidence/regression-testing/p1-t17-cancel-teardown-fail-before.md` with `ExpectedExitCode: 1`, enumerating each failing test by fully qualified name and its failure message reduced per R3. Acceptance: `EXIT_CODE: 1` and `ActionCancelAsync_UnregistersHandlersBeforeGroupsCleanup` appears in the failure set. + +```powershell +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vstest = & $vswhere -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p1-t17' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:FullyQualifiedName~QfcFormControllerCancelTeardownTests' +``` + +- [x] [P1-T18] [expect-fail] Run `QfcHomeControllerCleanupTests` and record `/evidence/regression-testing/p1-t18-home-cleanup-fail-before.md` with `ExpectedExitCode: 1`. Acceptance: `EXIT_CODE: 1` and both `Cleanup_DatamodelCleanupThrows_StillInvokesParentCleanup` and `Cleanup_DisposesTokenSourceAndDetachesWorkerCompleted` appear in the failure set. + +```powershell +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vstest = & $vswhere -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p1-t18' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:FullyQualifiedName~QfcHomeControllerCleanupTests' +``` + +- [x] [P1-T19] [expect-fail] Run `QfcDatamodelTeardownTests` and record `/evidence/regression-testing/p1-t19-datamodel-teardown-fail-before.md` with `ExpectedExitCode: 1`. The artifact must record that `TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing` fails with an `ArgumentException` whose message names a delegate over a null instance — the exact failure mode the issue log records at `issue.md` lines 64-69 — and that the two `QuiesceLoaderAsync` tests fail with `NotImplementedException` from the [P1-T2] seam. Acceptance: `EXIT_CODE: 1` and all five test names appear in the failure set with their exception types recorded. + +```powershell +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vstest = & $vswhere -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p1-t19' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:FullyQualifiedName~QfcDatamodelTeardownTests' +``` + +- [x] [P1-T20] Write `/evidence/regression-testing/p1-t20-expected-red-inventory.md` consolidating the four fail-before artifacts into one list of every test that is red at the end of Phase 1, each tagged `NEW`, `RETARGETED`, or `SEAM-BLOCKED`. This is the set Phase 2 must turn green and nothing else. Acceptance: the inventory's count equals the sum of the failure counts recorded by [P1-T16] through [P1-T19], and every entry carries one of the three tags. + +--- + +### Phase 2 — Production implementation + +- [x] [P2-T1] Rewrite the zero-acceptance branch of `DequeueAsync` in `QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs` lines 172-180 as a checkpoint plus a bounded exit, keeping every other statement of the loop unchanged. Inside the existing `deadlineEnabled && accepted.Count == 0` guard (D8), evaluate the two bounds against the run origin before evaluating the checkpoint against a separate checkpoint origin; when either bound is reached, return `new QfcGateBatch(accepted, QfcDequeueStop.ScanCapReached, scanned)`; when the checkpoint interval elapses, log and reset the checkpoint origin and continue. The bound check must sit ahead of `MailItem mailItem = _tryTakeNext();` so a capped scan cannot take an extra item. Acceptance: `DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance`, `DequeueAsync_ZeroAcceptedAndCapReached_StopsAndReportsScanCapReached`, `DequeueAsync_ZeroAcceptedAndCeilingReached_StopsWhileSourceStillRefilling`, `DequeueAsync_ZeroAcceptedAndSourceDrained_ReportsSourceExhausted` and `DequeueAsync_NonEmptyPrefix_UnchangedByCheckpoint` all pass. + +- [x] [P2-T2] Add the two logging helpers to `QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs`: a launch helper invoked once at the top of `DequeueAsync` emitting a line whose first token sequence is `High-confidence dequeue launch` and which carries the cutoff in per-mille and as a fraction, the requested quantity, the checkpoint interval, the scan cap and the ceiling; and a checkpoint helper replacing `LogDeadlineExpiry` (lines 242-250) emitting a line whose first token sequence is `Zero-acceptance checkpoint` and which carries accepted, scanned, the cutoff, elapsed time, the remaining cap and ceiling, and the decision. Add a third line for the bounded exit whose first token sequence is `Zero-acceptance scan bound reached`. All three route through both `_debugLog?.Invoke(message)` and `logger.Debug(message)`, exactly as the existing helpers do. Acceptance: `DequeueAsync_Launch_LogsCutoffQuantityAndBounds` and `DequeueAsync_CheckpointExpiry_LogsCutoffAndCounts` pass, and `DequeueAsync_UsesDequeueTimeScoreSelection_AndLogsScoreContext` (which uses a filtered `ContainSingle`) still passes. + +- [x] [P2-T3] Verify that no other production consumer routes the new stop reason into the queue-closing branch: `QuickFiler/Controllers/QfcHomeController.Iteration.cs` lines 39-48 must remain byte-identical, so `CompleteAddingAsync` stays reachable only under `SourceExhausted`. Acceptance: `IterateQueueAsync_EmptyBatchWithScanCapReached_DoesNotCompleteAdding` and `IterateQueueAsync_EmptyBatchWithSourceExhausted_CompletesAddingOnce` both pass, and the anchored diff below lists no entry for `QuickFiler/Controllers/QfcHomeController.Iteration.cs`. + +```powershell +$BaseSha = (Select-String -Path 'docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t2-branch-commit.md' -CaseSensitive -Pattern '^BASE-SHA: ([0-9a-f]{40})$').Matches[0].Groups[1].Value +git add --intent-to-add -- '*.cs' '*.csproj' +git status --porcelain --untracked-files=all -- 'QuickFiler/Controllers/QfcHomeController.Iteration.cs' +git diff --name-only $BaseSha -- 'QuickFiler/Controllers/QfcHomeController.Iteration.cs' +``` + +- [x] [P2-T4] In `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs`, add the private field `_remainingLoadTask` holding the loader task, and replace the [P1-T2] `NotImplementedException` seam with the real `QuiesceLoaderAsync(TimeSpan timeout)` body: cancel the token source, snapshot `_remainingLoadTask` into a local, return immediately when the local is null or already completed, otherwise `await Task.WhenAny(loader, TimeProvider.Delay(timeout, CancellationToken.None))` and emit exactly one outcome line through both `QuiesceDebugLog` and `logger.Info`, containing the literal `Loader quiesce completed` on the completion path and the literal `Loader quiesce timed out` on the bound path. The method must never throw for the timeout case. Acceptance: `QuiesceLoaderAsync_LoaderCompletes_ReturnsBeforeTimeout` and `QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs` pass, and a case-sensitive search of that file returns zero matches for the single-line literal `Issue #791: the quiesce body is supplied by` and exactly one match for `NotImplementedException`, that one being the pre-existing `UndoMove()` throw at line 28, which is outside this plan's Write Set. + +- [x] [P2-T5] Relocate `TryQueueRemainingMailItemAsync` from `QuickFiler/Controllers/QfcDatamodel.cs` lines 350-361 into `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs`, snapshotting `_masterQueue` and `_moveMonitor` into locals and returning `false` when either local is null or when cancellation is requested, before any delegate is constructed over them. The `QfcRemainingQueueAdmission` three-delegate constructor shape is unchanged. Acceptance: `TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing` passes and `TryQueueRemainingMailItemAsync_HighConfidenceEnabled_AddsBelowThresholdCandidate` in `QfcDatamodelTests.cs` still passes. + +- [x] [P2-T6] In `QuickFiler/Controllers/QfcDatamodel.cs`, replace line 191 so `Worker_DoWork` captures the loader task into `_remainingLoadTask` before awaiting it, and delete the method body relocated by [P2-T5]. The `finally` that clears `_remainingLoadActive` at lines 193-200 is unchanged. Acceptance: `Worker_DoWork_CapturesRemainingLoadTask` passes and `DequeueNextItemGroupAsync_WhileLoaderStillProducing_KeepsPollingAfterWorkerIdle` in `QfcDatamodelLivenessTests.cs` still passes. + +- [x] [P2-T7] Null-guard `QfcDatamodel.Cleanup()` at `QuickFiler/Controllers/QfcDatamodel.cs` lines 75-91 so the `_globals.Ol.App.NewMailEx` unsubscribe at line 79 and the `_moveMonitor.UnhookAll()` call at line 80 cannot dereference a null field, and add one comment recording that a second Cancel, or a Cancel after a partially failed launch, reaches this method with those fields already released. Acceptance: `Cleanup_CalledTwice_DoesNotThrow` passes. + +- [x] [P2-T8] In `QuickFiler/Controllers/QfcFormController.Deactivate.cs`, extract the body of `FormViewer_Deactivated` (lines 26-58) into `internal void ParkFocusAndCancelSelectors()`, leaving the event handler as a one-line delegation, add a null guard so the `IsWebView2Focused` read at line 28 is null-conditional, and rewrite the `` block at lines 22-25, whose claim that a null-viewer branch is unreachable becomes false the moment the Cancel path calls the routine (D7). The per-item boundary catch at lines 45-56 keeps its exact shape. Acceptance: all seven tests in `QfcFormControllerDeactivateTests.cs` still pass, and in particular `FormDeactivated_NullGroupsOrNullItemGroups_DoesNotThrow` still passes against the added `_formViewer` null guard; and a case-sensitive search of `QuickFiler/Controllers/QfcFormController.Deactivate.cs` returns zero matches for the single-line literal `a null-viewer branch would be unreachable code`. + +- [x] [P2-T9] In `QuickFiler/Controllers/QfcFormController.EventHandlers.cs`, add the teardown support members: `internal static readonly TimeSpan LoaderQuiesceBound` with the bound value and an XML doc recording that it is a caller-supplied constant rather than a setting; a private `RunTeardownStage(string stage, System.Action body)` helper that runs one stage, logs its completion at DEBUG and logs any escaping exception at ERROR with the stage name so a throwing stage cannot skip a later one; a private `ResetKeyboardActive()` that toggles the keyboard dialog only when `_parent?.KeyboardHandler?.KbdActive == true`, mirroring the OK path at lines 125-128; and a private `UnregisterCancelPathHandlers()` that calls `_groups?.UnregisterNavigation()` and then `UnregisterFormEventHandlers()`. Every dereference is null-conditional (D5). Acceptance: the solution compiles and `ButtonCancel_Click_ShouldCancelAction` in `QfcFormControllerTests.cs` still passes. + +- [x] [P2-T10] Rewrite `ActionCancelAsync` at `QuickFiler/Controllers/QfcFormController.EventHandlers.cs` lines 84-94 to the ordered teardown, keeping its zero-parameter signature (D3): log entry at INFO; cancel the parent token source; marshal to the UI sync context when it is non-null; reset the keyboard-active flag; call `ParkFocusAndCancelSelectors()` while the item groups still exist; unregister navigation and form handlers before any row is removed; hide the form; capture `_parent?.DataModel?.QuiesceLoaderAsync(LoaderQuiesceBound)` into a local and await it only when non-null; clean up the groups; and invoke `Cleanup()` from a `finally` so the ribbon release callback runs whichever stage threw. The token cancel must remain ahead of the first `await` (D4). Acceptance: `ActionCancelAsync_ResetsKbdActive_WhenKeyboardDialogActive`, `ActionCancelAsync_DoesNotToggle_WhenInactive`, `ActionCancelAsync_ParksFocusAndCancelsBreadcrumbSelectors`, `ActionCancelAsync_UnregistersHandlersBeforeGroupsCleanup`, `ActionCancelAsync_AwaitsLoaderQuiesceBeforeGroupsCleanup`, `ActionCancelAsync_GroupsCleanupThrows_StillInvokesParentCleanup` and `ActionCancelAsync_CalledTwice_InvokesParentCleanupOnce` all pass, and `CancelClicked_WhenRaised_CancelsParentTokenSource` in `QfcFormControllerSeamTests.cs` still passes. + +- [x] [P2-T11] In `QuickFiler/Controllers/QfcFormController.EventHandlers.cs`, remove the `throw;` at line 80 from `ButtonCancel_Click` so an `async void` handler no longer converts a teardown failure into an unhandled Outlook UI-thread exception, keeping the `logger.Error(ex.Message, ex)` above it, and add one `log.Debug` line immediately before the completion-path `await ActionCancelAsync();` at line 208 that names the completion path, supplying the Logging Plan's trigger discriminator without changing the method signature (D3). Acceptance: `ButtonCancel_Click_ActionThrows_DoesNotRethrow` passes. + +- [x] [P2-T12] Rewrite `QfcHomeController.Cleanup()` at `QuickFiler/Controllers/QfcHomeController.cs` lines 370-379 as two guarded blocks under one `finally` (D11): the first detaches `Worker_RunWorkerCompleted` from `_formViewer?.Worker` through a local, before the viewer reference is dropped; the second runs `_datamodel?.Cleanup()`, disposes `_tokenSource`, and nulls `Globals`, `_formViewer`, `_explorerController`, `_formController` and `_keyboardHandler`; each block logs any escaping exception at ERROR with its stage name; and the `finally` invokes `ParentCleanup?.Invoke()` and logs the release at INFO. Acceptance: `Cleanup_DatamodelCleanupThrows_StillInvokesParentCleanup` and `Cleanup_DisposesTokenSourceAndDetachesWorkerCompleted` pass, `Cleanup_ExecutesCorrectly` in `QfcHomeControllerPropertyTests.cs` still passes, and `QuickFiler/Controllers/QfcHomeController.cs` is at or below 500 lines. + +- [x] [P2-T13] Build the solution and record `/evidence/regression-testing/p2-t13-post-fix-build.md`. Acceptance: `EXIT_CODE: 0`. + +```powershell +msbuild TaskMaster.sln /t:Build /m /p:Configuration=Debug "/p:Platform=Any CPU" +``` + +- [x] [P2-T14] Run every test named in the [P1-T20] inventory and record `/evidence/regression-testing/p2-t14-pass-after.md`. Acceptance: `EXIT_CODE: 0`, `Failed: 0`, and the artifact records, for every test named in the [P1-T20] inventory, a `PASS-AFTER: ` line derived from the TRX, with the count of those lines equal to the [P1-T20] inventory count. The run's own totals are recorded separately as `P2-T14-TOTAL-PASSED:` and `P2-T14-TOTAL-RUN:` and are not asserted against the inventory count, because the filter selects whole classes and therefore also runs tests that were already green at the end of Phase 1. + +```powershell +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vstest = & $vswhere -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p2-t14' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:FullyQualifiedName~QfcStreamingDequeueConfidenceGateTests|FullyQualifiedName~QfcQueuePurePathsTests|FullyQualifiedName~QfcFormControllerCancelTeardownTests|FullyQualifiedName~QfcHomeControllerCleanupTests|FullyQualifiedName~QfcDatamodelTeardownTests|FullyQualifiedName~QfcHomeControllerIterationTests' +``` + +- [x] [P2-T15] Run the whole `QuickFiler.Test` assembly and record `/evidence/regression-testing/p2-t15-quickfiler-suite.md` with the derived lines `POST-QFT-TOTAL:`, `POST-QFT-PASSED:`, `POST-QFT-FAILED:` and a `NEWLY-FAILING:` line listing every test failing here that was not failing in the [P0-T10] baseline. Acceptance: `NEWLY-FAILING: NONE` and `POST-QFT-FAILED` is less than or equal to `BASELINE-QFT-FAILED` from [P0-T10]. + +```powershell +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vstest = & $vswhere -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p2-t15' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:TestCategory!=LiveOutlook' +``` + +- [x] [P2-T16] Record the pre-format line count of every file this plan has edited or created into `/evidence/qa-gates/p2-t16-file-size-interim.md`, one ` = ` line per file alongside the [P0-T13] baseline count for the same path, keeping `QuickFiler.Test/QuickFiler.Test.csproj` under the same `PROJECT-FILE (exempt):` heading [P0-T13] uses (R8). Acceptance: every listed `.cs` count is at or below 500; the exempt project-file count is recorded but not asserted against the ceiling; and any `.cs` file within ten lines of the ceiling is named explicitly with its remaining headroom. + +--- + +### Phase 3 — Final QA loop, coverage, and acceptance-criteria closure + +- [x] [P3-T1] Run the CSharpier formatter over the repository and record `/evidence/qa-gates/p3-t1-csharpier-format.md`. `format` rewrites tracked source and still exits 0 after rewriting, so the exit code alone cannot distinguish a clean run from a repairing one; the artifact must therefore record the verbatim printed line of the form `Formatted files in ms.` and, as the distinguishing observation, the `git status --porcelain --untracked-files=all` path set and the `git diff --stat` output anchored to `BASE-SHA`, captured before and after the run, with the two derived lines `PATH_SETS_IDENTICAL:` and `DIFFSTAT_IDENTICAL:`. Acceptance: `EXIT_CODE: 0` and both derived comparison lines are recorded with their values. + +```powershell +$env:DOTNET_ROOT = (Resolve-Path '.dotnet-sdk').Path +$env:PATH = "$env:DOTNET_ROOT;$env:PATH" +$BaseSha = (Select-String -Path 'docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t2-branch-commit.md' -CaseSensitive -Pattern '^BASE-SHA: ([0-9a-f]{40})$').Matches[0].Groups[1].Value +git add --intent-to-add -- '*.cs' '*.csproj' +$before = @(git status --porcelain --untracked-files=all) +$beforeStat = @(git diff --stat $BaseSha) +dotnet tool run csharpier format . +$after = @(git status --porcelain --untracked-files=all) +$afterStat = @(git diff --stat $BaseSha) +``` + +- [x] [P3-T2] Run the read-only CSharpier check and record `/evidence/qa-gates/p3-t2-csharpier-check.md` with the verbatim printed line and the derived line `FINAL-CSHARPIER-CHECKED-FILES: `. The success-case output on a clean tree is the single line `Checked files in ms.` with exit 0. Record the delta against `BASELINE-CSHARPIER-CHECKED-FILES` from [P0-T7]; four new `.cs` files are added by this plan, so a delta of 4 is the expected observation. Acceptance: `EXIT_CODE: 0`. The exit code is the gate here, because `check` is read-only and returns non-zero on drift. + +```powershell +$env:DOTNET_ROOT = (Resolve-Path '.dotnet-sdk').Path +$env:PATH = "$env:DOTNET_ROOT;$env:PATH" +dotnet tool run csharpier check . +``` + +- [x] [P3-T3] Run the analyzer gate and record `/evidence/qa-gates/p3-t3-msbuild-analyzers.md`, comparing its warning and error counts against [P0-T8]. Acceptance: `EXIT_CODE: 0` and the error count is 0. + +```powershell +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +``` + +- [x] [P3-T4] Run the nullable gate and record `/evidence/qa-gates/p3-t4-msbuild-nullable.md`, comparing its warning and error counts against [P0-T9]. `/p:Nullable=enable` must not be added and `/t:Build` must not be substituted. Acceptance: `EXIT_CODE: 0` and the error count is 0. + +```powershell +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true +``` + +- [x] [P3-T5] Run the full nine-assembly suite under `dotnet-coverage`, writing the Cobertura document to `artifacts/csharp/coverage.xml`, and record `/evidence/qa-gates/p3-t5-tests-coverage.md` with the derived lines `FINAL-LINES-COVERED:`, `FINAL-LINES-VALID:`, `FINAL-BRANCHES-COVERED:`, `FINAL-BRANCHES-VALID:`, the two derived percentages, and `FINAL-TOTAL-TESTS:` / `FINAL-FAILED-TESTS:`. The four counters are aggregated by the same pinned all-descendant `.//line` selection over the same nine first-party package names that [P0-T11] used, so the two sides are produced by one collector, one configuration, one selection and one filter. `artifacts/` is git-ignored at `.gitignore` line 57, so the document is a local tool output rather than committed evidence; the acceptance below is on-disk existence and the recorded counters, not on `git ls-files`. Acceptance: `EXIT_CODE: 0`, `FINAL-FAILED-TESTS: 0`, `artifacts/csharp/coverage.xml` exists, and all four `FINAL-` counter lines are numeric. + +```powershell +$env:DOTNET_ROOT = (Resolve-Path '.dotnet-sdk').Path +$env:PATH = "$env:DOTNET_ROOT;$env:PATH" +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vstest = & $vswhere -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 +New-Item -ItemType Directory -Force -Path 'artifacts\csharp' | Out-Null +dotnet-coverage collect --output artifacts\csharp\coverage.xml --output-format cobertura --settings coverage\791-effective-coverage.config -- $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll SVGControl.Test\bin\Debug\SVGControl.Test.dll Tags.Test\bin\Debug\Tags.Test.dll TaskMaster.Test\bin\Debug\TaskMaster.Test.dll TaskTree.Test\bin\Debug\TaskTree.Test.dll TaskVisualization.Test\bin\Debug\TaskVisualization.Test.dll ToDoModel.Test\bin\Debug\ToDoModel.Test.dll UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll VBFunctions.Test\bin\Debug\VBFunctions.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\791-p3-t5' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests' +``` + +- [x] [P3-T6] Record the toolchain loop closure into `/evidence/qa-gates/p3-t6-loop-closure.md`, listing [P3-T1] through [P3-T5] in order with each artifact path and each recorded exit code, and stating whether any step failed or rewrote a file. Acceptance: the artifact records all five steps as passing in one uninterrupted pass, or, if any step failed or changed files, records the restart and the subsequent clean pass; the checklist box for this task stays unchecked until a clean pass is recorded. + +- [x] [P3-T7] Write `/evidence/qa-gates/p3-t7-changed-line-coverage.md` comparing coverage on the changed production lines. Restrict the comparison to the paths [P0-T12] reported as `MEASURABLE:`; for each path reported `UNMEASURABLE:`, record `CHANGED-LINE-COVERAGE: NOT MEASURABLE` with the `[ExcludeFromCodeCoverage]` citation (D1) and name the passing tests that exercise those changed lines as the substitute evidence. For each measurable path, derive the changed line numbers from the anchored `git diff --unified=0` in the command block below and record each changed line's `hits` value from `artifacts/csharp/coverage.xml`, using a de-duplicated per-line map that merges `./lines/line` with `./methods/method/lines/line` keyed by line number and resolved by maximum `hits`. Where a diff hunk's added and removed line counts are unequal, no one-to-one baseline mapping exists; record such lines as `baseline=none` and exclude them from the regression count rather than attributing borrowed coverage. A changed line carrying no `line` element in either branch of the merged map is non-executable — an XML doc comment, a blank line, a `using` directive, a brace, an enum member or an interface method declaration — and has no `hits` value to record. Record such lines as `hits=non-executable` and exclude them from both the `hits = 0` count and the regression count. `QuickFiler/Interfaces/IQfcDatamodel.cs` is the case where this reaches every changed line, because [P1-T1] adds only an enum member, an interface method declaration and XML docs to it, none of which emits IL; the file therefore yields no coverage datum even if [P0-T12] reports a class element for it. The command block below enumerates the five paths D1 predicts to be measurable; [P0-T12]'s determination is authoritative, so if it reports a different measurable set, use that set and record the divergence in the artifact. Acceptance: every changed production line in the measurable set is recorded with either a post-change `hits` value or the `hits=non-executable` marker; the count of changed lines with `hits = 0` is stated over executable lines only; and the count of changed lines whose post-change `hits` is lower than their baseline `hits` is `0`. + +```powershell +$BaseSha = (Select-String -Path 'docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t2-branch-commit.md' -CaseSensitive -Pattern '^BASE-SHA: ([0-9a-f]{40})$').Matches[0].Groups[1].Value +git add --intent-to-add -- '*.cs' +git status --porcelain --untracked-files=all -- 'QuickFiler/Controllers' 'QuickFiler/Interfaces' +foreach ($p in @('QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs', 'QuickFiler/Interfaces/IQfcDatamodel.cs', 'QuickFiler/Controllers/QfcFormController.EventHandlers.cs', 'QuickFiler/Controllers/QfcFormController.Deactivate.cs', 'QuickFiler/Controllers/QfcHomeController.cs')) { + "=== $p" + git diff --unified=0 $BaseSha -- $p +} +``` + +- [x] [P3-T8] Write `/evidence/qa-gates/p3-t8-coverage-delta.md` comparing the four [P0-T11] baseline counters against the four [P3-T5] final counters. Record the comparability precondition first: `FINAL-LINES-VALID` and `BASELINE-LINES-VALID` must be compared and their relation stated, because the denominator grows when new production lines are added and the two sides are only directly comparable when it does not. When the denominators differ, compare the two derived percentages instead and state that the percentage comparison is the one used. Acceptance: the artifact records baseline coverage, post-change coverage, and the new/changed-code coverage determination from [P3-T7], and states explicitly whether the repository-wide first-party line percentage decreased. + +- [x] [P3-T9] Record the post-format line count of every file this plan edited or created into `/evidence/qa-gates/p3-t9-file-size-audit.md`, one ` = ` line per file alongside its [P0-T13] baseline, keeping `QuickFiler.Test/QuickFiler.Test.csproj` under the same `PROJECT-FILE (exempt):` heading [P0-T13] uses (R8). This audit runs after the final format because CSharpier can change line counts. Acceptance: every listed `.cs` count is at or below 500; the exempt project-file count is recorded but not asserted against the ceiling; and the artifact states the smallest remaining headroom across all listed `.cs` files. + +- [x] [P3-T10] Write `/evidence/qa-gates/p3-t10-scope-boundary.md` enumerating the changed source set under the R7 pathspec and asserting the AC5 boundary. The artifact must list the anchored-diff output and the porcelain output side by side, because neither alone is correct in both states: an anchored diff cannot see an untracked path, and porcelain status goes empty once the change is committed. Acceptance: the enumerated set contains only the seven Write Set production paths, the four new and five modified test paths under `QuickFiler.Test/Controllers`, and `QuickFiler.Test/QuickFiler.Test.csproj`; and none of `QuickFiler/Controllers/QfcCollectionController.cs`, `QuickFiler/Controllers/QfcHomeController.Iteration.cs`, `TaskMaster/Ribbon/RibbonController.cs`, `TaskMaster/Properties/Settings.Designer.cs`, `TaskMaster/AppGlobals/AppQuickFilerSettings.cs` appears in either output. + +```powershell +$BaseSha = (Select-String -Path 'docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/baseline/p0-t2-branch-commit.md' -CaseSensitive -Pattern '^BASE-SHA: ([0-9a-f]{40})$').Matches[0].Groups[1].Value +git add --intent-to-add -- '*.cs' '*.csproj' +git diff --name-only $BaseSha -- '*.cs' '*.csproj' +git status --porcelain --untracked-files=all -- '*.cs' '*.csproj' +``` + +- [x] [P3-T11] Check off AC1 in `spec.md` line 255 by changing its `- [ ]` to `- [x]`, citing the [P2-T14] pass-after artifact and the [P1-T16] fail-before artifact. Acceptance: exactly one AC checkbox changes in this task and the AC1 line carries `- [x]`. + +- [x] [P3-T12] Check off AC2 in `spec.md` line 256, citing [P1-T17], [P1-T18], [P1-T19] and [P2-T14], and recording that the live-Outlook confirmation is human-interaction exception HI-1 performed per `/runbooks/live-outlook-cancel-teardown-verification.runbook.md` and does not gate the automated review. Acceptance: exactly one AC checkbox changes in this task and the AC2 line carries `- [x]`. + +- [x] [P3-T13] Check off AC3 in `spec.md` line 257 by writing `/evidence/qa-gates/p3-t13-ac3-test-inventory.md` first, listing every test name that `spec.md` Test Strategy names alongside the file it now lives in and its pass result from [P2-T14], and confirming that fail-before and pass-after evidence exists for `DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance` and `TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing`. Acceptance: every Test Strategy test name maps to an existing file and a passing result, and exactly one AC checkbox changes in this task. + +- [x] [P3-T14] Check off AC4 in `spec.md` line 258, citing [P3-T1] through [P3-T6] for the toolchain order, [P3-T5] for `artifacts/csharp/coverage.xml`, and [P3-T7] and [P3-T8] for the coverage determinations. Acceptance: exactly one AC checkbox changes in this task, the AC4 line carries `- [x]`, and the check-off note records the D13 collector substitution. + +- [x] [P3-T15] Check off AC5 in `spec.md` line 259, citing [P3-T10] and recording the R7 pathspec reading and its rationale so a reviewer does not read the narrower evaluation as an unstated relaxation. Acceptance: exactly one AC checkbox changes in this task and the AC5 line carries `- [x]`. + +- [x] [P3-T16] Check off AC6 in `spec.md` line 260, citing the supersession statements already present at `spec.md` lines 103-105 and lines 213-214 (which this plan does not modify) and the [P2-T3] evidence that `QuickFiler/Controllers/QfcHomeController.Iteration.cs` is unmodified, plus the passing `IterateQueueAsync_EmptyBatchWithScanCapReached_DoesNotCompleteAdding`. Acceptance: exactly one AC checkbox changes in this task and the AC6 line carries `- [x]`. + +- [x] [P3-T17] Update `spec.md` Status to `Implemented` and add an "Outcome" note under Rollout & Follow-up recording the four deviations this plan makes from the spec's own prose, each with its reason: the `ActionCancelAsync` trigger discriminator is a call-site log rather than a parameter (D3); `QfcDatamodel.QuiesceDebugLog` is an added internal test seam (D10); the retargeting surface is seven tests rather than the four Test Strategy names (D2); and the coverage run uses `dotnet-coverage collect --output-format cobertura` rather than `vstest /EnableCodeCoverage`, because `/EnableCodeCoverage` writes a binary `.coverage` file and the two collectors conflict, while AC4's substantive requirement is Cobertura XML at `artifacts/csharp/coverage.xml` (D13). Acceptance: the Status line reads `Implemented` and all four deviations are recorded by name. + +- [x] [P3-T18] Update `issue.md` with the outcome and mirror it to `/evidence/issue-updates/issue-791..md` per the evidence conventions, including the literal field lines `Timestamp:`, the exact text intended, and `PostedAs:`. Acceptance: both the local `issue.md` update and the mirror artifact exist and carry the same text. + +- [x] [P3-T19] Write `/evidence/qa-gates/p3-t19-ac-status-summary.md` listing AC1 through AC6 with their final checkbox state and the artifact path that justifies each. Acceptance: six rows are present, each naming at least one existing artifact path, and every row's checkbox state matches the corresponding line in `spec.md`. + +--- + +SELF-REVIEW: RE-DERIVED THIS PASS + +Round 1 (initial authoring). Every citation in the Citation table was read directly from this worktree, +and the sibling region of each edited citation was re-checked. The sibling sweep produced D2 (three +deadline-dependent gate tests that `spec.md` Test Strategy does not name), D3 (`IFilerFormController.cs` +line 11 forbids an optional `trigger` parameter), D4 (`QfcFormControllerSeamTests.cs` requires the token +cancel to precede the first `await`), D5 (`QfcFormControllerTests.cs` loose mocks resolve +`KeyboardHandler` and `DataModel` to null), D7 (`QfcFormController.Deactivate.cs` line 24 remark becomes +false), and D11 (`QfcHomeController.cs` has 31 lines of headroom, which the three-block form exceeds). + +Round 2 (preflight revision). Every citation this revision touches was re-derived against the tree as it +stands after the revision, together with its sibling region, and each delta's own text was checked +against the rules it enforces: + +- `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` lines 25-29 — pre-existing `UndoMove()` with + `throw new NotImplementedException();` on line 28, which makes the round-1 zero-hit gate in [P2-T4] + unsatisfiable. Sibling sweep of the same file confirmed line 75 is the third production call site of + `DefaultFirstBatchDeadline`, which D8 had under-enumerated, and confirmed the gate construction at + lines 184-194 passes neither new bound, which is what makes [P1-T10]'s round-1 scan-cap mechanism + unreachable. +- `QuickFiler.Test/QuickFiler.Test.csproj` — 524 lines today. Re-derived against `.csharpierignore` + lines 9-14, which record project files as owned by Visual Studio and not C# source, and against + `.claude/rules/general-code-change.md`, whose ceiling names production code, test code and reusable + script files. R8 and the three file-size tasks are now scoped to `.cs`. +- `QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs` — the ten-item master queue is built at lines + 209-213 and the scoring-service callback at lines 230-234 is the fake-clock advance point, which is + the seam [P1-T10] now drives. +- `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs` — `CreateLowYieldGate` + at lines 37-70 takes a mandatory `TimeSpan deadline` and exposes no cap; its only two callers are at + lines 129 and 210, both retargeted by [P1-T8], so widening it reaches no other test. +- `QuickFiler.Test/Controllers/QfcFormControllerDeactivateTests.cs` — recounted: seven `[TestMethod]` + tests at lines 96, 113, 134, 153, 172, 194 and 227, not six. Sibling sweep confirmed that the one + affected by the added null guard is `FormDeactivated_NullGroupsOrNullItemGroups_DoesNotThrow` at + line 194. +- `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.cs` lines 171 and 221, and + `...Part2.cs` line 198 — re-derived as the two deadline-surface references that are deliberately + outside the retargeting set, because neither constructs the gate. +- Delta self-check: the vstest filter added to [P0-T14] in a first draft of this revision was removed + again, because `&` binds tighter than `|` in a vstest filter expression, so the combined form would + have applied the category exclusion to only the first of the two class clauses. The reason is + recorded in the task rather than left implicit. + +Round 3 (preflight revision, one defect). The changed region is [P3-T7] and its sibling region was +re-derived in this pass: + +- `QuickFiler/Interfaces/IQfcDatamodel.cs` — re-read at 133 lines. The whole file is declarations and + documentation apart from `QfcDequeueBatch` at lines 49-81, whose constructor at lines 59-68 and + expression-bodied properties at lines 71, 77 and 80 are the only members that emit IL. Everything + [P1-T1] adds to the file — the `ScanCapReached` member inside the `QfcDequeueStop` enum at lines + 30-40, the `QuiesceLoaderAsync` declaration on the interface at lines 83-132, and the XML docs + including the `DeadlineExpired` doc at lines 38-39 — is non-executable, so none of its changed lines + can carry a Cobertura `line` element. The file can still report a class element, because the struct + does, which is why the marker is keyed on the absence of a `line` element for the specific changed + line rather than on the file-level determination. +- Task [P0-T12] re-checked: it reports one `MEASURABLE:`/`UNMEASURABLE:` line per production path from a + class-element count. That determination is file-level and remains correct; the new marker operates one + level below it, per changed line, and the two do not contradict. +- D1 re-checked and left unchanged: it states that the two `QfcDatamodel` partials are excluded from + measurement by the type-level attribute. It makes no claim about `IQfcDatamodel.cs`, so the new + non-executable case neither extends nor invalidates it. The two mechanisms are distinct — an excluded + type versus a changed line that emits no IL — and are kept distinct in the plan text. +- Task [P3-T8] re-checked: it consumes "the new/changed-code coverage determination from [P3-T7]" and states + no per-line count of its own, so the added marker does not reach it. +- Task [P3-T14] and the AC4 `AC-MAPPING` entry re-checked: both cite the [P3-T7] artifact by path and assert + nothing about its internal counts. +- Delta self-check: the inserted prose states a structural fact about Cobertura output and names the + file it reaches, in neutral language, and the replaced acceptance remains falsifiable — a changed + executable line with `hits = 0`, or a changed line whose post-change `hits` fell below its baseline, + still fails it. + +PLANNER-INTERNAL-REVIEW: PASS +CITATION-TO-TREE: PASS +AC-TRACEABILITY: PASS +SCOPE-BOUNDARY: PASS +CITATION: QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs | 262 lines; zero-acceptance deadline branch at lines 172-180; nine-parameter constructor at lines 111-125; DefaultFirstBatchDeadline at line 56; LogDeadlineExpiry at lines 242-250 +CITATION: QuickFiler/Interfaces/IQfcDatamodel.cs | 133 lines; enum QfcDequeueStop at lines 30-40 with DeadlineExpired at lines 38-39; readonly struct QfcDequeueBatch at lines 49-81, whose constructor at lines 59-68 and expression-bodied properties at lines 71, 77 and 80 are the file's only IL-emitting members; interface IQfcDatamodel at lines 83-132, all declarations +CITATION: QuickFiler/Controllers/QfcDatamodel.cs | 480 lines; [ExcludeFromCodeCoverage] at line 25; Cleanup() at lines 75-91; Worker_DoWork at lines 175-213; TryQueueRemainingMailItemAsync at lines 350-361 +CITATION: QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs | 298 lines; public partial class QfcDatamodel at line 12; _remainingLoadActive at line 23; pre-existing UndoMove() at lines 25-29 with throw new NotImplementedException(); at line 28; DefaultFirstBatchDeadline delegation at line 75; gate construction at lines 184-194 passing neither new bound +CITATION: QuickFiler/Controllers/QfcFormController.EventHandlers.cs | 408 lines; ButtonCancel_Click at lines 70-82 with throw; at line 80; ActionCancelAsync at lines 84-94; completion-path cancel at line 208 +CITATION: QuickFiler/Controllers/QfcFormController.Deactivate.cs | 60 lines; unreachable-code remark at line 24; FormViewer_Deactivated at lines 26-58 +CITATION: QuickFiler/Controllers/QfcHomeController.cs | 469 lines; InternalsVisibleTo at line 15; Cleanup() at lines 370-379; Worker_RunWorkerCompleted subscription at lines 91 and 131 +CITATION: QuickFiler/Controllers/QfcHomeController.Iteration.cs | CompleteAddingAsync reachable only under SourceExhausted at lines 39-48 +CITATION: QuickFiler/Interfaces/IFilerFormController.cs | Task ActionCancelAsync(); at line 11 +CITATION: QuickFiler/Interfaces/IQfcCollectionController.cs | UnregisterNavigation() at line 109; ItemGroups at line 17 +CITATION: QuickFiler/Interfaces/IQfcFormViewer.cs | UiSyncContext at line 17; Worker at line 18; IsWebView2Focused at line 64; ParkFocusOffWebView2 at line 70 +CITATION: QuickFiler/Controllers/IQfcHomeController.cs | IQfcDatamodel DataModel { get; } at line 11 +CITATION: QuickFiler/Controllers/QfcScanProgressBandMapper.cs | prose confirming QfcDatamodel is [ExcludeFromCodeCoverage] at line 12 +CITATION: QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs | 477 lines; fail-closed nine-type constructor lookup at lines 53-77 +CITATION: QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs | 465 lines; four superseded tests at lines 76-121, 124-144, 205-228, 346-385; total-count assertion at line 384; CreateLowYieldGate helper at lines 37-70 with its only two callers at lines 129 and 210 +CITATION: QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs | 280 lines; two superseded tests at lines 92-127 and 174-208 +CITATION: QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs | 413 lines; superseded test at lines 201-260; ten-item master queue at lines 209-213; scoring-service fake-clock advance at lines 230-234 +CITATION: QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.cs | DefaultFirstBatchDeadline forwarding pinned at lines 171 and 221 through a Mock +CITATION: QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncHighConfidenceTests.Part2.cs | QfcDequeueStop.DeadlineExpired returned from a Mock at line 198 +CITATION: .csharpierignore | evidence exclusion at line 4; cobertura exclusion at line 5; project-file exclusion rationale at lines 9-14 with *.csproj at line 12 +CITATION: QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs | 477 lines; DeadlineExpired pin at lines 394-406; SourceExhausted control at lines 412-424 +CITATION: QuickFiler.Test/Controllers/QfcFormControllerDeactivateTests.cs | 248 lines; construction seam at lines 60-70; group injection at lines 79-92; seven [TestMethod] tests at lines 96, 113, 134, 153, 172, 194 and 227 +CITATION: QuickFiler.Test/Controllers/QfcFormControllerSeamTests.cs | CancelClicked_WhenRaised_CancelsParentTokenSource at lines 162-179 +CITATION: QuickFiler.Test/Controllers/QfcFormControllerTests.cs | loose-mock setup at lines 89-100; ButtonCancel_Click_ShouldCancelAction at lines 392-403 +CITATION: QuickFiler.Test/Controllers/QfcHomeControllerPropertyTests.cs | Cleanup_ExecutesCorrectly at lines 79-103 +CITATION: QuickFiler.Test/Controllers/QfcDatamodelLivenessTests.cs | uninitialized-object seam at lines 35-45; bounded condition wait rationale at lines 47-57 +CITATION: QuickFiler.Test/QuickFiler.Test.csproj | 524 lines; gate Compile Include entries at lines 165-167; QfcHomeControllerIterationTests.cs at line 169 +CITATION: QuickFiler.Test/packages.config | Microsoft.Extensions.TimeProvider.Testing 10.9.0 at lines 85-89; Moq 4.20.72 at line 112 +CITATION: coverage.config | module excludes at lines 12-22 with no Test.dll entry +CITATION: .gitignore | artifacts/ at line 57; coverage/* at line 144 +CITATION: .github/workflows/_mstest-coverage.yml | assembly discovery at lines 86-96; run switches at line 99 +CITATION: .claude/hooks/enforce-evidence-locations.ps1 | artifacts/csharp/ permitted at lines 22-26; forbidden prefixes at lines 64-74 +CITATION: docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/spec.md | Acceptance Criteria AC1 through AC6 at lines 255-260; Write Set at lines 141-164; Test Strategy at lines 219-252 +AC-INVENTORY: AC1, AC2, AC3, AC4, AC5, AC6 +AC-MAPPING: AC1 | IMPLEMENTATION: P2-T1, P2-T2, P2-T3 | TESTS: P1-T6, P1-T8, P1-T9, P1-T10, P1-T11 | EVIDENCE: /evidence/regression-testing/p1-t16-gate-fail-before.md and /evidence/regression-testing/p2-t14-pass-after.md +AC-MAPPING: AC2 | IMPLEMENTATION: P2-T4, P2-T5, P2-T6, P2-T7, P2-T8, P2-T9, P2-T10, P2-T11, P2-T12 | TESTS: P1-T12, P1-T13, P1-T14 | EVIDENCE: /evidence/regression-testing/p1-t17-cancel-teardown-fail-before.md, p1-t18-home-cleanup-fail-before.md, p1-t19-datamodel-teardown-fail-before.md and p2-t14-pass-after.md +AC-MAPPING: AC3 | IMPLEMENTATION: P1-T6, P1-T12, P1-T13, P1-T14 | TESTS: P2-T14, P3-T13 | EVIDENCE: /evidence/qa-gates/p3-t13-ac3-test-inventory.md +AC-MAPPING: AC4 | IMPLEMENTATION: P3-T1, P3-T2, P3-T3, P3-T4, P3-T5 | TESTS: P3-T5, P3-T6 | EVIDENCE: /evidence/qa-gates/p3-t5-tests-coverage.md, p3-t7-changed-line-coverage.md and p3-t8-coverage-delta.md +AC-MAPPING: AC5 | IMPLEMENTATION: P2-T3 | TESTS: P3-T10 | EVIDENCE: /evidence/qa-gates/p3-t10-scope-boundary.md +AC-MAPPING: AC6 | IMPLEMENTATION: P2-T3 | TESTS: P1-T11, P3-T16 | EVIDENCE: /evidence/qa-gates/p3-t10-scope-boundary.md and /evidence/regression-testing/p2-t14-pass-after.md +UNRESOLVED-GAPS: NONE +DIRECTIVE: PREFLIGHT VALIDATION ONLY +PREFLIGHT: REQUESTED — validation-only preflight has NOT been run by this planner, because no atomic-executor delegation tool and no MCP plan validator are present in this planner's tool surface. The orchestrator must obtain one of the two exact signals, `PREFLIGHT: ALL CLEAR` or `PREFLIGHT: REVISIONS REQUIRED`, and a passing `mcp__drm-copilot__validate_orchestration_artifacts` run with `artifact_type: "plan"` before execution begins. This plan is not self-approved. +CONVERGENCE: NO FURTHER ROUNDS EXPECTED — the round-2 review verified all twelve round-1 deltas and both self-found corrections as applied and correct and reported one defect, which this round applies in [P3-T7] together with a re-derivation of its sibling region. The three axes round 1 flagged remain closed: the self-created literals are quoted verbatim in plan prose outside their command spans, which is the G5 exoneration condition; the file-size ceiling is scoped to `.cs` so the 524-line project file no longer makes it unsatisfiable; and every `$vstest` and `$BaseSha` use carries its binding inside its own command block, which keeps the anchored diffs out of the ref-less G8 form. The round-3 change is confined to one task's prose and acceptance and introduces no new command, no new artifact and no new assertion target. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/policy-audit.2026-09-06T15-31.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/policy-audit.2026-09-06T15-31.md new file mode 100644 index 000000000..85edb5c52 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/policy-audit.2026-09-06T15-31.md @@ -0,0 +1,593 @@ +# Policy Audit — Issue #791 (quickfiler-high-confidence-cancel-teardown-and-deadline-defects) + +- **Component:** `QuickFiler` (controllers, interfaces), `QuickFiler.Test`, feature documentation for #791 +- **Date:** 2026-09-06 +- **Reviewer:** feature-review agent (cycle 1) +- **Base branch:** `main` -> `origin/main` @ `7c8ac9ae34b8b3dda9134a5e310f39742fd2f0b6` +- **Head:** `bug/quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791` @ `59536368756d979f3f72268dfb4dfd0d4b2f7d9f` +- **Merge base (recomputed by this reviewer):** `git merge-base HEAD origin/main` = `7c8ac9ae34b8b3dda9134a5e310f39742fd2f0b6`, identical to the caller-supplied value +- **Commits ahead:** 11 +- **Work mode:** `full-bug` (marker at `issue.md:12`) -> the sole acceptance-criteria source is `spec.md`, `## Acceptance Criteria`, AC1..AC6 +- **PR context artifacts:** `artifacts/pr_context.summary.txt` and `artifacts/pr_context.appendix.txt`, generated 2026-09-06 19:19:04 UTC and carrying `Head SHA: 59536368756d979f3f72268dfb4dfd0d4b2f7d9f`, which equals `git rev-parse HEAD`. Not stale. + +## Template Provenance Deviation + +The MCP tool `mcp__drm-copilot__resolve_policy_audit_template_asset` is not on this agent's tool +surface in this session, so the bundled `template` asset could not be resolved directly. This +artifact is authored against the canonical heading set enumerated in +`.claude/skills/policy-audit-template-usage/SKILL.md` (`## Executive Summary`, sections 1 through 10, +Appendix A and Appendix B), with the Coverage Evidence Checklist bullets, the section 1.2.1 +per-language coverage comparison block, the `**Coverage Metrics by Language:**` seven-column table, +the labelled per-language comparison bullets and the section 1.2.2 terminating heading required by +the artifact validator. The +structure of the most recent accepted audit in this repository, +`docs/features/active/2026-09-05-pr-778-post-merge-review-residuals-782/policy-audit.2026-09-06T02-18.md`, +was used as the reference. No template instruction block is present in this file. + +## Executive Summary + +**Verdict: PASS. Blocking findings: 0.** + +The branch fixes two reported QuickFiler defects: a first-batch deadline that returned an empty +High Confidence dialog while unscanned candidates remained, and a Cancel teardown that outlived its +own field nulling, left the Outlook keyboard captured, and logged nothing. Both are pinned by +deterministic MSTest regression tests with recorded fail-before and pass-after evidence, and the +fail-before record for the teardown defect reproduces the reported `ArgumentException` message +character-for-character without Outlook. + +Independently re-executed by this reviewer at the current head, not read from a delivery artifact: + +| Gate | Command this reviewer ran | Result | +|---|---|---| +| Format check | `dotnet tool run csharpier check .` | `Checked 1587 files in 4202ms`, exit 0 | +| Analyzer build | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` | exit 0 | +| Nullable build | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` | `Build succeeded. 0 Warning(s) 0 Error(s)`, exit 0 | +| QuickFiler test assembly | `vstest.console.exe QuickFiler.Test\bin\Debug\QuickFiler.Test.dll ... /TestCaseFilter:TestCategory!=LiveOutlook` | `Test Run Successful. Total tests: 1362`, exit 0 | +| Cobertura re-aggregation, post-change | direct `classes/class/lines/line` aggregation of `artifacts/csharp/coverage.xml` over the nine first-party packages | 84.51% line, 79.19% branch — reproduces the delivery's derived percentages exactly by a different selection | +| Cobertura re-aggregation, baseline | same selection over `coverage/791-baseline.cobertura.xml` | 84.50% line, 79.14% branch | +| Per-file coverage, all seven changed production paths | per-`filename` aggregation of both Cobertura documents | no file regressed; two files sit below the 85% per-file floor and both improved | + +`/t:Rebuild` was used for both builds rather than `/t:Build`, so `CoreCompile` was not skipped by +MSBuild incrementality and neither gate was vacuous. + +The working tree was clean before this review and is clean after it (`git status --porcelain +--untracked-files=all` empty at both points). This reviewer wrote nothing under `.claude/**`, +modified no source, test, or policy file, and executed no mutating command against tracked content. +The two `/t:Rebuild` invocations and the test run rewrote only git-ignored build output. + +Sixteen findings are recorded in `code-review.2026-09-06T15-31.md`. None is blocking; six are Minor +with a concrete recommendation and ten are Observations. Remediation was **not** triggered: there is +no blocking finding, no unmet acceptance criterion, no toolchain failure, and no coverage regression. +The two FAIL rows in section 1.2.1 are the repository-wide 85% line-coverage floor and the per-file +floor on two Outlook-Interop-bound files; both are pre-existing on `origin/main`, both improved on +this branch, and both carry a written non-blocking disposition below. + +## Rejected Scope Narrowing + +The caller's prompt did **not** attempt to narrow the audit scope. It supplied the base branch, the +merge base, the work mode, the acceptance-criteria source, the coverage artifact path, and a tool +discipline constraint, and it explicitly required the full feature-vs-base audit. No caller statement +marked any language "informational only", "context only", or excluded any file, toolchain step, or +coverage check. + +One caller statement is recorded verbatim because it was offered as a fact and was verified: + +> the collector's "Changed files overview" can misclassify C# files under docs/tooling in its top-10 +> truncation; the appendix carries the true file list. + +**Confirmed accurate in effect, with a correction of mechanism.** `artifacts/pr_context.summary.txt` +reports `Core logic changes: 0 files` and `Docs/templates/agents/tooling: 46 files`, while the branch +changes 17 `.cs` and `.csproj` files. `Core logic changes: 0 files` is a bucket **count**, not a +truncated list; truncation applies separately to the third bucket's enumeration, which shows the top +10 by churn and lists only `.md` paths. The C# files are absent from every bucket rather than misfiled +into one. This did not narrow the audit: the changed-file set was derived by this reviewer from +`git diff --numstat 7c8ac9ae..59536368`, not from the summary. Recorded as finding N11. + +A tooling constraint in the prompt (`git *` and `pwsh *` only on the Bash tool; no `cd`, `cat`, +`grep`, `sed`) is recorded for transparency. It is not a scope narrowing: every gate, every language +and every changed file remained in scope, and every command this audit needed was expressible inside +it. + +## Evidence Location Compliance + +**PASS.** The full branch diff was scanned for paths under `artifacts/baselines/`, +`artifacts/baseline/`, `artifacts/qa/`, `artifacts/qa-gates/`, `artifacts/evidence/`, +`artifacts/coverage/`, and `artifacts/regression-testing/`. + +- **Violations found: 0.** No changed path on the branch lies under `artifacts/` at all. +- All 33 changed evidence files lie under `/evidence//`. The kinds used are `baseline` + (12), `qa-gates` (11), `regression-testing` (9) and `issue-updates` (1). All four are canonical per + `.claude/skills/evidence-and-timestamp-conventions/SKILL.md`. +- `validate_evidence_locations.py` does not exist in this repository; a recursive search for + `*evidence_locations*` returns nothing. The scan was performed directly against + `git diff --name-only`, which is a superset check of what that script would report. +- No `EVIDENCE_LOCATION_OVERRIDE_REJECTED` condition arose: no caller instruction, plan task, or + delegation prompt supplied a non-canonical evidence path to this reviewer. +- `artifacts/csharp/coverage.xml` is an explicitly permitted path in + `.claude/hooks/enforce-evidence-locations.ps1`, is git-ignored, and is not a committed artifact. + +## 1. General Unit Test Policy Compliance + +| # | Requirement | Verdict | Evidence | +|---|---|---|---| +| 1.1 | Independence — tests run in any order | PASS | No new test writes process-global state that persists past the test. `ButtonCancel_Click_ActionThrows_DoesNotRethrow` installs a `SynchronizationContext` and restores the previous one in a `finally` (`QfcFormControllerCancelTeardownTests.cs:325-333`). Every other new test builds its own controller, mocks and `CancellationTokenSource` in `[TestInitialize]` or in the test body. The whole assembly ran green in one pass in this reviewer's own run (1362/1362). | +| 1.2 | Isolation — one unit per test | PASS | Each of the 23 added tests pins one behavior: one bound, one ordering pair, one log category, one guard. The ordering tests compare the first index of two markers rather than asserting a whole sequence, so a failure names the pair that inverted. | +| 1.3 | Fast execution | PASS | `QuickFiler.Test` 1362 tests in 12.15 s in the delivery's run and comparable in this reviewer's re-run; the six affected classes run in 1.8 s. | +| 1.4 | Determinism | PASS with one recorded exception | No `Thread.Sleep`, `Task.Delay`, `DateTime.Now` or `DateTime.UtcNow` is added anywhere on the branch (scan over all 1671 added `.cs`/`.csproj` lines: zero hits for each). `FakeTimeProvider` is the clock for both the gate tests and the quiesce tests, and the ceiling test asserts the task is incomplete before advancing the fake clock, which proves the fake clock is what releases it. The exception is `QfcDatamodelTeardownTests.cs:67` (`SpinWait.SpinUntil(condition, TimeSpan.FromSeconds(5))`) and `:220` (`loaderEntered.Task.Wait(TimeSpan.FromSeconds(5))`), which are real wall-clock bounded waits at the `async void` `Worker_DoWork` boundary. Both are condition-driven rather than fixed sleeps and both are verbatim copies of the pre-existing convention at `QfcDatamodelLivenessTests.cs:56,103,173` and `QfcInitEmailQueueZeroBatchTests.cs:161`. Recorded as finding N4, non-blocking. | +| 1.5 | Readability, AAA, documented intent | PASS | Every added test carries explicit `// Arrange` / `// Act` / `// Assert` comments and an XML-doc summary naming the acceptance criterion and the failure it prevents. Assertion reasons are supplied throughout (`"toggling an inactive dialog would activate it, not reset it"`). | +| 1.6 | No external dependencies, no temp files | PASS | Scan of all added lines for `GetTempPath`, `GetTempFileName` and `Path.GetTempPath`: zero hits. No new test touches disk, network, or a live Outlook object; `MailItem` is a Moq object and `QfcDatamodel` is built through `FormatterServices.GetUninitializedObject` so its COM-bound constructors never run. | +| 1.7 | Coverage exclusion policy — no production path excluded by config | PASS | `coverage/791-effective-coverage.config` excludes only third-party module paths (Deedle, FSharp, Castle.Core, FluentAssertions, Moq, Microsoft.Testing, MSTest) plus `.*\.Test\.dll$`, which excludes test assemblies as the policy requires. No `exclude` entry matches a production source path. The separate source-level `[ExcludeFromCodeCoverage]` on `QfcDatamodel` is pre-existing and is addressed as finding N5. | +| 1.8 | Test file location | PASS (repo convention) | Tests live in `QuickFiler.Test/Controllers/`, mirroring `QuickFiler/Controllers/`. The `tests/` layout named in `.claude/rules/general-unit-test.md` is not the layout of this .NET Framework solution; the divergence is repository-wide, pre-existing on `main`, and the branch introduces no new deviation. No test file was placed in the production tree. | +| 1.9 | Scenario completeness — positive, negative, edge, error | PASS | Positive: continuation to first acceptance, quiesce completion. Negative controls: `_DoesNotToggle_WhenInactive`, `IterateQueueAsync_EmptyBatchWithSourceExhausted_CompletesAddingOnce`, `DequeueAsync_NonEmptyPrefix_UnchangedByCheckpoint`. Edge: cap reached exactly, ceiling reached while the producer is still active, source drained with neither bound reached, double Cancel. Error: a throwing groups-cleanup stage, a hanging loader, released fields at the admission point. | +| 1.10 | Assertions retain pinning power after retargeting | PASS | The seven retargeted tests were read line by line against their pre-change form. Each replaces the superseded outcome with the superseding one and keeps the discrimination that gave it value: `ScanCapReached` versus `SourceExhausted` is still asserted with `sourceActive: () => true` so exhaustion is not an available explanation; the take-count and residual-queue assertions are preserved at exactly 4 and 6 by injecting a cap of 4 in place of the 4 s deadline; the `#608` non-empty-prefix pin is added as its own test with a deliberately undersized cap so a widened guard would fail it. No assertion was deleted or weakened to green. | + +### Coverage Evidence Checklist + +- C# baseline coverage artifact: `coverage/791-baseline.cobertura.xml` +- C# post-change coverage artifact: `artifacts/csharp/coverage.xml` +- TypeScript baseline coverage artifact: `N/A - out of scope` +- TypeScript post-change coverage artifact: `N/A - out of scope` +- PowerShell baseline coverage artifact: `N/A - out of scope` +- PowerShell post-change coverage artifact: `N/A - out of scope` +- Python baseline coverage artifact: `N/A - out of scope` +- Python post-change coverage artifact: `N/A - out of scope` +- Per-language comparison summary: section 1.2.1 of this document + +### 1.2.1 Per-Language Coverage Comparison + +Every language with changed files on the branch receives an explicit PASS or FAIL below. Languages +with zero changed files are listed for completeness. + +**Coverage Metrics by Language:** + +| Language | Files Changed | Tests | Test Result | Baseline Coverage | Post-Change Coverage | New Code Coverage | +|---|---|---|---|---|---|---| +| C# | 17 (7 production `.cs`, 9 test `.cs`, 1 test `.csproj`) | 7023 (nine assemblies; QuickFiler.Test 1362) | 7023 passed, 0 failed (exit 0) | 84.50% lines (55587/65783), 79.14% branches (13204/16684) | 84.51% lines (55783/66009), 79.19% branches (13292/16784) | 90.8% lines (119/131 executable changed lines) | +| PowerShell | 0 | N/A - out of scope | N/A - out of scope | N/A - out of scope | N/A - out of scope | N/A - out of scope | +| Python | 0 | N/A - out of scope | N/A - out of scope | N/A - out of scope | N/A - out of scope | N/A - out of scope | +| TypeScript | 0 | N/A - out of scope | N/A - out of scope | N/A - out of scope | N/A - out of scope | N/A - out of scope | + +**Coverage artifact and verdict by language.** Held in a four-column table, deliberately separate from +the metrics table above, so that no row outside that table can be read positionally as a coverage row. + +| Language | Coverage artifact | Verdict | Disposition | +|---|---|---|---| +| C# | post-change `artifacts/csharp/coverage.xml` (Cobertura, 18,167,952 bytes, written 2026-09-06 15:05); baseline `coverage/791-baseline.cobertura.xml` | FAIL | Non-blocking. 84.51% line coverage is below the 85% uniform floor, but it rose from 84.50% and branch coverage rose from 79.14% to 79.19%. Full reasoning below. | +| PowerShell | none required | PASS | Zero changed files on this branch. | +| Python | none required | PASS | Zero changed files on this branch. | +| TypeScript | none required | PASS | Zero changed files on this branch. | + +**Per-language comparison summary:** + +- C#: Baseline: 84.50% lines (55587/65783) -> Post-change: 84.51% lines (55783/66009). Change: +0.01% lines and +0.05% branches (79.14% -> 79.19%); numerator +196, denominator +226. New/changed-code coverage: 90.8%. Disposition: FAIL. Evidence: 119 of 131 executable changed lines covered with 0 regressions, from this reviewer's own `classes/class/lines/line` aggregation of `artifacts/csharp/coverage.xml` and `coverage/791-baseline.cobertura.xml` over the nine first-party packages, corroborated by `evidence/qa-gates/p3-t5-tests-coverage.md`, `evidence/qa-gates/p3-t7-changed-line-coverage.md` and `evidence/qa-gates/p3-t8-coverage-delta.md`. +- PowerShell: Baseline: N/A. Post-change: N/A. Change: N/A. Disposition: N/A. Evidence: N/A - zero PowerShell files changed on this branch. +- Python: Baseline: N/A. Post-change: N/A. Change: N/A. Disposition: N/A. Evidence: N/A - zero Python files changed on this branch. +- TypeScript: Baseline: N/A. Post-change: N/A. Change: N/A. Disposition: N/A. Evidence: N/A - zero TypeScript files changed on this branch. + +The C# new/changed-code figure of 90.8% is 119 of 131 executable changed lines covered, with 0 lines +regressed against baseline. The C# row reads **FAIL** because 84.51% is below the 85% uniform line floor in +`.claude/rules/quality-tiers.md` and `.claude/rules/general-unit-test.md`. The branch figure 79.19% +clears the 75% branch floor. Under the `CLAUDE.md` UT2 80% testable-denominator floor the same +measurement passes. The 80-versus-85 divergence between `CLAUDE.md` and `.claude/rules/` is +unreconciled and pre-exists on `origin/main`; this audit reports against the stricter `.claude/rules/` +figure. + +**Disposition of the C# FAIL row: non-blocking.** The branch moves the figure upward, not downward. +Both sides were aggregated by this reviewer from the two Cobertura documents using an identical +selection: baseline 55587/65783 lines and 13204/16684 branches, post-change 55783/66009 lines and +13292/16784 branches. Of the 226 newly valid lines, 196 are covered (86.7%), which is above the +repository rate and is why the aggregate rose. All 88 newly valid branches are covered. The shortfall +against 85% is therefore entirely inherited from `origin/main` and none of it is attributable to this +delivery. + +The delivery's own aggregation used a `.//line` all-descendant selection and reports 112551/133187, +roughly double this reviewer's counters because Cobertura emits many source lines under both +`class/lines/line` and `class/methods/method/lines/line`. The derived percentages are unaffected: the +delivery's 84.51% and 79.19% match this reviewer's independently computed 84.51% and 79.19% exactly. +The double-count is a presentational hazard in the absolute counters only and is recorded as +Observation N12. + +**Per changed production file, both documents, same selection:** + +| File | Baseline lines | Post lines | Baseline branches | Post branches | Per-file line verdict | +|---|---|---|---|---|---| +| `QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs` | 119/122 (97.54%) | 155/158 (98.10%) | 40/44 (90.91%) | 53/58 (91.38%) | PASS | +| `QuickFiler/Interfaces/IQfcDatamodel.cs` | 7/7 (100%) | 7/7 (100%) | 3/4 (75%) | 3/4 (75%) | PASS | +| `QuickFiler/Controllers/QfcFormController.Deactivate.cs` | 24/24 (100%) | 25/25 (100%) | 9/10 (90%) | 11/12 (91.67%) | PASS | +| `QuickFiler/Controllers/QfcHomeController.cs` | 179/236 (75.85%) | 197/258 (76.36%) | 31/58 (53.45%) | 39/68 (57.35%) | FAIL, non-blocking | +| `QuickFiler/Controllers/QfcFormController.EventHandlers.cs` | 126/254 (49.61%) | 179/308 (58.12%) | 51/100 (51%) | 73/118 (61.86%) | FAIL, non-blocking | +| `QuickFiler/Controllers/QfcDatamodel.cs` | zero `class` elements | zero `class` elements | n/a | n/a | Unmeasurable, pre-existing | +| `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` | zero `class` elements | zero `class` elements | n/a | n/a | Unmeasurable, pre-existing | + +**Disposition of the two per-file FAIL rows: non-blocking.** Both files improved on every metric — +`QfcFormController.EventHandlers.cs` by +8.51 points of line coverage and +10.86 of branch, +`QfcHomeController.cs` by +0.51 and +3.90. Neither lost coverage on any changed line +(`CHANGED-LINES-WITH-COVERAGE-REGRESSION: 0`, independently consistent with the per-file percentages +above, which cannot rise while a changed line falls without a compensating gain elsewhere in the same +file — and the delivery's line-by-line map records the seven baseline-mappable lines individually). +Both files carry `using Microsoft.Office.Interop.Outlook` and `using System.Windows.Forms` and are +Outlook-Interop event-handler surfaces in `QuickFiler`, which is exactly exemption class (c) of the +maintainer-ratified COM/VSTO/WinForms exemption in `CLAUDE.md` UT2. The twelve uncovered executable +changed lines are named individually in `evidence/qa-gates/p3-t7-changed-line-coverage.md` and this +reviewer confirmed each class by reading the code: three are the UI `SynchronizationContext` marshal, +eight are two defensive `catch` blocks whose throw sources have no injectable seam, and one is a +`log.Debug` on the live-Outlook `MoveAndIterate` completion branch. + +### 1.2.2 Coverage Artifact State + +Detail behind the Coverage Evidence Checklist bullets above. + +| Item | State | Note | +|---|---|---| +| Canonical C# artifact `artifacts/csharp/coverage.xml` | PRESENT | Cobertura, 18,167,952 bytes, `LastWriteTime` 2026-09-06 15:05:41, root element ``. Git-ignored at `.gitignore:57`, so it is a local tool output, not committed evidence. | +| C# baseline document available for independent comparison | PRESENT | `coverage/791-baseline.cobertura.xml`, 18,144,032 bytes, written 2026-09-06 14:28. Git-ignored. Produced by the same collector, settings file, assembly list and filter as the post-change run, so the two sides are comparable. | +| Committed summary reconciles with raw data | YES | The delivery's derived 84.51%/79.19% reproduce exactly under this reviewer's different selection. The delivery's absolute counters differ by the `.//line` double count, which is explained above and does not affect any percentage. | +| `TypeScript baseline coverage artifact:` | N/A - out of scope | No `.ts` or `.tsx` file changed on this branch. | +| `TypeScript post-change coverage artifact:` | N/A - out of scope | No `.ts` or `.tsx` file changed on this branch. | +| `PowerShell baseline coverage artifact:` | N/A - out of scope | No `.ps1` or `.psm1` file changed on this branch. | +| `PowerShell post-change coverage artifact:` | N/A - out of scope | No `.ps1` or `.psm1` file changed on this branch. | +| Changed-line no-regression determination | PRESENT | `evidence/qa-gates/p3-t7-changed-line-coverage.md`: 294 changed lines, 163 non-executable, 131 executable, 12 with zero hits, 0 regressions. | +| Test-run corroboration | PRESENT | `evidence/qa-gates/p3-t5-tests-coverage.md` 7023/7023/0 across nine assemblies; `evidence/regression-testing/p2-t15-quickfiler-suite.md` 1362/1362/0; this reviewer's own re-run of `QuickFiler.Test` returned 1362 tests, exit 0. | + +## 2. General Code Change Policy Compliance + +| # | Requirement | Verdict | Evidence | +|---|---|---|---| +| 2.1 | Simplicity first | PASS | The gate change keeps the whole zero-acceptance policy inside the single `deadlineEnabled && accepted.Count == 0` guard the #424 deadline already used, so the loop gains one branch rather than a second control structure. `ActionCancelAsync` reads as ten named stages through one `RunTeardownStage` helper instead of ten inline `try` blocks. | +| 2.2 | Reusability | PASS | `ParkFocusAndCancelSelectors()` is extracted once and consumed by both the `Form.Deactivate` event and the Cancel path, replacing what would otherwise be a copied body. `RunTeardownStage` and `UnregisterCancelPathHandlers` each collapse a repeated pattern. | +| 2.3 | Extensibility, no breaking public API change | PASS | The change is additive at the type level: one new `QfcDequeueStop` member, one new `IQfcDatamodel` method, one new `internal` method on `QfcFormController`, two new optional constructor parameters on the internal gate. `DeadlineExpired` is retained with an updated XML doc. `ActionCancelAsync` deliberately keeps its zero-parameter signature because `IFilerFormController.cs:11` declares it that way and that interface is outside the Write Set. Existing callers compile unchanged, confirmed by the green solution rebuild. | +| 2.4 | Separation of concerns | PASS | The three new gate log helpers are pure string construction plus two sink invocations. `TryCreateRemainingQueueAdmission` is a synchronous pure-ish factory separated from the async admission call, for a stated reason (see 2.12). No I/O is introduced. | +| 2.5 | Error handling — fail fast, no silent swallow | PASS with a recorded design tension | Five broad `catch (System.Exception)` handlers are added. All five are at a defined teardown boundary and all five log the stage name and the exception at ERROR (`RunTeardownStage`, the two `QfcHomeController.Cleanup()` blocks, the quiesce-await catch, and one test helper). AC2 mandates precisely this behavior — "a throwing stage cannot skip a later one" and "every stage, including any exception, is logged" — so the broad catch is the specified design, not a shortcut. The residual is that a programming error inside a stage now surfaces only in the log; recorded as Observation N7. The two catches that were deliberately *not* widened were verified intact: the per-item boundary catch in the deactivate routine and the gate's rejection-sink catch. | +| 2.6 | File size limit — 500 lines | PASS | Every changed `.cs` file measured at head: 497, 498, 496, 490, 487, 483, 418, 413, 393, 373, 347, 289, 258 (test-project entries excluded), 235, 168, 118, 73. Maximum 498. No `.cs` file crosses 500. Three sit within four lines of the ceiling and are recorded as Observation N9. `QuickFiler.Test/QuickFiler.Test.csproj` is 528 lines at head and was 524 at base; it is an MSBuild project file, not production code, test code, or a reusable script, so the rule's own enumeration does not reach it. Recorded as Observation N8. | +| 2.7 | Naming | PASS | `PascalCase` types and members, `camelCase` locals, `_camelCase` private fields throughout. Names are behavioral (`ParkFocusAndCancelSelectors`, `QuiesceLoaderAsync`, `LogScanBoundReached`, `MaxScanWithoutAcceptance`). No cryptic abbreviation is introduced. | +| 2.8 | Comment why, not what | PASS | Every non-obvious construct carries its reason: why `checkpointOrigin` is separate from `start`, why the bounds are evaluated ahead of the take, why `CancellationToken.None` is passed to the bound delay, why `MaxScanWithoutAcceptance` is an auto-property rather than a `readonly` field (CS0414 under `TreatWarningsAsErrors`), why `TryCreateRemainingQueueAdmission` is synchronous, and why `ButtonCancel_Click` no longer rethrows. This reviewer verified the CS0414 and the state-machine claims against the compiler behavior each describes; both are correct. | +| 2.9 | Mandatory toolchain loop, one uninterrupted pass | PASS | `evidence/qa-gates/p3-t6-loop-closure.md` records one restart caused by the first `csharpier format` rewriting files, then five green steps in one uninterrupted pass with `FINAL-PASS-ANY-FILE-REWRITTEN: NO`. This reviewer independently re-ran the format check, both `/t:Rebuild` gate builds and the `QuickFiler.Test` assembly at head; all four exit 0. | +| 2.10 | Dependencies — none added | PASS | No `packages.config` change and no `` or `` change. The single `.csproj` edit adds four `` entries for the four new test files, which the legacy non-SDK project format requires. `Microsoft.Extensions.Time.Testing` was already referenced. | +| 2.11 | No absolute host paths in artifacts | FAIL, non-blocking | One committed artifact embeds an absolute host path including the account name: `runbooks/live-outlook-cancel-teardown-verification.runbook.md:16` reads `C:\Users\DanMoisan\repos\TaskMaster\TaskMaster\bin\Debug\TaskMaster.vsto`. Every other committed document in the feature folder is clean, and no added source line contains `C:\Users\`. Non-blocking: the occurrence is a single line in a human runbook where a concrete manifest path has operational value, no `.claude/rules/` file or `CLAUDE.md` section codifies the prohibition, and hundreds of committed documents on `origin/main` already carry the same pattern. Recorded as finding N6. | +| 2.12 | Bugfix workflow — failing regression test first | PASS | Four separate fail-before records exist and each names its exception type: `p1-t16-gate-fail-before.md` (exit 1, 12 failures), `p1-t17-cancel-teardown-fail-before.md` (exit 1, 6), `p1-t18-home-cleanup-fail-before.md` (exit 1, 2), `p1-t19-datamodel-teardown-fail-before.md` (exit 1, 5). The last reproduces the reported crash exactly: `System.ArgumentException: Delegate to an instance method cannot have null 'this'`, character-for-character the message in `issue.md:65`, raised from `TryQueueRemainingMailItemAsync` without Outlook. `p2-t14-pass-after.md` records all 25 inventory tests green and was re-run verbatim against the final build after the `[P2-T15]` repair, so it describes the delivered code. The plan's own predicted failure mode for two tests was wrong (arrange-stage rather than `NotImplementedException`) and that divergence is disclosed rather than absorbed. | +| 2.13 | Minimal targeted fix, no opportunistic refactor | PASS | The seven production files touched are exactly the Write Set. The five files the spec names as non-goals — `QfcCollectionController.cs`, `QfcHomeController.Iteration.cs`, `RibbonController.cs`, `Settings.Designer.cs`, `AppQuickFilerSettings.cs` — are absent from `git diff --name-only` at head, verified by this reviewer. `QfcFormController.SetupDisposal.cs` is likewise untouched; the ordering defect is corrected by calling its existing unregister methods earlier from the Cancel path. | +| 2.14 | Architecture-boundary tests not weakened | PASS | `[P2-T15]` surfaced a real violation of the #731 three-owner `IEmailMoveMonitor` topology pin: an `async` method hoists its locals into a compiler-generated state-machine type, which became a fourth declaring type. The repair moved the snapshot into a synchronous helper so no state machine is generated, rather than relaxing the pin's expected count from 3 to 4. This reviewer read the repaired code and the pin is intact at its original strength. | + +## 3. Language-Specific Code Change Policy Compliance (C#) + +| # | Requirement | Verdict | Evidence | +|---|---|---|---| +| 3.1 | CSharpier formatting via `dotnet tool run` | PASS | This reviewer ran `dotnet tool run csharpier check .`: `Checked 1587 files in 4202ms`, exit 0. The count equals the delivery's recorded 1587, so the processed file set is unchanged. `dotnet format` appears nowhere on the branch. | +| 3.2 | .NET analyzers, `EnableNETAnalyzers` + `EnforceCodeStyleInBuild` | PASS | This reviewer ran the exact `CLAUDE.md` analyzer command with `/t:Rebuild`, exit 0. Delivery record `evidence/qa-gates/p3-t3-msbuild-analyzers.md`: 0 warnings, 0 errors. | +| 3.3 | Nullable / type checking with `TreatWarningsAsErrors=true` | PASS | This reviewer ran the exact `.github/workflows/_build-nullable.yml` command with `/t:Rebuild`: `Build succeeded. 0 Warning(s) 0 Error(s)`, exit 0. `/p:Nullable=enable` was correctly not passed and `/t:Build` was correctly not substituted. | +| 3.4 | Per-file nullable opt-in respected | PASS | No changed file adds or removes a `#nullable` directive. The `is not null` and `?.` forms used in the new code are language constructs available regardless of the pragma. | +| 3.5 | Strong contracts, explicit APIs, XML docs on non-obvious behavior | PASS | `IQfcDatamodel.QuiesceLoaderAsync` carries a full contract: what it cancels, what it awaits, that it returns at the earlier of completion and bound, that it never throws for the timeout case, that it must be awaited before any field is nulled, and that it must not become a blocking wait inside `Cleanup()` (with the #731 finding cited). The `ScanCapReached` and `DeadlineExpired` XML docs both state the caller obligation — leave the UI queue open — rather than only describing the value. | +| 3.6 | Null-safety by default | PASS | `QfcDatamodel.Cleanup()` replaces two unguarded dereferences with a snapshot-then-`is not null` test and a `?.`. `TryCreateRemainingQueueAdmission` snapshots both fields into locals before testing them, which is the correct shape for fields written on another thread; reading `_masterQueue` twice could otherwise observe two values. `ParkFocusAndCancelSelectors` adds the `_formViewer` guard the extraction made reachable, with a comment stating why it is now live code rather than defensive padding. | +| 3.7 | Banned symbols (`BannedSymbols.txt`) | PASS | Scan over all 1671 added lines: zero occurrences of `DateTime.Now`, `DateTime.UtcNow`, `Random.Shared`, `Thread.Sleep` or `Task.Delay`. | +| 3.8 | Time seam guidance — `TimeProvider` for touched time-dependent code | PASS | The new `QuiesceLoaderAsync` bound is `TimeProvider.Delay`, not `Task.Delay`, and the gate's new bound checks use the already-injected `_timeProvider.GetElapsedTime`. Both are drivable by `FakeTimeProvider` in tests, which is what makes the two new time-bound tests deterministic. | +| 3.9 | Async and resource safety | PASS | `ConfigureAwait(false)` on the two new library-side awaits. `Task.WhenAny(loader, bound)` with `CancellationToken.None` on the bound is correct: the bound must outlive the token this method just cancelled, or a hung loader would leave the Cancel path with no exit — the code states this reason in place. | +| 3.10 | Public surface minimal, `internal` preferred | PASS | `MaxScanWithoutAcceptance`, `ZeroAcceptanceCeiling`, `LoaderQuiesceBound`, `ParkFocusAndCancelSelectors` and `QuiesceDebugLog` are all `internal`; the three gate log helpers and `TryCreateRemainingQueueAdmission` are `private`. The one new public member is the interface method AC2 requires. `InternalsVisibleTo("QuickFiler.Test")` already exists, so no grant was widened. | +| 3.11 | No new suppressions or analyzer debt | PASS | Scan over all added lines for `SuppressMessage` and `#pragma warning disable`: zero hits. Scan for `ExcludeFromCodeCoverage`: zero added, zero removed. | + +## 4. Language-Specific Unit Test Policy Compliance (C#) + +| # | Requirement | Verdict | Evidence | +|---|---|---|---| +| 4.1 | MSTest framework | PASS | All four new files use `Microsoft.VisualStudio.TestTools.UnitTesting` with `[TestClass]`, `[TestMethod]` and `[TestInitialize]`. `QfcStreamingDequeueConfidenceGateTests.Part4.cs` correctly omits `[TestClass]` because it is a fourth part of a partial class whose base file already carries it (`AttributeUsage.AllowMultiple = false`, so repeating it is CS0579). Scan for `xunit` and `nunit`: zero hits. | +| 4.2 | Moq for mocks | PASS | `Mock`, `Mock`, `Mock`, `Mock`, `Mock`, `Mock`, `Mock`, `Mock`. Ordering is observed through `Callback` handlers on those mocks rather than through a bespoke framework. | +| 4.3 | FluentAssertions preferred | PASS | Every assertion in the four new files uses `Should()`. No MSTest `Assert.*` call is added. | +| 4.4 | Assertions pin the intended property | PASS with one gap | The ordering assertions compare `FirstIndexOf` of two markers, which fails if the order inverts and cannot pass vacuously because each marker's presence is separately asserted `BeGreaterThanOrEqualTo(0)`. The `#608` pin injects a cap of 2 that is deliberately smaller than the 21-candidate scan it performs, so widening the guard to evaluate the bounds after an acceptance would fail it — that is real pinning power, not a restatement. The gap: no test asserts the content of the `LogScanBoundReached` line. A grep of `QuickFiler.Test` for `scan bound reached`, `Bound=` and `Decision=stop` returns no match, so the `scan-cap` versus `zero-acceptance-ceiling` discriminator that AC1's "the bound decision is logged" clause names is unpinned. Recorded as finding N3. | +| 4.5 | Deterministic seams for all external boundaries | PASS | `FakeTimeProvider` for both clocks; injected `Action` delegates for both log sinks; `TaskCompletionSource` for the hanging loader; `FormatterServices.GetUninitializedObject` to bypass COM-bound constructors; `Control.ControlCollection` over a bare `Control` with an empty exclusion list to satisfy the unregister guard without creating a window handle. No live Outlook object and no WinForms message loop. | +| 4.6 | New test seams justified and minimal | PASS | `QfcDatamodel.QuiesceDebugLog` is an added `internal Action` not named in the spec. The stated reason is verified: `QfcDatamodel` logs through log4net, no memory-appender convention exists anywhere in `QuickFiler.Test`, attaching one would mutate a process-global logger repository and break test independence, and the injected-delegate convention was already established by the gate's `debugLog` parameter. The same lines still reach log4net at INFO in production. Disclosed as deviation 2 in `spec.md`. | +| 4.7 | Coverage targets for new and changed methods | PARTIAL | 90.8% of executable changed lines are covered against a `>= 90%` target for new and changed code, which the target meets. At whole-file granularity two modified files sit below the 85% per-file floor; see the disposition in section 1.2.1. The two `QfcDatamodel` partials are structurally unmeasurable and carry named passing tests as substitute evidence for each changed member; this reviewer confirmed all five substitute tests are recorded `PASS-AFTER`. | + +## 5. Test Coverage Detail + +All figures below were produced by this reviewer directly from the Cobertura XML, not read from a +delivery artifact. + +### 5.1 Repo-wide, first-party (nine production assemblies) + +First-party allowlist: `Tags`, `ToDoModel`, `TaskVisualization`, `UtilitiesCS`, `QuickFiler`, +`TaskTree`, `TaskMaster`, `SVGControl`, `VBFunctions`. Vendor packages present in the document +(`log4net`, `Microsoft.IO.RecyclableMemoryStream`, `Mono.Reflection`, `System.Interactive`, +`System.Linq.Async`) are excluded from numerator and denominator. + +- `coverage/791-baseline.cobertura.xml`, selection `classes/class/lines/line`: 55587/65783 lines = 84.5013%, 13204/16684 branches = 79.1417%. +- `artifacts/csharp/coverage.xml`, same selection: 55783/66009 lines = 84.5081%, 13292/16784 branches = 79.1897%. +- Delta: +196 covered lines against +226 valid lines; +88 covered branches against +88 valid branches. + +The whole-document root attributes read `lines-covered="58527" lines-valid="83181"` (70.36%), which +includes the five vendor packages and is not the policy figure. + +### 5.2 Per-package, post-change + +- `QuickFiler` 10138/12610 lines = 80.40%, 2409/3121 branches = 77.19%. +- `UtilitiesCS` 38844/43780 = 88.73%, 9228/11111 = 83.05%. +- `TaskVisualization` 1445/1607 = 89.92%. `Tags` 710/766 = 92.69%. `TaskTree` 296/310 = 95.48%. +- `TaskMaster` 2395/3204 = 74.75%. `ToDoModel` 1074/1874 = 57.31%. `SVGControl` 877/1854 = 47.30%. + +`QuickFiler`, the package this change touches, is at 80.40% line and 77.19% branch. The branch figure +clears the 75% floor; the line figure is below 85% and is pre-existing. + +### 5.3 Per changed production file + +Reproduced in the table under section 1.2.1. Summary: five measurable files, all five improved or +held both metrics; two of the five sit below the 85% per-file line floor and both are Outlook-Interop +event-handler surfaces inside the ratified `CLAUDE.md` UT2 exemption class (c). Two files are +structurally unmeasurable because `QfcDatamodel.cs:25` carries a type-level +`[ExcludeFromCodeCoverage]` that predates this branch; this reviewer confirmed both documents emit +**zero** `class` elements for both partials, so the condition is not one this branch introduced. + +### 5.4 Changed-line no-regression gate + +`CHANGED-LINES-WITH-COVERAGE-REGRESSION: 0`. 294 changed lines across the five measurable paths, 163 +non-executable, 131 executable, 12 with zero hits (90.8% covered). Seven lines had an equal-count +hunk and therefore a one-to-one baseline mapping; none lost coverage. The remaining changed lines are +pure insertions with no baseline counterpart and are correctly recorded `baseline=none` rather than +attributed borrowed coverage. This reviewer's per-file percentages are consistent with that +determination: no file's line or branch rate fell. + +### 5.5 New code coverage + +New/changed-code coverage: **90.8%** lines. The 12 uncovered executable lines are named individually +and each was checked against the code by this reviewer: `EventHandlers.cs:139-141` (the UI +`SynchronizationContext` marshal, which needs a WinForms message loop the headless policy forbids), +`EventHandlers.cs:160-163` (the catch around the awaited quiesce, reachable only if the interface +contract that `QuiesceLoaderAsync` never throws for timeout is violated), `EventHandlers.cs:289` (a +`log.Debug` on the live-Outlook `MoveAndIterate` completion branch), and `QfcHomeController.cs:382-385` +(the catch around a `BackgroundWorker` event-handler detach, which has no seam that can be made to +throw). All four classes are host-bound or contract-defence, not omitted coverage. + +## 6. Test Execution Metrics + +Rendered as bullets rather than a table so no second table in this document has a language-like first +column. + +- Baseline, nine first-party assemblies at the merge base (`evidence/baseline/p0-t11-coverage.md`, `p0-t10-quickfiler-tests.md`): 7000 total, 7000 passed, 0 failed; `QuickFiler.Test` alone 1339/1339/0. +- Fail-before, gate class (`evidence/regression-testing/p1-t16-gate-fail-before.md`): exit 1, 12 failures. +- Fail-before, Cancel teardown class (`p1-t17`): exit 1, 6 failures. +- Fail-before, home cleanup class (`p1-t18`): exit 1, 2 failures. +- Fail-before, datamodel teardown class (`p1-t19`): exit 1, 5 of 5 failures, including the reported `System.ArgumentException` reproduced verbatim. +- Pass-after, six affected classes (`p2-t14`): exit 0, 76 total, 76 passed; all 25 inventory names recorded `PASS-AFTER`; re-run verbatim against the final build after the `[P2-T15]` repair with identical counts. +- Post-change, whole `QuickFiler.Test` assembly (`p2-t15`): exit 0, 1362/1362/0, `NEWLY-FAILING: NONE`, +23 against the 1339 baseline. +- Final gate run, nine assemblies with coverage (`evidence/qa-gates/p3-t5-tests-coverage.md`): exit 0, 7023 total, 7023 passed, 0 failed, +23 against the 7000 baseline. +- This reviewer's independent re-run of `QuickFiler.Test` at head: `Test Run Successful. Total tests: 1362`, exit 0. +- Test selection deviation, applied identically to baseline and final so the comparison is like-for-like: `/TestCaseFilter:TestCategory!=LiveOutlook` plus exclusion of four `UtilitiesCS.Test` shell-icon classes that stall `vstest` on this machine. Recorded as Observation N14. + +## 7. Code Quality Checks + +| Check | Command | Result | +|---|---|---| +| Format check | `dotnet tool run csharpier check .` | PASS — `Checked 1587 files in 4202ms`, exit 0, re-run by this reviewer | +| Analyzer build | `msbuild TaskMaster.sln /t:Rebuild ... /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` | PASS — exit 0, re-run by this reviewer | +| Nullable build | `msbuild TaskMaster.sln /t:Rebuild ... /p:TreatWarningsAsErrors=true` | PASS — `0 Warning(s) 0 Error(s)`, exit 0, re-run by this reviewer | +| Banned symbol scan (added lines) | regex scan of all 1671 added `.cs`/`.csproj` lines | PASS — 0 hits for `Thread.Sleep`, `Task.Delay`, `DateTime.Now`, `DateTime.UtcNow`, `Random.Shared` | +| Suppression scan (added lines) | regex scan of all added lines | PASS — 0 `SuppressMessage`, 0 `#pragma warning disable`, 0 `ExcludeFromCodeCoverage` | +| Temp-file scan (added lines) | regex scan of all added lines | PASS — 0 `GetTempPath`, 0 `GetTempFileName` | +| Confidentiality masking scan | grep of the feature folder for the account name and absolute host paths | FAIL — one occurrence, `runbooks/live-outlook-cancel-teardown-verification.runbook.md:16`; 0 occurrences in any added source line | +| Workflow change scan | `git diff --name-only 7c8ac9ae..HEAD` filtered to `.github/workflows/**`, `.github/actions/**`, `scripts/benchmarks/**` | PASS — 0 matching paths, so the modified-workflow-needs-green-run rule does not fire | +| Scope boundary scan | `git diff --name-only` over `'*.cs' '*.csproj'` | PASS — 17 paths, exactly the Write Set plus test files under `QuickFiler.Test/Controllers` and the test `.csproj`; all five named exclusions absent | +| File size scan | line count of every changed `.cs` file at head | PASS — maximum 498, no `.cs` file over 500 | +| Test file size scan | line count of every changed test `.cs` file at head | PASS — maximum 498 (`QfcStreamingDequeueConfidenceGateTests.Part2.cs`) | +| Working tree cleanliness | `git status --porcelain --untracked-files=all` | PASS — empty before and after this review | + +## 8. Gaps and Exceptions + +### PA-1 — Repository-wide C# line coverage is 84.51%, below the 85% uniform floor (FAIL row; non-blocking) + +`.claude/rules/quality-tiers.md` and `.claude/rules/general-unit-test.md` set a uniform 85% line +floor. `CLAUDE.md` UT2 sets 80% against a testable denominator. The measured figure passes one and +fails the other. This audit reports the stricter figure as FAIL and dispositions it non-blocking +because the branch raises it from 84.50% to 84.51% and covers 86.7% of the executable surface it +adds. The 80-versus-85 divergence is a pre-existing documentation conflict on `origin/main` and is not +this branch's to resolve. + +### PA-2 — Two modified production files sit below the 85% per-file line floor (FAIL rows; non-blocking) + +`QfcFormController.EventHandlers.cs` at 58.12% and `QfcHomeController.cs` at 76.36%. Both improved +(from 49.61% and 75.85%), neither regressed on any changed line, and both are Outlook-Interop +event-handler surfaces inside the maintainer-ratified `CLAUDE.md` UT2 exemption class (c). No +remediation is recommended: the uncovered remainder is host-bound code with no injectable seam, and +the correct long-term response is the extraction the Coverage Exclusion Policy describes, not a test +that fakes a WinForms message loop. + +### PA-3 — New production code lands inside a type excluded from coverage measurement (Advisory; pre-existing repository condition) + +115 added lines land in `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs`, part of a type +carrying `[ExcludeFromCodeCoverage]` at `QfcDatamodel.cs:25`. This reviewer confirmed both the +baseline and the post-change Cobertura emit zero `class` elements for both partials, so the attribute +is pre-existing and the branch neither added nor extended it. The consequence is that +`QuiesceLoaderAsync`, `LogQuiesceOutcome`, `TryQueueRemainingMailItemAsync`, +`TryCreateRemainingQueueAdmission` and the `Worker_DoWork` capture are outside the coverage +denominator. Substitute evidence exists and was verified: five named tests, each recorded +`PASS-AFTER`, cover each changed member. `.claude/rules/general-unit-test.md`'s Coverage Exclusion +Policy ("No production file may be excluded from coverage measurement") and `CLAUDE.md` UT2's ratified +`[ExcludeFromCodeCoverage]` exemption are in direct conflict on this point; the conflict pre-exists +this branch. Recommended follow-up: promote the extraction of host-neutral queue logic out of +`QfcDatamodel` to a tracked issue. + +### PA-4 — Coverage collected with `dotnet-coverage`, not `vstest /EnableCodeCoverage` (Advisory; correctly justified) + +Disclosed by the delivery as deviation 4. `/EnableCodeCoverage` writes a binary `.coverage` file +rather than the Cobertura XML the same criterion requires at `artifacts/csharp/coverage.xml`, and the +two collectors conflict when combined. The substitution wraps the same `vstest.console.exe`, the same +nine assemblies, the same runsettings and the same switches, and both sides of the comparison were +produced by one collector and one configuration. This reviewer independently parsed the resulting +document and reproduced the delivery's derived percentages, which is the substantive check. Accepted. + +### PA-5 — Human-interaction exception HI-1 is outstanding + +AC2 states that the live-Outlook confirmation (keyboard usable after Cancel, new Cancel-stage log +lines present, no null-`this` loader error) is a human follow-up performed per +`runbooks/live-outlook-cancel-teardown-verification.runbook.md` and does not gate the automated +review. The exception is declared in `issue.md:102`, in `spec.md:257`, in the `## Next Step` checklist +at `issue.md:109`, and in `spec.md` Rollout & Follow-up. It is outstanding at review time. The AC2 +check-off does not depend on it, and this audit does not treat it as a gap in the automated evidence. +It remains owed before the behavioral claim about the Outlook keyboard can be considered confirmed in +the field. + +### PA-6 — Spec-declared follow-ups not yet promoted (Advisory; owed at PR time) + +`spec.md` names issue #792 for the breadcrumb WebView2 initialization failure and it exists as a +promoted potential entry at +`docs/features/potential/promoted/2026-09-06-breadcrumb-webview2-init-fails-resource-not-in-correct-state.md`. +Three further defect classes surfaced by this review have no tracked issue: PA-3's coverage exclusion, +finding N1 (the disposed-but-not-nulled `_tokenSource`), and finding N2 (the unprotected +`_parentCleanup?.Invoke()` in `QfcFormController.Cleanup()`). Promoting them is owed at PR time. + +### Deviations already disclosed by the executor and confirmed adequate + +All five are stated by name in `spec.md` Rollout & Follow-up or in the referenced evidence artifact, +and each was independently checked against the code: + +1. The `ActionCancelAsync` trigger discriminator is a call-site log line rather than a method + parameter, because `IFilerFormController.cs:11` declares `Task ActionCancelAsync();` and that file + is outside the Write Set. Verified: the interface file is absent from the diff and the call-site + `log.Debug` exists at `EventHandlers.cs:289`. +2. `QfcDatamodel.QuiesceDebugLog` is an added internal test seam not named in the spec. Justification + verified; see row 4.6. +3. The retargeting surface is seven tests rather than the four Test Strategy names. Verified by + reading all seven diffs; each preserves its original intent against the new behavior. +4. Coverage collected with `dotnet-coverage`. See PA-4. +5. The two `QuiesceLoaderAsync` tests fail one step earlier than predicted, in Arrange rather than + Act. Disclosed in `p1-t19`; both remain red before and green after, so the fail-before evidence is + unaffected. This is weaker RED-first evidence than the other two classes carry, because an + arrange-stage fail-closed guard is not the defect reproducing; the two tests that carry the + substantive RED-first proof (`TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_...` and + `Cleanup_CalledTwice_DoesNotThrow`) fail with the real exception types. + +## 9. Summary of Changes + +- **Production, 7 files, +332/-46 lines.** `QfcStreamingDequeueConfidenceGate.cs` turns the + zero-acceptance deadline branch into a logged checkpoint bounded by a 250-candidate scan cap and a + 120-second ceiling, adds a launch log line carrying the cutoff, and adds three log helpers. + `IQfcDatamodel.cs` adds the `ScanCapReached` stop reason and the `QuiesceLoaderAsync` contract. + `QfcDatamodel.QueueProcessing.cs` adds `QuiesceLoaderAsync`, `LogQuiesceOutcome`, the relocated and + guarded `TryQueueRemainingMailItemAsync`, the synchronous `TryCreateRemainingQueueAdmission`, the + `_remainingLoadTask` field and the `QuiesceDebugLog` seam. `QfcDatamodel.cs` null-guards `Cleanup()` + and captures the loader task in `Worker_DoWork`. `QfcFormController.Deactivate.cs` extracts + `ParkFocusAndCancelSelectors()`. `QfcFormController.EventHandlers.cs` reorders `ActionCancelAsync` + into ten logged stages under `finally` and stops `ButtonCancel_Click` rethrowing. + `QfcHomeController.cs` rewrites `Cleanup()` as two guarded blocks under one `finally` that disposes + the token source and detaches the worker-completed handler. +- **Tests, 9 files, +1035/-65 lines, +23 tests.** Four new files (gate Part4, Cancel teardown, home + cleanup, datamodel teardown) and five retargeted files. Seven pre-existing tests that encoded the + superseded #424/#608 behavior were retargeted rather than deleted. +- **Project files, 1.** Four `` entries in `QuickFiler.Test/QuickFiler.Test.csproj`. +- **Documentation and evidence, 40 files.** `spec.md`, `user-story.md`, `issue.md`, the atomic plan, + a research note, a runbook, two promoted potential entries, and 33 evidence artifacts under + `/evidence/`. +- **Agent memory, 6 files.** Notes carried by the task-researcher, atomic-planner and atomic-executor + agents. These are documentation of agent behavior, contain no host paths and no credentials, and + were audited rather than excluded from scope. + +## 10. Compliance Verdict + +**PASS. Blocking findings: 0.** + +| Area | Verdict | +|---|---| +| General Unit Test Policy | PASS (one recorded determinism exception matching pre-existing convention) | +| General Code Change Policy | PASS (one non-blocking FAIL row: an absolute host path in one committed runbook line) | +| C# Code Change Policy | PASS | +| C# Unit Test Policy | PASS (one PARTIAL: per-file coverage floor on two exempted Outlook-Interop files) | +| Test Coverage | FAIL rows recorded and dispositioned non-blocking; no regression at any scope | +| Test Execution | PASS | +| Code Quality Checks | PASS (one FAIL row: confidentiality masking) | +| Evidence Location Compliance | PASS | +| Modified-workflow green-run rule | Does not fire — no workflow, action, or benchmark path changed | +| Acceptance Criteria (see `feature-audit.2026-09-06T15-31.md`) | 6 of 6 PASS | + +Remediation was not triggered. No finding is blocking, no acceptance criterion is FAIL or PARTIAL, no +toolchain step failed, and coverage regressed at no scope. `remediation-inputs.2026-09-06T15-31.md` +was therefore not produced. The six Minor findings and ten Observations are recorded with concrete +recommendations in `code-review.2026-09-06T15-31.md`; three of them are named in PA-6 as owed +promotions at PR time. + +**Go/no-go: GO for PR.** + +## Appendix A: Test Inventory + +Tests added by this branch, 23 in total. + +`QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs` (7): +`DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance`, +`DequeueAsync_ZeroAcceptedAndSourceDrained_ReportsSourceExhausted`, +`DequeueAsync_ZeroAcceptedAndCapReached_StopsAndReportsScanCapReached`, +`DequeueAsync_ZeroAcceptedAndCeilingReached_StopsWhileSourceStillRefilling`, +`DequeueAsync_CheckpointExpiry_LogsCutoffAndCounts`, +`DequeueAsync_Launch_LogsCutoffQuantityAndBounds`, +`DequeueAsync_NonEmptyPrefix_UnchangedByCheckpoint`. + +`QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs` (8): +`ActionCancelAsync_ResetsKbdActive_WhenKeyboardDialogActive`, +`ActionCancelAsync_DoesNotToggle_WhenInactive`, +`ActionCancelAsync_ParksFocusAndCancelsBreadcrumbSelectors`, +`ActionCancelAsync_UnregistersHandlersBeforeGroupsCleanup`, +`ActionCancelAsync_AwaitsLoaderQuiesceBeforeGroupsCleanup`, +`ActionCancelAsync_GroupsCleanupThrows_StillInvokesParentCleanup`, +`ButtonCancel_Click_ActionThrows_DoesNotRethrow`, +`ActionCancelAsync_CalledTwice_InvokesParentCleanupOnce`. + +`QuickFiler.Test/Controllers/QfcHomeControllerCleanupTests.cs` (2): +`Cleanup_DatamodelCleanupThrows_StillInvokesParentCleanup`, +`Cleanup_DisposesTokenSourceAndDetachesWorkerCompleted`. + +`QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs` (5): +`TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing`, +`QuiesceLoaderAsync_LoaderCompletes_ReturnsBeforeTimeout`, +`QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs`, +`Cleanup_CalledTwice_DoesNotThrow`, +`Worker_DoWork_CapturesRemainingLoadTask`. + +`QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs` (1): +`IterateQueueAsync_EmptyBatchWithScanCapReached_DoesNotCompleteAdding`. + +Tests retargeted rather than deleted, 7 in total: +`DequeueAsync_LowYieldStream_StopsScanningAtDefaultFirstBatchDeadline` -> +`DequeueAsync_LowYieldStream_ContinuesPastDefaultDeadlineToTheQualifier`; +`DequeueAsync_DeadlineExpiresWithZeroAccepted_ReturnsEmptyListAtTheBound` -> +`DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesToSourceExhaustion`; +`DequeueAsync_AfterDeadlineReturn_StopsTakingAndLeavesUnscannedCandidates` -> +`DequeueAsync_AfterScanCapReached_StopsTakingAndLeavesUnscannedCandidates`; +`DequeueAsync_DeadlineExpiry_EmitsOneExpiryLineAndKeepsPerCandidateLogging` -> +`DequeueAsync_CheckpointExpiry_EmitsCheckpointLineAndKeepsPerCandidateLogging`; +`DequeueAsync_DeadlineExpiresWithZeroAccepted_ReportsDeadlineExpiredStop` -> +`DequeueAsync_ZeroAcceptedAndCapReached_ReportsScanCapReachedStop`; +`DequeueNextItemGroupWithOutcomeAsync_DeadlineExpiredGate_ReportsDeadlineExpiredStop` -> +`DequeueNextItemGroupWithOutcomeAsync_ZeroAcceptanceCeilingGate_ReportsScanCapReachedStop`; +`DequeueAsync_ProgressCallback_StopsReportingOnceTheMethodReturns` (name unchanged, bound rebased from +the 3 s deadline onto an injected scan cap of 3). + +Architecture pin preserved unchanged: +`QfcMoveMonitorTopologyTests.NoTypeDeclaresMoreThanOneEmailMoveMonitorField` still expects exactly +three declaring types. + +## Appendix B: Toolchain Commands Reference + +Commands this reviewer executed, all read-only against tracked content: + +``` +git -C diff --numstat 7c8ac9ae34b8b3dda9134a5e310f39742fd2f0b6..59536368756d979f3f72268dfb4dfd0d4b2f7d9f +git -C log --oneline 7c8ac9ae..59536368 +git -C diff 7c8ac9ae..59536368 -- +git -C diff --name-only 7c8ac9ae..51b557df -- '*.cs' '*.csproj' +git -C status --porcelain --untracked-files=all +dotnet tool run csharpier check . +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true + QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /Settings:scripts\vscode\TaskMaster.cli.runsettings /InIsolation /Logger:trx /ResultsDirectory: /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None /TestCaseFilter:TestCategory!=LiveOutlook +``` + +`` resolves to +`\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe`. +The `Extensions\TestPlatform` binary is used deliberately rather than the `CommonExtensions` +`TestWindow` one, which drops the Moq binding redirect. + +Coverage aggregation was performed with `System.Xml.XmlDocument` over +`artifacts/csharp/coverage.xml` and `coverage/791-baseline.cobertura.xml`, selecting +`classes/class/lines/line` per package and parsing `condition-coverage="… (h/t)"` for branches. The +same script was run against both documents so the two sides are computed identically. + +Commands referenced from delivery evidence and not re-executed by this reviewer: + +``` +dotnet tool run csharpier format . +dotnet-coverage collect --output artifacts\csharp\coverage.xml --output-format cobertura --settings coverage\791-effective-coverage.config -- ... +``` diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/research/2026-09-06T13-00-quickfiler-hc-cancel-teardown-deadline-791-research.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/research/2026-09-06T13-00-quickfiler-hc-cancel-teardown-deadline-791-research.md new file mode 100644 index 000000000..06fea857d --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/research/2026-09-06T13-00-quickfiler-hc-cancel-teardown-deadline-791-research.md @@ -0,0 +1,438 @@ +# Research: QuickFiler High Confidence deadline policy and Cancel teardown (#791) + +- Date: 2026-09-06 +- Author: task-researcher agent +- Scope: static analysis and design research only; no code changes +- Canonical issue: #791 +- Base commit read: `7c8ac9ae` (worktree `TaskMaster-wt/2026-09-06T09-59`, clean) + +## Summary + +Two independent defects share one feature folder. AC1 concerns +`QfcStreamingDequeueConfidenceGate.DequeueAsync`, whose first-batch deadline terminates the scan +while `accepted.Count == 0`, producing an empty High Confidence dialog even though unscanned +candidates remain in the master queue. AC2 concerns the Cancel teardown, which is unordered: the +background queue loader is never awaited before `QfcDatamodel.Cleanup()` nulls the fields the loader +still dereferences, the keyboard-active flag and WebView2 focus are reset only on paths the Cancel +path unsubscribes or never reaches, and the ribbon release callback is not protected by a `finally`. +Every established fact supplied to this research was re-verified against the current tree and is +cited below with file:line. Two prior acceptance criteria (#424 and #608) explicitly ratified the +behavior AC1 now changes; they are identified so the plan supersedes them deliberately rather than +regressing them silently. + +## Current State Analysis + +### The gate and its deadline + +- Cutoff conversion to per-mille: `QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs:129` + (`_cutoff = (long)Math.Round(threshold * 1000, 0)`). The scan loop is `:168-237`. +- The deadline is evaluated only while nothing has been accepted: + `:172-176` guards on `deadlineEnabled && accepted.Count == 0 && elapsed >= _firstBatchDeadline`, + then returns `QfcDequeueStop.DeadlineExpired` at `:179`. `#608` deliberately restricted the test to + the zero-accepted case (documented at `:88-95`). +- `scanned++` occurs at `:205`, after `_scoreLoader` returns (`:199-203`), so a logged + `Scanned=38 Accepted=0` means 38 completed scores all strictly below `_cutoff` — the observation in + the issue is consistent with the code. +- Rejected candidates leave the session queue permanently: the take at `:182` removes the item and the + reject branch (`:215-232`) only unhooks it. A rerun therefore rescans the same view prefix, matching + the reported determinism. +- `DefaultFirstBatchDeadline = TimeSpan.FromSeconds(12)` at `:56`. +- `LogDeadlineExpiry` (`:242-250`) emits `Accepted`, `Scanned` and `Deadline`; it does not emit + `_cutoff`, and there is no launch-time log line at all. `LogScore` (`:252-260`) logs each score. +- Logging idiom confirmed: `private static readonly log4net.ILog logger = log4net.LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);` + at `QfcStreamingDequeueConfidenceGate.cs:45-47`, `QfcHomeController.cs:21-23`, `QfcDatamodel.cs:28-30`, + `QfcFormController.cs:21-23`. `QfcDatamodel.cs:97-99` and `QfcFormController.cs:67-69` each declare a + second identical logger named `log`; both names are live in those files. + +### Who consumes `DeadlineExpired` + +Located by two independent searches (a repository-wide content search for `DeadlineExpired`, and a +declaration search for `enum QfcDequeueStop` followed by reading each referencing production file): + +- Declaration and XML docs: `QuickFiler/Interfaces/IQfcDatamodel.cs:30-40`. +- Producer: gate `:179`, projected verbatim by + `QfcDatamodel.QueueProcessing.cs:177-200` (`DequeueWithHighConfidenceGateWithOutcomeAsync`). +- Consumer 1 — `QfcHomeController.RunAsync` (`QfcHomeController.cs:271-341`): calls the outcome + member at `:300-305` with `DefaultFirstBatchDeadline` and the scan-progress sink, then loads + `preScored` at `:322`. It does not read `batch.Stop`; an empty accepted set simply loads zero rows. +- Consumer 2 — `QfcHomeController.IterateQueueAsync` (`QfcHomeController.Iteration.cs:22-48`): reads + `batch.Stop` and calls `QfcQueue.CompleteAddingAsync` only under `SourceExhausted` (`:39-47`). This + branch is pinned by #446 AC-6, so any new stop reason must not be routed into it. + +### Streaming loader and the `_remainingLoadActive` refill signal + +- `_remainingLoadActive` is declared `volatile` at `QfcDatamodel.QueueProcessing.cs:23`, set true + immediately before `worker.RunWorkerAsync()` (`QfcDatamodel.cs:256`, `:283`) and cleared in the + `finally` of `Worker_DoWork` (`:193-200`). +- The gate receives it as `sourceActive` (`QfcDatamodel.QueueProcessing.cs:190`) and uses it at + `gate:185-196`: when `_tryTakeNext()` returns null and the producer is still live, the gate waits + `timeOut` ms through `TimeProvider.Delay` and retries; `SourceExhausted` is reported only when + `timeOut <= 0` or the second consecutive empty take coincides with a dead producer. + **Consequence for AC1:** "queue exhausted" is already honest while the loader refills, but the wait + loop does not increment `scanned`, so an item-count cap alone does not bound the wait. +- `Worker_DoWork` is `async void` (`QfcDatamodel.cs:175-213`); no handle to the loader task is + retained. `SetupWorker` registers `worker.CancelAsync` on the token (`:170`), which sets + `CancellationPending` but cannot stop an `async` body that never reads it. +- `LoadRemainingEmailsToQueueAsync` observes the token at `:322` and `:324` only, then calls + `TryQueueRemainingMailItemAsync` (`:350-361`), which constructs + `new QfcRemainingQueueAdmission(_masterQueue.AddLast, _moveMonitor.HookItem, x => _masterQueue.Remove(x))` + at `:355-359`. When either field is already null, delegate construction throws + `ArgumentException: Delegate to an instance method cannot have null 'this'` — exactly the logged + error. `QfcRemainingQueueAdmission` itself is sound (`QfcRemainingQueueAdmission.cs:14-38`) and no + longer carries the dead constructor parameters #731 identified. +- `QfcDatamodel.Cleanup()` (`:75-91`) cancels, calls `_worker?.CancelAsync()`, then unconditionally + dereferences `_globals.Ol.App` and `_moveMonitor` (`:79-80`) and nulls `_moveMonitor`, `_globals`, + `_masterQueue`, `_worker` (`:81-90`) without awaiting anything. + +### Cancel path + +- `ButtonCancel_Click` is `async void` and rethrows after logging + (`QfcFormController.EventHandlers.cs:70-82`), so an escaping exception becomes an unhandled + UI-thread failure inside Outlook. +- `ActionCancelAsync` (`:84-94`): cancel token, `await _formViewer.UiSyncContext`, `Hide()`, + `_groups?.Cleanup()`, `Cleanup()`. No `try`/`finally`, no `KbdActive` reset, no focus parking, no + logging. It is also the completion path: `MoveAndIterate` calls it at `:169` (error) and `:208` + ("Finished Moving Emails"), so the same defects apply to normal completion. +- The OK path does reset the keyboard flag (`:125-128`, + `if (_parent.KeyboardHandler.KbdActive) _parent.KeyboardHandler.ToggleKeyboardDialog();`). +- `RegisterFormEventHandlers` subscribes `FormDeactivated` at `SetupDisposal.cs:175`; + `UnregisterFormEventHandlers` unsubscribes it at `:204`. `FormViewer_Deactivated` + (`QfcFormController.Deactivate.cs:26-58`) is the only caller of `ParkFocusOffWebView2()` and of the + per-item `CancelBreadcrumbSelector()` loop. The Cancel path removes that subscription (through + `Cleanup` → `UnregisterFormEventHandlers`) and never invokes the routine directly. +- Ordering defect: `QfcFormController.Cleanup()` calls `UnregisterFormEventHandlers()` at `:220`, + but `_groups.Cleanup()` already ran (`EventHandlers.cs:92`). `QfcCollectionController.Cleanup` + (`:2128-2140`) delegates to `RemoveControls()` (`:737-757`), which removes the rows from the + `TableLayoutPanel` at `:745` and clears `_itemGroups` at `:751`. The recursive + `Controls.ForAllControls` unsubscribe at `SetupDisposal.cs:185-197` therefore no longer reaches the + item controls whose `PreviewKeyDown`/`KeyDown` were attached at `:156-168`. The guard at `:180-183` + additionally returns early once `_formViewer?.Controls` or `_parent?.KeyboardHandler` is null. +- `QfcCollectionController.Cleanup` touches neither `_kbdHandler`/`KbdActive` nor + `UnregisterNavigation()`; `UnregisterNavigation` is public on the interface + (`IQfcCollectionController.cs:109`) and implemented at `QfcCollectionController.cs:1080-1089` + (#644 ledger replay). +- `QfcHomeController.Cleanup()` (`:370-379`) calls `_datamodel.Cleanup()` first and + `ParentCleanup.Invoke()` last with no `try`/`finally`; `_tokenSource` is never disposed and + `Worker_RunWorkerCompleted` (subscribed at `:131`) is never detached. Note that by the time this + runs, `QfcFormController.Cleanup` has already disposed the viewer (`SetupDisposal.cs:251`) and only + then invoked `_parentCleanup` (`:259`), so any viewer access here must be defensive. +- `RibbonController.ReleaseQuickFiler` (`TaskMaster/Ribbon/RibbonController.cs:148-153`) is the + `ParentCleanup` delegate; it clears `_quickFiler`, `_quickFilerLoaded` and the high-confidence + launch flag. It is `private` with no test seam, and both launch guards depend on it (`:114`, `:135`). + +### Reachable seams in tests + +- Gate: `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs:27-121` builds the gate + by reflection against an **exact** nine-parameter constructor signature and asserts the constructor + is found (`:74-77`), i.e. it fails closed. Any added constructor parameter requires updating this + helper. `FakeTimeProvider` (`Microsoft.Extensions.Time.Testing`) is already the clock seam + (`Part3.cs:178-195`), so no new time seam is needed. +- Datamodel: `FormatterServices.GetUninitializedObject` plus private-field injection, with + `TimeProvider`, `ScoringServiceFactory` and `RemainingEmailLoader` as public/internal seams + (`QfcDatamodelLivenessTests.cs:35-45,83-100`; `QfcQueuePurePathsTests.cs:205-250`). + `RemainingEmailLoader` (`QfcDatamodel.cs:130`) is the injection point that makes the loader + controllable without COM. +- Form controller: real constructor plus `Mock`, `Mock`, + `Mock` injected into `_groups` by reflection, and `Mock.Raise` for viewer + events (`QfcFormControllerDeactivateTests.cs:36-92`); `TimeProvider`/`UndoConsumerStarter` + overrides in `QfcFormControllerCleanupTests.cs:60-77`. +- Existing cancel coverage is vacuous: `QfcFormControllerTests.cs:392-403` + (`ButtonCancel_Click_ShouldCancelAction`) awaits `ActionCancelAsync()` and asserts nothing. + +## Behavior Semantics + +AC1 (deadline policy): + +- Success: with `HighConfidenceModeEnabled` and zero acceptances at the first checkpoint, scanning + continues; the call returns as soon as one candidate scores `>= _cutoff` (subsequent behavior is + unchanged #608 fill-or-exhaust), or when the source is genuinely exhausted, or when the hard cap is + reached. A zero-row dialog is legal only for exhaustion or cap. +- Failure/edge: cancellation during the extended scan must still surface `OperationCanceledException` + from `:170`/`:204`; a transient empty queue while `_remainingLoadActive` is true must not be read as + exhaustion; `quantity <= 0` must still short-circuit (`:159-162`); a non-empty prefix must keep + #608 semantics (the deadline must remain inert once `accepted.Count > 0`). +- Ordering: the checkpoint decision is evaluated before the take, as today; the cap must be checked in + the same place so a capped scan cannot take an extra item. + +AC2 (Cancel teardown) — the required order, each step observable: + +1. Log entry to the teardown. +2. Signal cancellation (`_parent?.TokenSource?.Cancel()`). +3. Marshal to the UI context (`await _formViewer.UiSyncContext`). +4. Reset `KbdActive` (toggle only when active, mirroring `:125-128`). +5. Park focus off WebView2 and cancel every open breadcrumb selector (the `FormViewer_Deactivated` + routine), while `_groups.ItemGroups` still exists. +6. `_groups?.UnregisterNavigation()` and `UnregisterFormEventHandlers()` — before rows are removed. +7. `Hide()`. +8. Await the background loader to quiesce, bounded; only then allow datamodel field nulling. +9. `_groups?.Cleanup()`. +10. `Cleanup()` → `_parentCleanup` → `QfcHomeController.Cleanup` → `_datamodel.Cleanup()` and + `ParentCleanup.Invoke()` under `finally`. + +Failure semantics: any step may throw; every later step must still run, the release callback must run, +and each exception must be logged with its stage. Repeat invocation (double Cancel, or Cancel after +`MoveAndIterate`'s completion path) must be inert rather than throwing. + +## Recommended Approach + +### AC1 — advisory checkpoint plus a hard scan bound + +Change the zero-acceptance branch (`gate:172-180`) from a return into a checkpoint: + +- Keep `_firstBatchDeadline` but re-purpose it as the **checkpoint interval**: on expiry, log the + cutoff, `scanned`, `accepted.Count`, the elapsed time and the remaining bound, reset the interval + origin, and continue scanning. +- Add two bounds, both injected through the constructor with internal defaults on the gate: + `maxScanWithoutAcceptance` (recommended default 250 scored candidates) and + `zeroAcceptanceCeiling` (recommended default 120 s). The item cap answers the AC's "hard cap on + items scanned"; the time ceiling is required in addition because the empty-queue wait path + (`gate:185-196`) does not increment `scanned`, so an item cap alone leaves the pre-UI wait unbounded + while `_remainingLoadActive` is true. +- Add `QfcDequeueStop.ScanCapReached` for the bounded exit and treat it exactly as `DeadlineExpired` + is treated today (queue stays open; `IterateQueueAsync` still calls `CompleteAddingAsync` only under + `SourceExhausted`, preserving #446 AC-6 verbatim). Retain the `DeadlineExpired` member with an + updated XML doc recording that #791 made the deadline advisory; retaining it avoids touching the + public enum's existing members and keeps both existing stop-reason tests meaningful after + retargeting. +- Add a launch log line at the top of `DequeueAsync` carrying cutoff, quantity, checkpoint interval + and both bounds, satisfying "logged at launch". + +Configuration location: keep the bounds as gate-internal `internal static readonly` constants with the +constructor seam, following the precedent set by #424, whose ratified acceptance criterion states the +deadline is "an internal constant with an internal test seam; no new `QfSettings`/ +`IAppQuickFilerSettings` member, no `Settings.Designer.cs` change, and no ribbon plumbing" +(`docs/features/archive/2026-08-06-quickfiler-high-confidence-queue-init-stall-424/spec.md:239`). +`TaskMaster/Properties/Settings.Designer.cs:1-9` is auto-generated and must not be hand-edited, and +`AppQuickFilerSettings` (`TaskMaster/AppGlobals/AppQuickFilerSettings.cs:48-66`) exposes only the two +high-confidence settings; adding a third would also require `IAppQuickFilerSettings`, `app.config`, +`Settings.settings` and ribbon plumbing for a value with no user story. + +Prior-AC reconciliation (must be stated explicitly in the plan, not discovered at review): + +- #424 spec AC "When zero candidates reach the cutoff before the deadline, `DequeueAsync` returns an + empty list at the deadline bound, and the `RunAsync` path proceeds to show the form with an empty + first group" (`.../424/spec.md:231`) is **superseded** by #791 AC1. +- #608 spec AC "Deadline expiry with `accepted.Count == 0` retains the current empty-result behavior" + (`docs/features/active/2026-08-25-quickfiler-high-confidence-partial-screen-backfill-608/spec.md:184`) + is **superseded** by #791 AC1. #608's other criteria (`:181-183`, `:185`) concern the non-empty + prefix and must remain green. +- #446 AC-6 (`CompleteAddingAsync` only under `SourceExhausted`) is **preserved** by routing the new + stop reason away from that branch. + +Rejected alternatives for AC1: + +- *Raise the deadline constant (e.g. 12 s → 60 s).* Rejected: it re-parameterises the same defect and + still yields an empty dialog on any view whose qualifying items sit past the new bound. +- *Rank-and-take the best-scoring candidate when nothing clears the cutoff.* Rejected: it silently + files below-threshold suggestions, defeating the purpose of High Confidence mode and contradicting + the inclusive `score >= _cutoff` rule ratified by #608 (`spec.md:185`). +- *Show the dialog immediately and back-fill rows asynchronously.* Rejected: the row-loading path + (`RunAsync` → `LoadItemsAsync`) is single-shot per iteration, so this is a UI-architecture change far + wider than the defect, and it does not remove the empty first screen. +- *Replace the deadline parameter with a bounds struct.* Rejected: it churns every existing gate call + site and the fail-closed reflection helper for no behavioral gain over two optional parameters. + +### AC2 — ordered, logged, exception-safe teardown + +1. **Make the loader awaitable without blocking.** In `Worker_DoWork`, capture the task before + awaiting it (`_remainingLoadTask = RemainingEmailLoader(_token); e.Result = await _remainingLoadTask;`) + and expose `Task QuiesceLoaderAsync(TimeSpan timeout)` on `IQfcDatamodel` that cancels, then awaits + `Task.WhenAny(_remainingLoadTask, TimeProvider.Delay(timeout, CancellationToken.None))`, logging + whether the loader completed or the bound expired. Declare the field and the method in + `QfcDatamodel.QueueProcessing.cs` (partial class) to protect `QfcDatamodel.cs`'s remaining headroom. + Call it from `ActionCancelAsync` through `_parent.DataModel` (`IQfcHomeController.DataModel`, + `QuickFiler/Controllers/IQfcHomeController.cs:11`), i.e. from an `async` method — **never** a + blocking wait inside `Cleanup()`, which #731 established runs on the UI thread. +2. **Guard the admission construction as defence in depth.** Relocate + `TryQueueRemainingMailItemAsync` into `QfcDatamodel.QueueProcessing.cs`, snapshot `_masterQueue` and + `_moveMonitor` into locals, and return `false` when either is null or cancellation is requested. + This makes the reported crash impossible even if a future path skips the quiesce, and it is + directly unit-testable through the uninitialized-object pattern. +3. **Null-guard `QfcDatamodel.Cleanup()`** (`:79-80` currently unguarded) so a second Cancel, or a + Cancel after a partially-failed launch, cannot throw before the fields are released. +4. **Extract the deactivate routine.** Split `FormViewer_Deactivated` (`Deactivate.cs:26-58`) into the + event handler plus `internal void ParkFocusAndCancelSelectors()`; call the latter from both the + event and the Cancel path. This is the "same routine" the AC requires and keeps the per-item + boundary catch intact. +5. **Reorder `ActionCancelAsync`** to the ten steps in Behavior Semantics, with a `try`/`catch`/ + `finally` per stage-group so a throwing stage cannot skip the release callback. Call + `_groups?.UnregisterNavigation()` from `ActionCancelAsync` rather than adding it to + `QfcCollectionController.Cleanup`, because that file is already 2329 lines and adding to it worsens + an existing 500-line violation; `UnregisterNavigation` is on the interface, so no new seam is needed. + Keep the existing `UnregisterFormEventHandlers()` call inside `Cleanup()`: it is idempotent + (`-=` on absent handlers is a no-op) and preserves the non-Cancel call shape. +6. **`QfcHomeController.Cleanup()`**: wrap the datamodel cleanup, the field nulling and the + `Worker_RunWorkerCompleted` detach in `try`/`catch` with logging, and invoke `ParentCleanup` in a + `finally`; dispose `_tokenSource` there as well. The viewer is already disposed by the caller, so + the detach must be inside its own guarded block. +7. **Do not rethrow from `ButtonCancel_Click`** (`EventHandlers.cs:70-82`): an `async void` rethrow + becomes an unhandled Outlook UI-thread exception, which is precisely the failure mode the AC's + logging requirement exists to replace. This is a deliberate behavior change and should be called out + in the spec. + +`UnregisterNavigation` on the Cancel path (#644): recommended **yes**, at step 6 of the order. The +navigation actions are digit-string entries in the shared `KeyboardHandler.StringActionsAsync` ledger +(`QfcCollectionController.cs:1080-1099`); `QfcCollectionController.Cleanup` never drains it, and the +handler instance is per-launch, so leaving it is not a cross-session leak — but draining it before the +rows disappear keeps the #644 ledger invariant true through teardown and costs one call. + +#731 reconciliation (already on base; must not be duplicated or undone): the deferred undo-queue +disposal via `_undoQueueDisposal` (`SetupDisposal.cs:207-249`) stays exactly as is; the one-monitor-per- +owner comment and design (`QfcDatamodel.cs:104-105`) stays; `QfcRemainingQueueAdmission`'s three-delegate +constructor is final. In particular, do not "simplify" step 1 into a blocking wait inside `Cleanup()` — +that is the deadlock #731 finding 4 rejected. + +Rejected alternatives for AC2: + +- *Make `Cleanup()` async throughout (`IQfcDatamodel.CleanupAsync`, `IFilerHomeController.CleanupAsync`).* + Rejected: it changes three interfaces and the `System.Action parentCleanup` contract that + `RibbonController` supplies, for no behavior the bounded quiesce in `ActionCancelAsync` does not give. +- *Have the loader poll a `_cleanupRequested` flag only.* Rejected: it narrows the race window without + closing it and provides no observable completion point for a deterministic test. +- *Move focus parking into `QfcCollectionController.Cleanup`.* Rejected: wrong owner (the routine is + viewer/form scoped) and it grows an already oversized file. + +## Requirements Mapping + +| File | Change | Current lines | +| --- | --- | --- | +| `QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs` | checkpoint-instead-of-return; two bounds + defaults; launch log; cutoff in both log lines | 262 | +| `QuickFiler/Interfaces/IQfcDatamodel.cs` | add `ScanCapReached`; doc `DeadlineExpired` as superseded; declare `QuiesceLoaderAsync` | 133 | +| `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` | `_remainingLoadTask` field, `QuiesceLoaderAsync`, relocated + guarded `TryQueueRemainingMailItemAsync` | 298 | +| `QuickFiler/Controllers/QfcDatamodel.cs` | capture loader task in `Worker_DoWork`; null-guard `Cleanup()`; remove relocated method | 480 | +| `QuickFiler/Controllers/QfcFormController.EventHandlers.cs` | ordered `ActionCancelAsync`; no rethrow in `ButtonCancel_Click`; stage logging | 408 | +| `QuickFiler/Controllers/QfcFormController.Deactivate.cs` | extract `ParkFocusAndCancelSelectors()` | 60 | +| `QuickFiler/Controllers/QfcHomeController.cs` | `Cleanup()` try/finally, token-source dispose, worker detach, logging | 469 | + +Not touched, deliberately: `QfcCollectionController.cs` (2329 lines, pre-existing violation), +`QfcHomeController.Iteration.cs` (the `SourceExhausted`-only branch is already correct), +`TaskMaster/Ribbon/RibbonController.cs`, `Settings.Designer.cs`, `AppQuickFilerSettings.cs`. + +Interface/API deltas: one new enum member, one new interface method, one new internal method, two new +optional constructor parameters on an internal class. `QuickFiler.csproj` and `QuickFiler.Test.csproj` +are legacy non-SDK projects with explicit `` items (e.g. +`QuickFiler.csproj:321-325`, `QuickFiler.Test.csproj:155`), so every new file needs an entry. + +## Testing Implications + +MSTest + Moq + FluentAssertions, no temp files, no wall-clock waits. `FakeTimeProvider` is already the +established clock seam, so no new time injection is required for the gate; `QfcDatamodel.TimeProvider` +covers the quiesce timeout. + +AC1 (new tests; suggested home `QfcStreamingDequeueConfidenceGateTests.Part4.cs`, new file, because +Part1 is 477 and Part2 465 lines): + +- `DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance` — 40 below-cutoff candidates + then one at 950; fake clock advances 1 s per score with a 12 s checkpoint. Fails before (returns + empty at the checkpoint), passes after (returns the qualifying item). +- `DequeueAsync_ZeroAcceptedAndSourceDrained_ReportsSourceExhausted` — cap not reached, producer dead. +- `DequeueAsync_ZeroAcceptedAndCapReached_StopsAndReportsScanCapReached` — cap injected as a small + value; asserts no take occurs after the cap. +- `DequeueAsync_ZeroAcceptedAndCeilingReached_StopsWhileSourceStillRefilling` — `sourceActive` true and + `tryTakeNext` always null; asserts the ceiling terminates the wait loop. +- `DequeueAsync_CheckpointExpiry_LogsCutoffAndCounts` and + `DequeueAsync_Launch_LogsCutoffQuantityAndBounds` — assert through the injected `debugLog` delegate, + not a log4net appender (existing convention). +- `DequeueAsync_NonEmptyPrefix_UnchangedByCheckpoint` — #608 regression pin. + +AC1 test-maintenance obligations (these currently encode the superseded behavior and will fail after +the change; retarget, do not delete): `QfcStreamingDequeueConfidenceGateTests.Part3.cs:174-208` +(`DequeueAsync_DeadlineExpiresWithZeroAccepted_ReportsDeadlineExpiredStop`), +`QfcQueuePurePathsTests.cs:201-260` +(`DequeueNextItemGroupWithOutcomeAsync_DeadlineExpiredGate_ReportsDeadlineExpiredStop`), and the +fail-closed reflection helper `QfcStreamingDequeueConfidenceGateTests.cs:27-92`, which asserts the +exact nine-parameter constructor. `QfcHomeControllerIterationTests.cs:395-402` should gain a sibling +asserting `ScanCapReached` also leaves the queue open. + +AC2 (new file `QfcFormControllerCancelTeardownTests.cs`; `QfcFormControllerTests.cs` is 792 lines and +`QfcFormControllerSeamTests.cs` 496, both unsuitable): + +- `ActionCancelAsync_ResetsKbdActive_WhenKeyboardDialogActive` / `..._DoesNotToggle_WhenInactive`. +- `ActionCancelAsync_ParksFocusAndCancelsBreadcrumbSelectors` — `Mock` with + `IsWebView2Focused` true; verify `ParkFocusOffWebView2` and per-item `CancelBreadcrumbSelector`. +- `ActionCancelAsync_UnregistersHandlersBeforeGroupsCleanup` — Moq `MockSequence` or a shared + invocation-order list; fails before (order inverted). +- `ActionCancelAsync_AwaitsLoaderQuiesceBeforeGroupsCleanup` — `Mock` on + `_parent.DataModel`; verify call order and that a timed-out quiesce still proceeds. +- `ActionCancelAsync_GroupsCleanupThrows_StillInvokesParentCleanup` and + `ButtonCancel_Click_ActionThrows_DoesNotRethrow`. +- `QfcHomeControllerCleanupTests`: `Cleanup_DatamodelCleanupThrows_StillInvokesParentCleanup`, + `Cleanup_DisposesTokenSourceAndDetachesWorkerCompleted`. +- `QfcDatamodelTeardownTests`: `TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing` + (fails before with the exact `ArgumentException` from the log), + `QuiesceLoaderAsync_LoaderCompletes_ReturnsBeforeTimeout`, + `QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs` (both driven by + `RemainingEmailLoader` + `FakeTimeProvider`), `Cleanup_CalledTwice_DoesNotThrow`. + +Not proposed: any test of `RibbonController.ReleaseQuickFiler` itself. It is `private`, has no seam, +and the guarantee that matters is expressible at the `QfcHomeController.ParentCleanup` boundary. + +## Logging Plan + +All lines through the existing `log4net.ILog` idiom on the class (no new logger shape). Levels chosen +so a normal Cancel is readable at INFO and diagnosis is available at DEBUG. + +| Stage | Level | Content | +| --- | --- | --- | +| Gate launch | DEBUG | cutoff (per-mille and fraction), quantity, checkpoint interval, scan cap, ceiling | +| Gate checkpoint | DEBUG | accepted, scanned, cutoff, elapsed, remaining cap/ceiling, decision (continue / stop) | +| Cancel entry | INFO | trigger (button vs. completion path), token already cancelled? | +| Token cancelled | DEBUG | — | +| Keyboard flag reset | DEBUG | previous `KbdActive` value | +| Focus parked / selectors cancelled | DEBUG | whether a WebView2 held focus; item count cancelled | +| Handlers unregistered | DEBUG | navigation ledger drained, form handlers removed | +| Loader quiesce | INFO | completed vs. timed out, elapsed, bound | +| Datamodel cleanup | DEBUG | — | +| Groups cleanup | DEBUG | rows removed | +| Release callback invoked | INFO | — | +| Any stage exception | ERROR | stage name + exception (`logger.Error(message, e)`) | + +## Automation Feasibility + +Automatable, deterministically, with no Outlook process: + +- Every AC1 behavior: the gate takes `Func`, a score-loader delegate, a `TimeProvider` and a + `debugLog` delegate through its constructor, and `MailItem` is mocked with Moq throughout the + existing suite. Continuation past the checkpoint, first-acceptance return, exhaustion, cap, ceiling + and both log lines are all assertable headlessly. +- The AC2 *ordering* and *exception-safety* properties: handler unregistration before row removal, + quiesce before cleanup, `KbdActive` reset, park-focus invocation, selector cancellation, and + `ParentCleanup` under `finally` are all observable through `Mock`, + `Mock`, `Mock` and `Mock` with + invocation-order verification — the pattern `QfcFormControllerDeactivateTests` already uses. +- The loader-crash regression: reproducible exactly, because the failing construction + (`QfcDatamodel.cs:355-359`) depends only on private fields a test can null. + +Requires a human with a live Outlook process (the manual evidence note AC2 asks for): + +- That the Outlook keyboard is actually usable after Cancel. The mechanism identified by #677 is + WebView2 runtime focus retention (WebView2Feedback #951), which is a runtime behavior of real + browser child windows on Outlook's shared UI thread; no mock reproduces it. A unit test can prove + `ParkFocusOffWebView2()` was called, not that focus moved. +- That the breadcrumb `ToolStripDropDown` is really closed and WinForms modal menu mode has exited. +- That no `Delegate to an instance method cannot have null 'this'` error follows a real Cancel, and + that the new Cancel-stage log lines appear in `TaskMaster\bin\Debug\logs\debug_.log` in the + documented order. +- End-to-end AC1 confirmation against a real Explorer view whose first ~40 items score below cutoff, + including that the pre-UI wait remains tolerable with real scoring throughput (~2-3 items/s + observed), and that the progress band advances during the extended scan. +- Relaunch-after-Cancel behavior (both ribbon buttons functional), because `_quickFilerLoaded` lives in + the VSTO ribbon controller. + +Recommended manual evidence artifact: +`docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/regression-testing/live-outlook-cancel-teardown..md`, +carrying the log excerpt with the teardown stages and the absence of the loader error, following the +#677 precedent. + +## Provenance and Unknowns + +- Every file:line citation above was read in this session from the worktree at `7c8ac9ae`. No shell + command, build, or test run was performed (tooling restricted to read/search for this task). +- The recommended default bounds (250 scanned candidates, 120 s ceiling) are engineering proposals + derived from the observed ~2-3 items/s throughput reported in the issue; they are not measured in + this session and should be confirmed during live verification. +- Unknown: whether the 09:05 keyboard lock cleared on Escape, on focus change, or only on restart + (the issue records the user could not reproduce it). The Cancel-stage logging added by AC2 is what + makes a future occurrence diagnosable. +- Unknown: whether any consumer outside this repository reads `QfcDequeueStop`; `IQfcDatamodel` is + public, but no other project in the solution references the enum in the searches performed here. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/runbooks/live-outlook-cancel-teardown-verification.runbook.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/runbooks/live-outlook-cancel-teardown-verification.runbook.md new file mode 100644 index 000000000..b1a11fd02 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/runbooks/live-outlook-cancel-teardown-verification.runbook.md @@ -0,0 +1,95 @@ +# Human-Exception Runbook — Live-Outlook Cancel Teardown Verification (Issue #791, AC2) + +## Cue + +Act on this runbook when the executor or feature reviewer for issue #791 reaches the manual-verification +item of acceptance criterion AC2 ("Cancel teardown completes cleanly... plus a manual live-Outlook +evidence note"), or when a pull request for issue #791 is opened. A live Outlook process, a live WebView2 +runtime, and real user-driven Cancel/Undo clicks cannot be driven by an agent, so this step is resolved as +a permitted `exception` and performed by a human. + +## Prerequisites + +- A Windows machine with Microsoft Outlook installed and the TaskMaster VSTO add-in already registered for + the current user (an HKCU Outlook add-in manifest entry pointing at a `TaskMaster.vsto` deployment). +- A local checkout of the issue #791 feature branch. The confirmed working deployment path on this + machine's registered manifest is `\TaskMaster\bin\Debug\TaskMaster.vsto` (read the exact path from `HKCU\SOFTWARE\Microsoft\Office\Outlook\Addins\TaskMaster\Manifest`). + Either build the feature branch in that exact checkout, or update the HKCU manifest to point at the + checkout actually used, before testing. Rebuilding a checkout that is already registered updates the + assembly Outlook loads without any re-registration step (see Source and Citation). +- Read access to the feature folder's atomic plan file, `plan.2026-09-06T12-57.md`, to obtain the exact + Cancel-path log line markers as implemented. The marker text is defined by the plan, not by this + runbook, and may differ from any provisional wording used during planning. +- An Outlook Explorer view containing enough mail items to file two rounds of High Confidence suggestions. +- Write access to create the evidence file under `docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/other/`. + +## Step-by-step Instructions + +1. Close Outlook if it is running. +2. Build the issue #791 feature branch in the checkout that deploys to + `TaskMaster\bin\Debug\TaskMaster.vsto` (or confirm the HKCU manifest has been updated to the checkout + you are using instead). +3. Open `plan.2026-09-06T12-57.md` in the feature folder and note the exact text of every Cancel-path log + line the plan introduces (for example, lines logging token cancellation, loader await/stop, handler + unregistration, `KbdActive` reset, focus park, breadcrumb cancel, and ribbon release). Keep this list + at hand for step 8. +4. Open the deploy directory's log folder (`\logs\`) and note the last line and timestamp of + today's `debug_yyyy-MM-dd.log`, if the file already exists. This marks where new lines from this test + begin. +5. Launch Outlook. Confirm the add-in loaded (the QuickFiler ribbon group, including the High Confidence + button, is visible). +6. Select an Explorer view with mail items. Launch QuickFiler via the ribbon **High Confidence** button. +7. File a first round of suggestions (commit at least one item). File a second round, then click **Undo** + repeatedly in a quick burst (10 or more clicks in rapid succession) to reproduce the reported scenario. +8. Press **Cancel**. +9. Immediately click into a native Outlook window (an Explorer view or an open Inspector) and type. + Confirm keystrokes are received normally and Outlook is responsive. +10. Open `\logs\debug_yyyy-MM-dd.log` and read the lines written after the marker noted in + step 4. Confirm each Cancel-path log line from step 3 appears, in the order the plan specifies. +11. In the same lines, confirm the following string does NOT appear anywhere after the Cancel press: + `ERROR QuickFiler.Controllers.QfcDatamodel - LoadRemainingEmailsToQueue Error. Delegate to an instance + method cannot have null 'this'.` +12. Because the original defect was sporadic (observed once directly, once via a surviving background + loader crashing on the next launch), repeat steps 6-11 at least once more if time allows. Record every + pass performed. +13. Write the evidence file at + `docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/evidence/other/manual-verification..md` + (timestamp format per the evidence-and-timestamp-conventions skill) with these fields: + - `Timestamp:` ISO-8601 timestamp of the test session. + - `Build (commit SHA):` the commit the tested build was compiled from. + - `Steps performed:` which of steps 6-12 were executed and how many passes. + - `Observed log lines:` the Cancel-path lines actually observed, quoted verbatim. + - `Keyboard state after Cancel:` what was observed in step 9, for each pass. + - `Result: PASS` or `Result: FAIL` (FAIL if the null-`this` error appears, if any expected Cancel-path + line is missing, or if the keyboard is left unusable in any pass). + - `Tester:` name of the person who performed the verification. + +## Verification + +- The evidence file described in step 13 exists at the canonical path and contains all required fields. +- `Result: PASS` requires: the Outlook keyboard remained usable in native Outlook windows after every + Cancel press performed; every Cancel-path log line named in the plan file was observed in the log, in + the specified order, for every pass; and the string + `Delegate to an instance method cannot have null 'this'.` does not appear anywhere in the log after any + Cancel press. +- If any pass fails these conditions, record `Result: FAIL` with the specific missing line, out-of-order + line, error occurrence, or keyboard symptom observed, rather than omitting the failing pass. + +## Source and Citation + +- Build-to-load mechanism (third-party UI/tooling background, web-second — no MCP documentation tool is + wired into this repository at this time; see the two-axis-model-selection spec, Out of Scope): + Microsoft Learn, "Create Visual Studio Tools for Office Add-ins: Outlook mail" — "When you build the + project, the code is compiled into an assembly that is included in the build output folder for the + project. Visual Studio also creates a set of registry entries that enable Outlook to discover and load + the VSTO Add-in." Source URL: + https://learn.microsoft.com/en-us/visualstudio/vsto/walkthrough-creating-your-first-vsto-add-in-for-outlook + — updated_at: 2026-04-24 (fetched 2026-09-06). +- Defect signature, deploy path, log location, and threshold facts (primary, in-repo source): `issue.md` + in this feature folder, captured 2026-09-06 (session in which the deploy path, log file location, and + the exact `null 'this'` error text were verified directly against a live log). +- Exact Cancel-path log marker text (primary, in-repo source, authoritative at test time): the atomic plan + file `plan.2026-09-06T12-57.md` in this feature folder — read at the time of testing, since the markers + are implementation output, not fixed by this runbook. +- Evidence file naming and location: `.claude/skills/evidence-and-timestamp-conventions/SKILL.md`, + canonical `/evidence/other/` path and `yyyy-MM-ddTHH-mm` timestamp format. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/spec.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/spec.md new file mode 100644 index 000000000..79213509c --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/spec.md @@ -0,0 +1,351 @@ +# 2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects (Spec) + +- **Issue:** #791 +- **Parent (optional):** none +- **Owner:** drmoisan +- **Last Updated:** 2026-09-06T12-57 +- **Status:** Implemented +- **Version:** 1.0 +- **Work Mode:** full-bug. This spec is the sole authoritative acceptance-criteria source. `user-story.md` in this folder is narrative operator context only and carries no criteria. + +> Path-formatting note: the backticked repository paths in the Write Set below are the change +> footprint for this fix. Everywhere else in this document, file paths and File.cs:123 line citations are +> written as plain prose on purpose. Do not add backticks to them. The single exception is the runbook +> path inside AC2, which is quoted verbatim from issue.md. + +## Context +Two defects observed while running QuickFiler in High Confidence mode on 2026-09-06 against the build of `7c8ac9ae`. (1) A High Confidence run whose first 12 seconds of scanning finds no item at or above the cutoff opens an empty dialog, and because scan order follows the Explorer view the same view produces the same empty dialog on every rerun. (2) The Cancel teardown does not shut QuickFiler down cleanly: the background queue loader outlives Cancel and crashes on fields that cleanup has already nulled, the keyboard-active flag and WebView2 focus are never reset on the Cancel path, the teardown chain has no `try`/`finally`, and the whole path emits no log output, which left a 37 minute unexplained gap during which the Outlook keyboard was locked. + +Environment: +- OS/version: Windows 11 Pro 10.0.26200 +- Runtime: C# / .NET Framework 4.8 VSTO add-in (no Python component) +- Command/flags used: QuickFiler launched from the ribbon High Confidence button; HighConfidenceThreshold at the designer default 0.9 (never changed in any user.config on the machine); HighConfidenceModeEnabled toggled by the ribbon launch path +- Data source or fixture: live Outlook Inbox view; add-in loaded from TaskMaster\bin\Debug built 2026-09-06 08:51 from `7c8ac9ae` + +Impact / Severity: +- [ ] Blocker +- [x] High +- [ ] Medium +- [ ] Low + +High: the deadline defect makes High Confidence mode unusable for any view whose top-scoring items are not near the front, with no message and no recovery other than filing items some other way. The teardown defect can leave the whole Outlook keyboard unusable until Outlook is restarted, and the surviving background loader crashes against the next launch's state. + +## Repro & Evidence +Steps to Reproduce — Defect 1 (deadline policy, deterministic for a given view): +1. Arrange an Explorer view whose first roughly 40 items in view order all score below 900 per-mille while later items score above it. +2. Launch QuickFiler via the High Confidence ribbon button. +3. Observe the dialog open with zero rows after roughly 20 seconds. +4. Cancel and relaunch via the same button; observe the same empty dialog. + +Steps to Reproduce — Defect 2 (Cancel teardown, sporadic): +1. Launch QuickFiler via the High Confidence ribbon button and file one round of suggestions. +2. File a second round, then press Undo repeatedly (24 undo clicks were logged between 09:04:05 and 09:05:53). +3. Press Cancel. +4. Observe the Outlook keyboard is unusable in the native Outlook window. In a separate run the same Cancel left the background loader running until it crashed 4 seconds after the next launch. + +Expected: +- A High Confidence run that has scored items but found none at or above the cutoff within the first-batch deadline keeps scanning until the first acceptance or until the candidate queue is exhausted, subject to a hard cap on scanned items, and reports progress. It never opens an empty dialog while unscanned candidates remain. +- The cutoff in effect and the scan progress are logged at launch and at every deadline decision. +- Cancel performs a complete, ordered teardown: cancellation is signalled, the background loader is stopped and awaited before any datamodel field is nulled, form and item keyboard handlers are unregistered before the item rows are removed, the keyboard-active flag is reset, WebView2 focus is parked and any open breadcrumb dropdown is cancelled (the same routine that FormViewer_Deactivated runs), and the ribbon release callback runs even if an earlier step throws. +- Every stage of the Cancel teardown writes a log line through the existing log4net pattern, including any exception. + +Actual: +- QfcStreamingDequeueConfidenceGate.DequeueAsync returns DeadlineExpired with an empty accepted list when accepted.Count == 0 after 12 seconds, and QfcHomeController.RunAsync loads zero rows. Scores were real, not zero: the three zero-accepted runs peaked at 928 and 960 *after* the deadline had already expired, while accepting runs peaked at 997 to 1000. The cutoff (900) is never logged. +- After Cancel, QfcDatamodel.Cleanup() cancels the token and calls worker.CancelAsync() but does not await LoadRemainingEmailsToQueueAsync, then nulls _moveMonitor, _globals, _masterQueue and _worker. The still-running loader then throws at QfcDatamodel.cs:355-358 while constructing QfcRemainingQueueAdmission from those fields. +- ActionCancelAsync (QfcFormController.EventHandlers.cs:84-93) does not reset KbdActive, does not call ParkFocusOffWebView2() or CancelBreadcrumbSelector(), and has no `try`/`finally`. ButtonCancel_Click is `async void` and rethrows, so an escaping exception becomes an unhandled Outlook UI-thread failure. +- QfcFormController.Cleanup() (QfcFormController.SetupDisposal.cs:213-261) unregisters form event handlers after _groups.Cleanup() has already removed the item rows, so the recursive unsubscribe no longer reaches the item controls' PreviewKeyDown/KeyDown subscriptions. +- QfcHomeController.Cleanup() (QfcHomeController.cs:370-379) invokes ParentCleanup with no `try`/`finally`; if the datamodel cleanup throws, RibbonController.ReleaseQuickFiler() never runs and both ribbon buttons become no-ops. _tokenSource is never disposed and Worker_RunWorkerCompleted is never detached. +- The Cancel path, QfcDatamodel.Cleanup() and ParkFocusOffWebView2() contain no logging. After the 09:05:53 undo burst the log is silent for 37 minutes 39 seconds until the next launch at 09:43:32. + +Logs / Screenshots: +- [x] Attached minimal logs or screenshot +- Snippet (from TaskMaster\bin\Debug\logs\debug_2026-09-06.log): + +``` +2026-09-06 09:43:54,214 [44] DEBUG QfcStreamingDequeueConfidenceGate - First-batch deadline expired [DequeueAsync] Accepted=0 Scanned=38 Deadline=00:00:12 +2026-09-06 09:45:36,727 [53] DEBUG QfcStreamingDequeueConfidenceGate - First-batch deadline expired [DequeueAsync] Accepted=0 Scanned=44 Deadline=00:00:12 +2026-09-06 10:08:06,149 [29] DEBUG QfcStreamingDequeueConfidenceGate - First-batch deadline expired [DequeueAsync] Accepted=0 Scanned=42 Deadline=00:00:12 +2026-09-06 10:08:10,910 [5] ERROR QuickFiler.Controllers.QfcDatamodel - LoadRemainingEmailsToQueue Error. + Delegate to an instance method cannot have null 'this'. + at System.MulticastDelegate.CtorClosed(Object target, IntPtr methodPtr) + at QuickFiler.Controllers.QfcDatamodel.d__41.MoveNext() ... QfcDatamodel.cs:line 355 + at QuickFiler.Controllers.QfcDatamodel.d__40.MoveNext() ... QfcDatamodel.cs:line 330 +2026-09-06 10:08:10,985 [5] ERROR QuickFiler.Controllers.QfcDatamodel - Error in Worker_DoWork Delegate to an instance method cannot have null 'this'. +``` + +Timeline evidence (same log): launches at 08:52:09 (accepted, rows at 08:53:39), 09:43:32 (Accepted=0), 09:45:14 (Accepted=0), 10:04:26 (accepted), 10:05:12 (accepted), 10:07:45 (Accepted=0). Undo burst 09:04:05 to 09:05:53, then no log output until 09:43:32. + +## Scope & Non-Goals +- In scope: the production files listed in the Write Set (gate, IQfcDatamodel, both QfcDatamodel partials, the QfcFormController EventHandlers and Deactivate partials, QfcHomeController), the new and retargeted MSTest files, and the `` entries required for new files. +- Out of scope / non-goals (paths below are deliberately unbackticked; they are not part of the change footprint): + - QuickFiler/Controllers/QfcCollectionController.cs — 2329 lines, a pre-existing violation of the 500-line limit. UnregisterNavigation is already on IQfcCollectionController (line 109) and is called from the Cancel path instead of being added to that file's Cleanup. + - QuickFiler/Controllers/QfcHomeController.Iteration.cs — the SourceExhausted-only CompleteAddingAsync branch is already correct and is preserved unchanged (#446 AC-6). + - TaskMaster/Ribbon/RibbonController.cs — ReleaseQuickFiler stays private with no test seam; the guarantee is expressed at the QfcHomeController.ParentCleanup boundary. + - TaskMaster/Properties/Settings.Designer.cs and TaskMaster/AppGlobals/AppQuickFilerSettings.cs — no new user-facing setting is introduced. + - QuickFiler/Controllers/QfcFormController.SetupDisposal.cs — the existing Cleanup body and the #731 deferred undo-queue disposal are untouched; the ordering defect is corrected by calling the existing unregister methods earlier from the Cancel path. +- Explicitly excluded systems, integrations, or datasets: the breadcrumb WebView2 initialization failure (`Breadcrumb CoreWebView2 initialization failed ... 0x8007139F`, observed 08:55:22 and 10:06:51), filed separately as issue #792; the SpamBayes/Triage scoring engines; Outlook COM automation in tests. + +## Root Cause Analysis +- Gate loop QfcStreamingDequeueConfidenceGate.cs:168-237: the deadline is evaluated only while accepted.Count == 0 (:172-176) and returns DeadlineExpired at :179. scanned++ at :205 runs only after the score loader returns, so `Scanned=38 Accepted=0` means 38 completed scores all strictly below _cutoff (:129, per-mille). Rejected items leave the session queue permanently (:182, :215-232), so a rerun rescans the same view prefix — hence the reported determinism. +- The empty-queue wait path (:185-196) does not increment scanned, so an item cap alone cannot bound the pre-UI wait while the loader is still refilling. A time ceiling is required in addition. +- The 12 second first-batch deadline was introduced by #424 and adjusted by #446 and #608; those changes handled the post-UI iteration and the undersized-batch cases, not the zero-accepted first batch. +- Worker_DoWork (QfcDatamodel.cs:175-213) is `async void` and retains no handle to the loader task, so nothing can await it. LoadRemainingEmailsToQueueAsync observes the token only at :322 and :324; TryQueueRemainingMailItemAsync then dereferences _masterQueue and _moveMonitor at :355-359 with no null guard. +- Keyboard mechanism: no SetWindowsHookEx, AddMessageFilter or KeyPreview exists anywhere in the repo. #677 identified WebView2 focus retention and an open breadcrumb ToolStripDropDown as the mechanism and fixed it on the Form.Deactivate path only; the Cancel path unsubscribes that event. +- Related closed issues: #424, #446, #608 (deadline lineage), #677 (Deactivate focus fix), #731 (controller lifecycle disposal), #737 (breadcrumb keyboard navigation). All are on `7c8ac9ae`. +- Unknown: whether the 09:05 keyboard lock cleared on Escape, on focus change, or only on restart. The Cancel-stage logging added here is what makes a future occurrence diagnosable. + +## Proposed Fix + +### Design summary (what changes where): + +**AC1 — advisory checkpoint plus a hard scan bound.** The zero-acceptance branch at gate:172-180 becomes a checkpoint instead of a return. _firstBatchDeadline is re-purposed as the checkpoint interval: on expiry the gate logs the cutoff, scanned, accepted count, elapsed time and the remaining bounds, resets the interval origin, and continues scanning. Two bounds terminate the extended scan: maxScanWithoutAcceptance (default 250 scored candidates) and zeroAcceptanceCeiling (default 120 seconds). Both are gate-internal `internal static readonly` defaults with an optional constructor parameter as the test seam — no new setting. A new stop reason `QfcDequeueStop.ScanCapReached` reports the bounded exit and is treated exactly as DeadlineExpired is treated today (the queue stays open). `DeadlineExpired` is retained as an enum member with its XML doc updated to record that #791 made the deadline advisory. A launch log line at the top of DequeueAsync carries the cutoff, quantity, checkpoint interval and both bounds. IterateQueueAsync still calls CompleteAddingAsync only under SourceExhausted. + +Superseded prior criteria, stated deliberately rather than regressed silently: +- #424 spec acceptance criterion at docs/features/archive/2026-08-06-quickfiler-high-confidence-queue-init-stall-424/spec.md:231 ("When zero candidates reach the cutoff before the deadline, `DequeueAsync` returns an empty list at the deadline bound...") is **superseded by #791 AC1**. +- #608 spec acceptance criterion at docs/features/active/2026-08-25-quickfiler-high-confidence-partial-screen-backfill-608/spec.md:184 ("Deadline expiry with `accepted.Count == 0` retains the current empty-result behavior...") is **superseded by #791 AC1**. #608's other criteria (:181-183, :185) concern the non-empty prefix and must remain green. + +**AC2 — ordered, logged, exception-safe teardown.** +1. Worker_DoWork captures the loader task in a `_remainingLoadTask` field before awaiting it. A new `IQfcDatamodel.QuiesceLoaderAsync(TimeSpan)` cancels, then awaits the loader against a TimeProvider delay of the supplied bound and logs whether the loader completed or the bound expired. The field and method live in the QueueProcessing partial. It is awaited from ActionCancelAsync through _parent.DataModel — **never** a blocking wait inside Cleanup(), which #731 established runs on the UI thread. +2. TryQueueRemainingMailItemAsync is relocated to the QueueProcessing partial, snapshots _masterQueue and _moveMonitor into locals, and returns false when either is null or cancellation is requested. +3. QfcDatamodel.Cleanup() null-guards its _globals / _moveMonitor dereferences so a second Cancel cannot throw before the fields are released. +4. FormViewer_Deactivated is split into the event handler plus `internal void ParkFocusAndCancelSelectors()`, called from both the event and the Cancel path, with the per-item boundary catch intact. +5. ActionCancelAsync is reordered to: (1) log entry; (2) signal cancellation; (3) marshal to the UI sync context; (4) reset KbdActive, toggling only when active; (5) ParkFocusAndCancelSelectors() while the item groups still exist; (6) _groups?.UnregisterNavigation() and UnregisterFormEventHandlers() before rows are removed; (7) Hide(); (8) await QuiesceLoaderAsync; (9) _groups?.Cleanup(); (10) Cleanup(), which reaches ParentCleanup. Each stage-group is wrapped so a throwing stage cannot skip a later one, and the release callback runs under `finally`. Repeat invocation (double Cancel, or Cancel after the MoveAndIterate completion path, which calls the same method) is inert. +6. QfcHomeController.Cleanup() wraps the datamodel cleanup, the field nulling and the Worker_RunWorkerCompleted detach in guarded blocks with logging, disposes _tokenSource, and invokes ParentCleanup in a `finally`. +7. ButtonCancel_Click no longer rethrows. This is a deliberate behavior change: an `async void` rethrow becomes an unhandled Outlook UI-thread exception, which is the failure mode the logging requirement replaces. + +**Invariant established by this fix (single sentence):** after ActionCancelAsync returns, no background loader work can observe a nulled QfcDatamodel field — the loader has either completed or been bounded out and its admission path returns false rather than constructing a delegate over a null instance — and RibbonController.ReleaseQuickFiler has been invoked exactly once regardless of which teardown stage threw. + +**Trace of one accepted value (the reported crash):** +1. *Accept point* — LoadRemainingEmailsToQueueAsync (QfcDatamodel.cs:322, :324) checks only the cancellation token and passes a MailItem to TryQueueRemainingMailItemAsync. It does not validate _masterQueue or _moveMonitor, and no guard exists anywhere between here and the throw. +2. *Throw point* — QfcDatamodel.cs:355-359 constructs QfcRemainingQueueAdmission over _masterQueue.AddLast and _moveMonitor.HookItem; once Cleanup() has nulled either field, delegate construction raises ArgumentException "Delegate to an instance method cannot have null 'this'". +3. *Current absorption point* — the exception is caught and logged as "LoadRemainingEmailsToQueue Error." and again as "Error in Worker_DoWork". Because Worker_DoWork is `async void` and the form is already hidden, neither location can report to the operator, abort the teardown, or prevent the loader from surviving into the next launch (the logged crash occurred 4 seconds after a relaunch). +4. *Where the fix moves the decision* — the awaited QuiesceLoaderAsync in ActionCancelAsync is an `async` boundary that can both wait and report before any field is nulled, and the relocated guard in TryQueueRemainingMailItemAsync returns false at the accept point instead of throwing at the throw point. + +Why neither half suffices alone: the quiesce await alone leaves the crash reachable on any future path that nulls fields without awaiting (for example the MoveAndIterate completion path, or a partially-failed launch), and the null guard alone silently truncates queue loading that was still legitimately in flight while giving the teardown no completion point to observe. Both are required, and both are pinned by tests. + +### Boundaries and invariants to preserve: +- #446 AC-6: CompleteAddingAsync is invoked only under SourceExhausted. The new ScanCapReached stop reason must not be routed into that branch, and QfcHomeController.Iteration.cs is not modified. +- #608's non-empty-prefix criteria (:181-183, :185): the deadline remains inert once accepted.Count > 0; inclusive `score >= _cutoff` qualification, below-cutoff discard, accepted-message ordering and cancellation propagation are unchanged. +- #731 disposal design, untouched: the deferred undo-queue disposal via _undoQueueDisposal in QfcFormController.SetupDisposal.cs:207-249; the one-monitor-per-owner design and comment at QfcDatamodel.cs:104-105; the three-delegate QfcRemainingQueueAdmission constructor. Do not convert the quiesce into a blocking wait inside Cleanup() — that is the deadlock #731 finding 4 rejected. +- #677: ParkFocusOffWebView2 and the per-item CancelBreadcrumbSelector loop keep their existing bodies and their existing Form.Deactivate wiring; only the extraction of the shared routine is new. +- Cancellation semantics: cancelling during the extended scan still surfaces OperationCanceledException; `quantity <= 0` still short-circuits. +- Which catches must not be widened: the per-item boundary catch inside the deactivate routine stays per-item (a broader catch would hide a single failing selector); the gate's score-loader call site keeps propagating OperationCanceledException, pinned by the existing gate cancellation tests. + +### Dependencies or blocked work: +None. All prerequisite work (#424, #446, #608, #677, #731, #737) is closed and present on `7c8ac9ae`. The live-Outlook confirmation is a human follow-up and does not block the automated review. + +### Implementation strategy (what changes, not sequencing): + +#### Files/modules to change: + +Write Set — production: +- `QuickFiler/Controllers/QfcStreamingDequeueConfidenceGate.cs` +- `QuickFiler/Interfaces/IQfcDatamodel.cs` +- `QuickFiler/Controllers/QfcDatamodel.QueueProcessing.cs` +- `QuickFiler/Controllers/QfcDatamodel.cs` +- `QuickFiler/Controllers/QfcFormController.EventHandlers.cs` +- `QuickFiler/Controllers/QfcFormController.Deactivate.cs` +- `QuickFiler/Controllers/QfcHomeController.cs` + +Write Set — tests (new): +- `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs` +- `QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs` +- `QuickFiler.Test/Controllers/QfcHomeControllerCleanupTests.cs` +- `QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs` + +Write Set — tests (retargeted): +- `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs` +- `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs` +- `QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs` +- `QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs` + +Write Set — project files: +- `QuickFiler.Test/QuickFiler.Test.csproj` — `` entries for the four new test files +- `QuickFiler/QuickFiler.csproj` — an entry is required only if implementation introduces a new production file; the mapping above adds none + +#### Functions/classes/CLI commands impacted: +DequeueAsync, LogDeadlineExpiry and the new launch-log helper on the gate; the QfcDequeueStop enum and the IQfcDatamodel contract; Worker_DoWork, Cleanup, TryQueueRemainingMailItemAsync and the new QuiesceLoaderAsync on QfcDatamodel; ActionCancelAsync and ButtonCancel_Click on QfcFormController; FormViewer_Deactivated split into the handler plus ParkFocusAndCancelSelectors; QfcHomeController.Cleanup. No CLI surface exists in this component. + +#### Data flow and validation changes: +The gate's scan loop gains two counters checked at the same point as the existing checkpoint, before the take, so a bounded scan cannot take an extra item. The queue take, admission, scoring and progress-callback contracts are unchanged. TryQueueRemainingMailItemAsync gains a null/cancellation precondition that returns false instead of throwing. No persisted data, schema or file format changes. + +#### Error handling and logging updates: +All lines use the existing log4net ILog idiom on each class; no new logger shape. Levels are chosen so a normal Cancel is readable at INFO and diagnosis is available at DEBUG. + +| Stage | Level | Content | +| --- | --- | --- | +| Gate launch | DEBUG | cutoff (per-mille and fraction), quantity, checkpoint interval, scan cap, ceiling | +| Gate checkpoint | DEBUG | accepted, scanned, cutoff, elapsed, remaining cap/ceiling, decision (continue / stop) | +| Cancel entry | INFO | trigger (button vs. completion path), token already cancelled? | +| Token cancelled | DEBUG | — | +| Keyboard flag reset | DEBUG | previous KbdActive value | +| Focus parked / selectors cancelled | DEBUG | whether a WebView2 held focus; item count cancelled | +| Handlers unregistered | DEBUG | navigation ledger drained, form handlers removed | +| Loader quiesce | INFO | completed vs. timed out, elapsed, bound | +| Datamodel cleanup | DEBUG | — | +| Groups cleanup | DEBUG | rows removed | +| Release callback invoked | INFO | — | +| Any stage exception | ERROR | stage name + exception (logger.Error(message, e)) | + +#### Rollback/feature-flag considerations (if applicable): +No feature flag. Rollback is a revert of the branch. The bounds are constructor-seamed constants, so behavior can be tuned without a settings migration. + +### Technical specifications (interfaces/contracts): + +#### Inputs/outputs and formats: +DequeueAsync keeps its existing parameters and result shape; the outcome's stop reason may now be ScanCapReached in addition to the existing members. QuiesceLoaderAsync takes a TimeSpan bound and returns a Task that completes when the loader finishes or the bound expires; it never throws for the timeout case. ParkFocusAndCancelSelectors takes no arguments and returns void. + +#### Required configuration keys and defaults: +None. maxScanWithoutAcceptance (250) and zeroAcceptanceCeiling (120 seconds) are internal gate defaults with optional constructor parameters, following the ratified #424 precedent that the deadline is an internal constant with an internal test seam and no settings surface. The quiesce bound is likewise a constant supplied by the caller. + +#### Backward-compatibility expectations: +Additive only at the type level: one new enum member, one new interface method, one new internal method, two new optional constructor parameters. DeadlineExpired is retained. Existing callers compile unchanged. The behavioral changes that are not backward compatible, and are intended, are the superseded #424/#608 empty-result criteria and the non-rethrowing ButtonCancel_Click. + +#### Performance constraints (latency/throughput/memory): +The extended scan is bounded by the item cap and the time ceiling; no unbounded wait is introduced. No performance threshold is asserted as an acceptance criterion, because no measured baseline exists for scan throughput (observed 2-3 items/s is a field observation, not a benchmark). Real-world tolerability of the extended scan is an observation recorded during the live-Outlook verification. + +## Assumptions, Constraints, Dependencies +- Assumptions: the scoring throughput and score distributions observed on 2026-09-06 are representative; the recommended bounds (250 items, 120 seconds) are engineering proposals confirmed during live verification, not measured values. +- Constraints: .NET Framework 4.8 / legacy non-SDK projects, so every new file needs an explicit Compile entry; the 500-line file limit; no temporary files and no wall-clock waits in tests; Cleanup runs on the UI thread and must not block. +- External dependencies: MSTest, Moq, FluentAssertions, Microsoft.Extensions.Time.Testing (FakeTimeProvider), log4net — all already referenced. No new package. + +## Data / API / Config Impact +- User-facing or API changes: one new QfcDequeueStop member (ScanCapReached); one new IQfcDatamodel method (QuiesceLoaderAsync); one new internal method on QfcFormController (ParkFocusAndCancelSelectors); two new optional constructor parameters on the internal gate class. Operator-visible change: a High Confidence run may now scan longer before the dialog opens, and opens empty only on exhaustion or at a bound. +- Superseded criteria recorded here for the reviewer: the #424 spec criterion at archive/2026-08-06-quickfiler-high-confidence-queue-init-stall-424/spec.md:231 and the #608 spec criterion at active/2026-08-25-quickfiler-high-confidence-partial-screen-backfill-608/spec.md:184 are both superseded by #791 AC1. #446 AC-6 is preserved. +- Data or migration considerations: none. No settings surface: Settings.Designer.cs, AppQuickFilerSettings and IAppQuickFilerSettings are unchanged. +- Logging/telemetry updates: the Logging Plan table above. +- Compatibility notes: both QuickFiler projects are legacy non-SDK, so new files require `` entries; no CLI flags or config schemas exist for this component. + +## Test Strategy +MSTest with Moq and FluentAssertions. FakeTimeProvider is the clock seam for both the gate and the quiesce bound. No temporary files, no Thread.Sleep, no Task.Delay, no wall-clock waits, no live Outlook COM. + +AC1 — new tests in `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part4.cs` (a new file; the existing gate test files are 477 and 465 lines): +- DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance — 40 below-cutoff candidates then one at 950, fake clock advancing 1 s per score against a 12 s checkpoint. Fail-before evidence required. +- DequeueAsync_ZeroAcceptedAndSourceDrained_ReportsSourceExhausted — cap not reached, producer dead. +- DequeueAsync_ZeroAcceptedAndCapReached_StopsAndReportsScanCapReached — small injected cap; asserts no take occurs after the cap. +- DequeueAsync_ZeroAcceptedAndCeilingReached_StopsWhileSourceStillRefilling — sourceActive true and tryTakeNext always null; asserts the ceiling terminates the wait loop. +- DequeueAsync_CheckpointExpiry_LogsCutoffAndCounts and DequeueAsync_Launch_LogsCutoffQuantityAndBounds — asserted through the injected debugLog delegate, not a log4net appender (existing convention). +- DequeueAsync_NonEmptyPrefix_UnchangedByCheckpoint — #608 regression pin. + +AC1 retargeting obligations (these encode the superseded behavior; retarget, do not delete): +- `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part3.cs` lines 174-208, DequeueAsync_DeadlineExpiresWithZeroAccepted_ReportsDeadlineExpiredStop. +- `QuickFiler.Test/Controllers/QfcQueuePurePathsTests.cs` lines 201-260, DequeueNextItemGroupWithOutcomeAsync_DeadlineExpiredGate_ReportsDeadlineExpiredStop. +- `QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.cs` lines 27-92, the fail-closed reflection helper that asserts an exact nine-parameter constructor and must be updated for the two new optional parameters. +- `QuickFiler.Test/Controllers/QfcHomeControllerIterationTests.cs` gains a sibling of the existing pin asserting that ScanCapReached also leaves the queue open (CompleteAddingAsync not called). + +AC2 — new tests: +- `QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs`: ActionCancelAsync_ResetsKbdActive_WhenKeyboardDialogActive and _DoesNotToggle_WhenInactive; ActionCancelAsync_ParksFocusAndCancelsBreadcrumbSelectors; ActionCancelAsync_UnregistersHandlersBeforeGroupsCleanup (invocation-order assertion; fails before, order inverted); ActionCancelAsync_AwaitsLoaderQuiesceBeforeGroupsCleanup, including that a timed-out quiesce still proceeds; ActionCancelAsync_GroupsCleanupThrows_StillInvokesParentCleanup; ButtonCancel_Click_ActionThrows_DoesNotRethrow. +- `QuickFiler.Test/Controllers/QfcHomeControllerCleanupTests.cs`: Cleanup_DatamodelCleanupThrows_StillInvokesParentCleanup; Cleanup_DisposesTokenSourceAndDetachesWorkerCompleted. +- `QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs`: TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing (fail-before evidence required; fails today with the exact ArgumentException from the log); QuiesceLoaderAsync_LoaderCompletes_ReturnsBeforeTimeout; QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs; Cleanup_CalledTwice_DoesNotThrow. +- Not proposed: any test of RibbonController.ReleaseQuickFiler. It is private with no seam; the guarantee is asserted at the ParentCleanup boundary. + +Edge cases and negative scenarios: quantity <= 0 short-circuit; cancellation during the extended scan; transient empty queue while the loader refills; double Cancel; Cancel after a partially-failed launch; a throwing stage in each teardown stage-group. + +Coverage impact and targets: changed lines must not regress, and the new and changed methods target >= 90% per the repository unit-test policy. The repository-wide figure is reported against the testable denominator per CLAUDE.md UT2 (COM/VSTO/WinForms/Outlook-Interop exemptions); this change must not lower it. Coverage XML is produced at artifacts/csharp/coverage.xml for the feature review (a permitted non-evidence artifacts path), and the baseline and final-QC coverage notes are recorded under this feature folder's canonical evidence/baseline/ and evidence/qa-gates/ directories. Fail-before/pass-after evidence is recorded under this feature folder's evidence/regression-testing/ directory. + +Toolchain commands to run, in this order, restarting from the first on any failure or auto-fix (per CLAUDE.md): +1. `dotnet tool run csharpier format .` then `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. vstest.console.exe over the QuickFiler.Test and dependent test assemblies with /EnableCodeCoverage + +Manual validation steps: performed by a human per runbooks/live-outlook-cancel-teardown-verification.runbook.md; see Rollout & Follow-up. + +## Acceptance Criteria +- [x] AC1: A High Confidence run that has found no item at or above the cutoff when the first-batch deadline expires continues scanning until the first acceptance, until the candidate queue is genuinely exhausted, or until a hard bound is reached (a cap on items scanned without acceptance, plus a time ceiling that bounds the wait while the background loader is still refilling). An empty dialog is permitted only on exhaustion or at the bound, and the bound decision is logged. The cutoff in effect and the scanned/accepted counts are logged at launch and at each deadline decision. Covered by deterministic MSTest regression tests using a fake time provider. + - Verified 2026-09-06T15-13. Fail-before: `evidence/regression-testing/p1-t16-gate-fail-before.md` (EXIT_CODE 1, 12 failures, including `DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance` failing with an empty accepted collection). Pass-after: `evidence/regression-testing/p2-t14-pass-after.md` (EXIT_CODE 0, all 25 inventory tests `PASS-AFTER`). The zero-acceptance branch is now a checkpoint that logs and continues, bounded by `DefaultMaxScanWithoutAcceptance` (250) and `DefaultZeroAcceptanceCeiling` (120 s), reported as the new `QfcDequeueStop.ScanCapReached`; launch and checkpoint lines carry the cutoff, quantity, counts and both bounds. +- [x] AC2: The Cancel teardown completes cleanly and in order: the background loader is stopped and awaited before any datamodel field is nulled; form and item keyboard handlers are unregistered before item rows are removed; the keyboard-active flag is reset; WebView2 focus is parked and any open breadcrumb dropdown is cancelled on the Cancel path; the ribbon release callback runs under a `finally`; and every stage, including any exception, is logged. Covered by deterministic MSTest regression tests. The live-Outlook confirmation (keyboard usable after Cancel, new log lines present, no null-`this` loader error) is a human follow-up performed per `runbooks/live-outlook-cancel-teardown-verification.runbook.md`, recorded as human-interaction exception HI-1, and does not gate the automated review. + - Verified 2026-09-06T15-13. Fail-before: `evidence/regression-testing/p1-t17-cancel-teardown-fail-before.md` (EXIT_CODE 1, 6 failures), `evidence/regression-testing/p1-t18-home-cleanup-fail-before.md` (EXIT_CODE 1, 2 failures), `evidence/regression-testing/p1-t19-datamodel-teardown-fail-before.md` (EXIT_CODE 1, 5 failures, including the reported `ArgumentException` "Delegate to an instance method cannot have null 'this'"). Pass-after: `evidence/regression-testing/p2-t14-pass-after.md` (EXIT_CODE 0, all 25 inventory tests `PASS-AFTER`). `ActionCancelAsync` now awaits `IQfcDatamodel.QuiesceLoaderAsync` before any field is nulled, unregisters navigation and form handlers before the rows are removed, resets the keyboard-active flag, calls the extracted `ParkFocusAndCancelSelectors()`, runs every stage through a logging `RunTeardownStage` helper, and reaches `Cleanup()` — and through it the ribbon release callback — under `finally`; `QfcHomeController.Cleanup()` does the same with two guarded blocks and disposes the token source. + - The live-Outlook confirmation (keyboard usable after Cancel, new log lines present, no null-`this` loader error) is human-interaction exception HI-1, performed by a human per `runbooks/live-outlook-cancel-teardown-verification.runbook.md`, recorded at `evidence/other/manual-verification.yyyy-MM-ddTHH-mm.md`, and does not gate the automated review. It is outstanding at check-off time and is listed in Rollout & Follow-up. +- [x] AC3: Every regression test named in Test Strategy exists in the file listed for it and passes, and fail-before/pass-after evidence is recorded under this feature folder's evidence/regression-testing/ directory for at least DequeueAsync_ZeroAcceptedAtCheckpoint_ContinuesUntilFirstAcceptance and TryQueueRemainingMailItemAsync_AfterCleanupNulledFields_ReturnsFalseWithoutThrowing. + - Verified 2026-09-06T15-14 by `evidence/qa-gates/p3-t13-ac3-test-inventory.md`, which maps every Test Strategy test name to the file it now lives in and to its result in the [P2-T14] run: 26 names, 26 mapped to an existing file, 25 with a passing result, and the 26th being the RibbonController test Test Strategy explicitly does not propose. Both required fail-before/pass-after pairs are recorded: `evidence/regression-testing/p1-t16-gate-fail-before.md` with `evidence/regression-testing/p2-t14-pass-after.md`, and `evidence/regression-testing/p1-t19-datamodel-teardown-fail-before.md` with the same pass-after artifact. +- [x] AC4: The C# toolchain passes in the CLAUDE.md order (csharpier format then check, the analyzer msbuild /t:Rebuild, the nullable msbuild /t:Rebuild, vstest with /EnableCodeCoverage) with no failures in the final pass; coverage XML is produced at artifacts/csharp/coverage.xml; and coverage on the changed files is at or above the policy target with no regression on changed lines. + - Verified 2026-09-06T15-15. Toolchain order and closure: `evidence/qa-gates/p3-t1-csharpier-format.md`, `p3-t2-csharpier-check.md` (exit 0, 1587 files), `p3-t3-msbuild-analyzers.md` (exit 0, 0 warnings, 0 errors), `p3-t4-msbuild-nullable.md` (exit 0, 0 warnings, 0 errors), `p3-t5-tests-coverage.md` (exit 0, 7023 passed, 0 failed), consolidated by `p3-t6-loop-closure.md`, which records one restart caused by the first format rewriting files and then five green steps in one uninterrupted pass. + - Coverage XML exists at `artifacts/csharp/coverage.xml` (`p3-t5-tests-coverage.md`). Changed-line coverage: `p3-t7-changed-line-coverage.md` — 131 executable changed lines, 12 with `hits = 0` (90.8 % covered, at or above the >= 90 % target for new and changed code), 0 lines with a coverage regression. Repository-wide delta: `p3-t8-coverage-delta.md` — first-party line coverage 84.50 % to 84.51 %, branch coverage 79.14 % to 79.19 %; neither decreased. + - Collector substitution (D13): the run uses `dotnet-coverage collect --output-format cobertura -- ...` rather than `vstest /EnableCodeCoverage`. `/EnableCodeCoverage` writes a binary `.coverage` file, not the Cobertura XML this criterion also requires, and the two collectors conflict when combined. The same `vstest.console.exe`, assemblies and switches are used inside the wrapper, so the substantive requirement is met. Recorded as a deviation under Rollout & Follow-up. +- [x] AC5: The branch diff touches no file outside the Write Set, other than test files under QuickFiler.Test/Controllers and `` entries in the QuickFiler project files; in particular QfcCollectionController.cs, QfcHomeController.Iteration.cs, RibbonController.cs, Settings.Designer.cs and AppQuickFilerSettings.cs are unmodified. + - Verified 2026-09-06T15-15 by `evidence/qa-gates/p3-t10-scope-boundary.md`, which lists the anchored `git diff --name-only` and the `git status --porcelain --untracked-files=all` outputs side by side (neither alone is correct in both states) and finds the same 17 paths in each: the seven Write Set production paths, four new and five modified test paths under QuickFiler.Test/Controllers, and QuickFiler.Test/QuickFiler.Test.csproj with four `` entries. QuickFiler/QuickFiler.csproj is unchanged because the implementation introduces no new production file. None of the five named exclusions appears in either output; QfcHomeController.Iteration.cs was additionally verified unmodified by its own path-scoped diff and porcelain pair in [P2-T3]. + - Evaluation scope (plan rule R7): this criterion is evaluated over the source pathspec `'*.cs' '*.csproj'`. Read literally over the whole tree it is unsatisfiable, because delivering this fix requires writing evidence artifacts under evidence/ and checking these AC boxes in this file. The narrower pathspec is the footprint the Write Set actually describes, and it is recorded here rather than left implicit so it is not read as an unstated relaxation. Outside that pathspec the branch also changes the plan file, this spec, issue.md and the evidence artifacts, all of which are the plan's own required outputs. +- [x] AC6: The superseded #424 criterion (spec.md:231 in the archived #424 feature folder) and the superseded #608 criterion (spec.md:184 in the active #608 feature folder) are both recorded as superseded in this spec, under Proposed Fix and under Data / API / Config Impact, and #446 AC-6 is verifiably preserved by an unmodified QfcHomeController.Iteration.cs. + - Verified 2026-09-06T15-16. Both supersession statements are present in this file and were not modified by this plan: under Proposed Fix at lines 103-105 ("Superseded prior criteria, stated deliberately rather than regressed silently", naming the #424 criterion at archive/2026-08-06-quickfiler-high-confidence-queue-init-stall-424/spec.md:231 and the #608 criterion at active/2026-08-25-quickfiler-high-confidence-partial-screen-backfill-608/spec.md:184), and under Data / API / Config Impact at lines 213-214. + - #446 AC-6 preservation: [P2-T3] ran a path-scoped anchored `git diff --name-only` and `git status --porcelain --untracked-files=all` over QuickFiler/Controllers/QfcHomeController.Iteration.cs; both returned empty, so the file is byte-identical to BASE-SHA and CompleteAddingAsync remains reachable only under SourceExhausted. `evidence/qa-gates/p3-t10-scope-boundary.md` confirms the same at whole-pathspec scope. The behavioural pin is `IterateQueueAsync_EmptyBatchWithScanCapReached_DoesNotCompleteAdding`, which asserts the new stop reason does not close the queue and is recorded `PASS-AFTER` in `evidence/regression-testing/p2-t14-pass-after.md`, alongside its negative control `IterateQueueAsync_EmptyBatchWithSourceExhausted_CompletesAddingOnce`. + +## Risks & Mitigations +- Risk: the extended scan makes the pre-UI wait feel longer to the operator when no item qualifies. Mitigation: progress reporting continues during the extended scan, checkpoint decisions are logged, and both bounds terminate it; the bounds are confirmed during live verification. +- Risk: the retargeted gate tests are rewritten to assert the new behavior in a way that no longer pins anything. Mitigation: the superseded assertions are replaced by explicit ScanCapReached and continuation assertions, and the #608 non-empty-prefix pin is added as its own test. +- Risk: the non-rethrowing ButtonCancel_Click hides a real failure. Mitigation: every stage exception is logged at ERROR with its stage name, which is strictly more diagnosable than the current unhandled UI-thread rethrow. +- Risk: the quiesce bound expires while the loader is genuinely mid-work. Mitigation: the relocated null/cancellation guard makes the post-bound continuation harmless, and the timeout case is logged at INFO. +- Rollback: revert the branch; no data or configuration migration is involved. + +## Rollout & Follow-up + +### Outcome + +Implemented on branch bug/quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791 on +2026-09-06. All six acceptance criteria are checked off above with their evidence paths. The final +toolchain pass was green in one uninterrupted run (evidence/qa-gates/p3-t6-loop-closure.md): 1587 +files formatter-clean, 0 analyzer warnings and errors, 0 nullable warnings and errors, and 7023 +tests passed with 0 failures across the nine first-party test assemblies. First-party line coverage +moved from 84.50 % to 84.51 % and branch coverage from 79.14 % to 79.19 %; no changed line lost +coverage. + +Four deviations from this spec's own prose were made during implementation. Each is recorded here by +name with its reason rather than left as a silent difference between the spec and the code. + +1. **The ActionCancelAsync trigger discriminator is a call-site log line, not a method parameter.** + The Logging Plan asks the Cancel-entry line to carry a "trigger (button vs. completion path)" + discriminator, which would naturally be an optional parameter on ActionCancelAsync. + QuickFiler/Interfaces/IFilerFormController.cs:11 declares `Task ActionCancelAsync();`, C# requires + an exact signature match to implement an interface member, and that interface file is outside the + Write Set, so AC5 forbids changing it. ActionCancelAsync therefore keeps its zero-parameter + signature and the discriminator is supplied at the call site instead: the error path already logs + in MoveAndIterate, and a log.Debug line naming the completion path was added immediately before + the completion-path call. + +2. **QfcDatamodel.QuiesceDebugLog is an added internal test seam not named in this spec.** + Test Strategy requires QuiesceLoaderAsync_LoaderHangs_ReturnsAtBoundAndLogs to observe the log. + QfcDatamodel logs through log4net and no memory-appender convention exists anywhere in + QuickFiler.Test; attaching one would mutate a process-global logger repository and break test + independence. The dequeue gate already established the alternative convention — an injected + Action debugLog asserted directly — so an `internal Action QuiesceDebugLog` was + added to the QueueProcessing partial, mirroring it. It is internal, the assembly already grants + InternalsVisibleTo("QuickFiler.Test"), and it widens no public surface. The same lines still reach + log4net at INFO in production. + +3. **The retargeting surface is seven tests, not the four Test Strategy names.** + Reading every deadline-dependent gate test against the AC1 design found three more that encode + the superseded behaviour and fail after the change: two further tests in + QuickFiler.Test/Controllers/QfcStreamingDequeueConfidenceGateTests.Part2.cs beyond the ones named, + and DequeueAsync_ProgressCallback_StopsReportingOnceTheMethodReturns in + QfcStreamingDequeueConfidenceGateTests.Part3.cs, whose bound was rebased from the deadline onto an + injected scan cap. All seven were retargeted rather than deleted, preserving each test's intent + against the new behaviour. Retargeting a test AC3 does not name is permitted: AC3 requires the + named tests to exist and pass, and AC5 permits changes to test files under + QuickFiler.Test/Controllers. Two further files that reference the deadline surface — + QfcHomeControllerRunAsyncHighConfidenceTests.cs and its Part2 — were deliberately excluded, + because neither constructs the gate and so neither observes the behaviour change. + +4. **Coverage is collected with dotnet-coverage, not vstest /EnableCodeCoverage.** + AC4's toolchain step 4 names `vstest.console.exe /EnableCodeCoverage`. + /EnableCodeCoverage writes a binary .coverage file rather than the Cobertura XML AC4 also requires + at artifacts/csharp/coverage.xml, and the two collectors conflict when combined. The runs + therefore use `dotnet-coverage collect --output-format cobertura -- ...`, wrapping the + same vstest.console.exe with the same nine assemblies, the same runsettings and the same + switches. AC4's substantive requirement — Cobertura XML at artifacts/csharp/coverage.xml, + produced by running the full suite — is met, and the baseline and final documents are produced by + one collector, one configuration, one selection and one filter so they are comparable. + +A fifth, smaller divergence is recorded for completeness in +evidence/regression-testing/p1-t19-datamodel-teardown-fail-before.md: the two QuiesceLoaderAsync +tests were expected to fail with NotImplementedException from the Phase 1 seam and instead fail one +step earlier, on the fail-closed reflective lookup of the _remainingLoadTask field that the Phase 2 +task adds. Both remain red before the change and green after it, so the fail-before/pass-after +evidence is unaffected. + +### Original rollout notes + +- Release/rollout steps: merge to main after review; the add-in is picked up by rebuilding the registered checkout, with no re-registration step. +- Post-fix manual verification: a human performs the live-Outlook confirmation per runbooks/live-outlook-cancel-teardown-verification.runbook.md after the fix is built, following the #677 precedent, and records the evidence note in this feature folder at evidence/other/manual-verification.yyyy-MM-ddTHH-mm.md with the timestamp format from the evidence-and-timestamp-conventions skill. This is human-interaction exception HI-1 and does not gate the automated review. +- Post-fix monitoring: after the next live High Confidence runs, confirm the Cancel-stage log lines appear and that no "Delegate to an instance method cannot have null 'this'" error follows a Cancel. +- Follow-up: issue #792 tracks the breadcrumb WebView2 initialization failure (0x8007139F), which is out of scope here. +- Links: issue https://github.com/drmoisan/TaskMaster/issues/791; research note in this feature folder under research/; runbook under runbooks/; follow-up issue #792. diff --git a/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/user-story.md b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/user-story.md new file mode 100644 index 000000000..915759fb1 --- /dev/null +++ b/docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/user-story.md @@ -0,0 +1,25 @@ +# User Story — QuickFiler High Confidence deadline and Cancel teardown (Issue #791) + +Why this file exists in full-bug mode: both defects are operator-facing (an empty High Confidence dialog with no explanation, and an Outlook keyboard left unusable after Cancel), so the operator's perspective is warranted; spec.md remains the sole authoritative acceptance-criteria source under work mode full-bug. + +> This document is narrative context only. It contains no acceptance criteria and no checkboxes. Do not use it as an AC source or a check-off target; the criteria live in spec.md in this folder. + +## Story 1 — High Confidence returns suggestions instead of an empty dialog + +As an Outlook user filing mail with QuickFiler in High Confidence mode, I want the scan to keep looking when the first seconds of a view produce no confident match, so that I get the suggestions that exist further down the view instead of an empty dialog. + +- **Given** an Explorer view whose leading items all score below the confidence cutoff while later items score above it, +- **When** I launch QuickFiler from the High Confidence ribbon button, +- **Then** scanning continues past the first-batch checkpoint with progress still reported, the dialog opens with the first confident suggestions once they are found, and it opens empty only when the candidate queue is genuinely exhausted or a documented scan bound is reached — with the cutoff, the scanned and accepted counts, and the stop decision written to the log so the outcome is explainable. + +## Story 2 — Cancel leaves Outlook usable + +As an Outlook user who presses Cancel in QuickFiler, I want the add-in to shut down completely and in order, so that my keyboard keeps working in Outlook and no leftover background work interferes with the next launch. + +- **Given** a QuickFiler session in which I have filed suggestions and used Undo, +- **When** I press Cancel, +- **Then** the background queue loader is stopped and awaited before any state it uses is released, the keyboard handlers are unregistered before the item rows are removed, the keyboard-active flag is reset, WebView2 focus is parked and any open breadcrumb dropdown is closed, the ribbon is released even if a teardown step fails so both ribbon buttons still work, typing in Outlook works immediately afterwards, and every teardown stage — including any failure — appears in the log. + +## Out of scope for these stories + +The breadcrumb WebView2 initialization failure observed in the same session is tracked separately as issue #792. diff --git a/docs/features/potential/2026-09-06-quickfiler-high-confidence-scan-bounds-configurable.md b/docs/features/potential/2026-09-06-quickfiler-high-confidence-scan-bounds-configurable.md new file mode 100644 index 000000000..06378cbdc --- /dev/null +++ b/docs/features/potential/2026-09-06-quickfiler-high-confidence-scan-bounds-configurable.md @@ -0,0 +1,37 @@ +# quickfiler-high-confidence-scan-bounds-configurable (Potential) + +- Date captured: 2026-09-06 +- Author: Dan Moisan +- Status: Draft + +## Problem / Why + +Issue #791 made the High Confidence first-batch deadline advisory and bounded the zero-acceptance scan with two gate-internal constants: `DefaultMaxScanWithoutAcceptance` (250 scored candidates) and `DefaultZeroAcceptanceCeiling` (120 seconds). Both values are engineering estimates derived from the observed scoring rate of roughly 2 to 3 items per second; they were not measured in a live session (see the #791 research artifact, Provenance and Unknowns). Following the #424 precedent, no settings surface was added. If live use shows the bounds are too tight (empty dialog on large low-yield views) or too loose (long waits before the dialog appears), the only remedy today is a code change. + +## Proposed Behavior + +Expose the two bounds as user settings alongside the existing High Confidence threshold, with the current constants as defaults: an `AppQuickFilerSettings` pair backed by `Settings.Designer.cs`, surfaced on the ribbon next to the threshold edit box, and passed into `QfcStreamingDequeueConfidenceGate` through the constructor seam that #791 already added. The launch log line already records both bounds, so tuning is observable without further logging changes. + +## Acceptance Criteria (early draft) + +- [ ] The scan cap and the zero-acceptance ceiling are persisted user settings with defaults 250 and 120 seconds, read by the datamodel's gate construction. +- [ ] Out-of-range or non-numeric ribbon input is rejected with the same guard pattern as the threshold edit box. +- [ ] The gate's launch log line reflects the configured values. +- [ ] Existing #791 gate tests remain green with the defaults; new tests cover the settings-to-gate plumbing. + +## Constraints & Risks + +- #424's ratified criterion explicitly refused a settings surface for the deadline; adopting one here reverses that decision and should be recorded as superseding it. +- Live measurement of the bounds should precede the change; the #791 live-Outlook runbook records the observed wait and is the natural input. +- Ribbon plumbing touches `RibbonViewer.cs`, `RibbonController.Intelligence.cs`, `AppQuickFilerSettings.cs`, `IAppQuickFilerSettings`, `app.config`, and `Settings.settings`. + +## Test Conditions to Consider + +- [ ] Unit coverage areas: settings round-trip; gate construction receives configured values; ribbon input validation. +- [ ] Integration scenarios: launch with modified bounds and confirm the launch log line. +- [ ] CLI/API examples: none. + +## Next Step + +- [ ] Promote to GitHub issue (feature request template) +- [ ] Create `docs/features/active/quickfiler-high-confidence-scan-bounds-configurable/` folder from the template diff --git a/docs/features/potential/promoted/2026-09-06-breadcrumb-webview2-init-fails-resource-not-in-correct-state.md b/docs/features/potential/promoted/2026-09-06-breadcrumb-webview2-init-fails-resource-not-in-correct-state.md new file mode 100644 index 000000000..66276fcfa --- /dev/null +++ b/docs/features/potential/promoted/2026-09-06-breadcrumb-webview2-init-fails-resource-not-in-correct-state.md @@ -0,0 +1,76 @@ +# breadcrumb-webview2-init-fails-resource-not-in-correct-state (Issue #792) + +- Date captured: 2026-09-06 +- Author: Dan Moisan +- Status: Promoted -> docs/features/active/breadcrumb-webview2-init-fails-resource-not-in-correct-state/ (Issue #792) + +> Automation note: Keep the section headings below unchanged; the promotion tooling maps each of them into the GitHub bug issue template. + +- Issue: #792 +- Issue URL: https://github.com/drmoisan/TaskMaster/issues/792 +- Last Updated: 2026-09-06 +## Summary + +The breadcrumb `CoreWebView2` initialization fails intermittently with HRESULT 0x8007139F ("The group or resource is not in the correct state to perform the requested operation"), logged by both `WebView2BreadcrumbHost` and `EfcFormController`. The failure is logged and swallowed; the session continues with a breadcrumb host that never initialized, and a later `BreadcrumbUiDispatcher` dispatch fails in the same session. Because the #677 keyboard-lock mechanism is WebView2 focus retention, a half-initialized WebView2 is a plausible contributor to the sporadic keyboard lock, but that link is unconfirmed. + +## Environment + +- OS/version: Windows 11 Pro 10.0.26200 +- Python version: n/a (C# / .NET Framework 4.8 VSTO add-in) +- Command/flags used: QuickFiler launched from the ribbon (High Confidence button); add-in loaded from `TaskMaster\bin\Debug` built 2026-09-06 08:51 from `7c8ac9ae` +- Data source or fixture: live Outlook Inbox view + +## Steps to Reproduce + +1. Launch QuickFiler from the ribbon several times in one Outlook session. +2. Inspect `TaskMaster\bin\Debug\logs\debug_.log` for `Breadcrumb CoreWebView2 initialization failed`. +3. Observe that the failure occurs on some launches (2 of 6 today: 08:55:22 and 10:06:51) and not others. + +Not reproducible on demand. + +## Expected Behavior + +WebView2 initialization either succeeds, or fails with a clear surfaced error and a defined fallback state that cannot retain keyboard focus. A failed initialization should be retried or the host disposed, not left half-constructed. + +## Actual Behavior + +Two ERROR lines per occurrence (`WebView2BreadcrumbHost - Breadcrumb CoreWebView2 initialization failed: ... (HRESULT: 0x8007139F)` and `EfcFormController - Breadcrumb WebView2 initialization failed: ...`), then normal operation continues. A `BreadcrumbUiDispatcher - Breadcrumb UI dispatch failed.` error followed at 09:01:56 in the same session. + +## Logs / Screenshots + +- [x] Attached minimal logs or screenshot +- Snippet (`debug_2026-09-06.log`): + +``` +2026-09-06 08:55:22,227 [VSTA_Main] ERROR QuickFiler.Viewers.WebView2BreadcrumbHost - Breadcrumb CoreWebView2 initialization failed: ... (HRESULT: 0x8007139F) +2026-09-06 08:55:22,286 [VSTA_Main] ERROR QuickFiler.Controllers.EfcFormController - Breadcrumb WebView2 initialization failed: ... (HRESULT: 0x8007139F) +2026-09-06 09:01:56,237 [VSTA_Main] ERROR QuickFiler.Viewers.BreadcrumbUiDispatcher - Breadcrumb UI dispatch failed. +2026-09-06 10:06:51,594 [VSTA_Main] ERROR QuickFiler.Viewers.WebView2BreadcrumbHost - Breadcrumb CoreWebView2 initialization failed: ... (HRESULT: 0x8007139F) +2026-09-06 10:06:51,661 [VSTA_Main] ERROR QuickFiler.Controllers.EfcFormController - Breadcrumb WebView2 initialization failed: ... (HRESULT: 0x8007139F) +``` + +## Impact / Severity + +- [ ] Blocker +- [ ] High +- [x] Medium +- [ ] Low + +Medium: the breadcrumb folder selector is unavailable on affected launches, and the half-initialized control is a candidate contributor to the sporadic Outlook keyboard lock tracked under the sibling QuickFiler Cancel-teardown issue filed the same day. + +## Suspected Cause / Notes + +- 0x8007139F (`ERROR_INVALID_STATE`) from `CoreWebView2Environment`/`EnsureCoreWebView2Async` typically indicates the control was initialized while its handle or parent was not yet in a valid state, or a second initialization was attempted against a control already mid-initialization or disposed. Both `WebView2BreadcrumbHost` and `EfcFormController` log the same failure, suggesting the initialization is attempted from two paths. +- Files to inspect: `QuickFiler/Viewers/WebView2BreadcrumbHost.cs`, `QuickFiler/Controllers/EfcFormController.cs` (breadcrumb initialization), `QuickFiler/Viewers/BreadcrumbUiDispatcher.cs`, and the pooled-viewer handler-retention history in `docs/features/potential/promoted/2026-08-07-webview2breadcrumbhost-handler-retention-pooled-viewer.md`. +- Related: #677 (keyboard hook leak; WebView2 focus retention mechanism), the sibling potential entry `2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects.md`. + +## Proposed Fix / Validation Ideas + +- [ ] Unit coverage areas: initialization state machine of `WebView2BreadcrumbHost` (single initialization, guard against re-entry, disposed-host guard, failure leaves a defined non-focusable state). +- [ ] Integration scenario to retest: repeated QuickFiler launches in one session; confirm no `0x8007139F` and that a failed initialization cannot retain focus. +- [ ] Manual verification notes: live-Outlook log review across several launches. + +## Next Step + +- [x] Promote to GitHub issue (bug-report template) +- [ ] Move to active fix folder / branch diff --git a/docs/features/potential/promoted/2026-09-06-gate-scan-bound-log-line-content-unasserted.md b/docs/features/potential/promoted/2026-09-06-gate-scan-bound-log-line-content-unasserted.md new file mode 100644 index 000000000..e610580f9 --- /dev/null +++ b/docs/features/potential/promoted/2026-09-06-gate-scan-bound-log-line-content-unasserted.md @@ -0,0 +1,63 @@ +# gate-scan-bound-log-line-content-unasserted (Issue #794) + +- Date captured: 2026-09-06 +- Author: Dan Moisan +- Status: Promoted -> docs/features/active/gate-scan-bound-log-line-content-unasserted/ (Issue #794) + +> Automation note: Keep the section headings below unchanged; the promotion tooling maps each of them into the GitHub bug issue template. + +- Issue: #794 +- Issue URL: https://github.com/drmoisan/TaskMaster/issues/794 +- Last Updated: 2026-09-06 +## Summary + +The #791 fix added three log lines to `QfcStreamingDequeueConfidenceGate` (launch, zero-acceptance checkpoint, scan bound reached). The launch and checkpoint lines are content-asserted by tests; the scan-bound line emitted by `LogScanBoundReached` is not asserted by any test, so a regression in its content (the `Bound=` value or the `Decision=stop` token) would pass the suite. AC1 of #791 requires the bound decision to be logged. + +## Environment + +- OS/version: Windows 11 Pro 10.0.26200 +- Python version: n/a (C# / .NET Framework 4.8 VSTO add-in) +- Command/flags used: static review of branch bug/quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791 at 59536368 +- Data source or fixture: none (code review finding N3 in code-review.2026-09-06T15-31.md) + +## Steps to Reproduce + +1. Search QuickFiler.Test for the literals `scan bound reached`, `Bound=`, and `Decision=stop`: zero matches. +2. Compare with the launch and checkpoint lines, which `QfcStreamingDequeueConfidenceGateTests.Part4.cs` asserts through the injected `debugLog` delegate. + +## Expected Behavior + +A test drives the gate to `ScanCapReached` (item cap and time ceiling) and asserts the emitted line carries the cutoff, scanned and accepted counts, the bound that fired, and the stop decision. + +## Actual Behavior + +The `ScanCapReached` tests assert the stop reason and that no further take occurs, but not the log line content. + +## Logs / Screenshots + +- [ ] Attached minimal logs or screenshot +- Snippet: none; static finding. + +## Impact / Severity + +- [ ] Blocker +- [ ] High +- [ ] Medium +- [x] Low + +Low: a diagnostics-only gap; the behavior itself is tested. + +## Suspected Cause / Notes + +- Test-coverage gap left by #791; a two-line addition to the existing cap and ceiling tests in `QfcStreamingDequeueConfidenceGateTests.Part4.cs` closes it (the file has headroom under the 500-line ceiling; `.Part2.cs` does not). + +## Proposed Fix / Validation Ideas + +- [ ] Unit coverage areas: `DequeueAsync_ZeroAcceptedAndCapReached_LogsBoundDecision`, `DequeueAsync_ZeroAcceptedAndCeilingReached_LogsBoundDecision` asserting via the `debugLog` delegate. +- [ ] Integration scenario to retest: none. +- [ ] Manual verification notes: none. + +## Next Step + +- [x] Promote to GitHub issue (bug-report template) +- [ ] Move to active fix folder / branch diff --git a/docs/features/potential/promoted/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects.md b/docs/features/potential/promoted/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects.md new file mode 100644 index 000000000..8b9276726 --- /dev/null +++ b/docs/features/potential/promoted/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects.md @@ -0,0 +1,105 @@ +# quickfiler-high-confidence-cancel-teardown-and-deadline-defects (Issue #791) + +- Date captured: 2026-09-06 +- Author: Dan Moisan +- Status: Promoted -> docs/features/active/quickfiler-high-confidence-cancel-teardown-and-deadline-defects/ (Issue #791) + +> Automation note: Keep the section headings below unchanged; the promotion tooling maps each of them into the GitHub bug issue template. + +- Issue: #791 +- Issue URL: https://github.com/drmoisan/TaskMaster/issues/791 +- Last Updated: 2026-09-06 +## Summary + +Two defects observed while running QuickFiler in High Confidence mode on 2026-09-06 against the build of `7c8ac9ae`. (1) A High Confidence run whose first 12 seconds of scanning finds no item at or above the cutoff opens an empty dialog, and because scan order follows the Explorer view the same view produces the same empty dialog on every rerun. (2) The Cancel teardown does not shut QuickFiler down cleanly: the background queue loader outlives Cancel and crashes on fields that cleanup has already nulled, the keyboard-active flag and WebView2 focus are never reset on the Cancel path, the teardown chain has no `try`/`finally`, and the whole path emits no log output, which left a 37 minute unexplained gap during which the Outlook keyboard was locked. + +## Environment + +- OS/version: Windows 11 Pro 10.0.26200 +- Python version: n/a (C# / .NET Framework 4.8 VSTO add-in) +- Command/flags used: QuickFiler launched from the ribbon High Confidence button; `HighConfidenceThreshold` at the designer default 0.9 (never changed in any `user.config` on the machine); `HighConfidenceModeEnabled` toggled by the ribbon launch path +- Data source or fixture: live Outlook Inbox view; add-in loaded from `TaskMaster\bin\Debug` built 2026-09-06 08:51 from `7c8ac9ae` + +## Steps to Reproduce + +Defect 1 (deadline policy, deterministic for a given view): +1. Arrange an Explorer view whose first roughly 40 items in view order all score below 900 per-mille while later items score above it. +2. Launch QuickFiler via the High Confidence ribbon button. +3. Observe the dialog open with zero rows after roughly 20 seconds. +4. Cancel and relaunch via the same button; observe the same empty dialog. + +Defect 2 (Cancel teardown, sporadic): +1. Launch QuickFiler via the High Confidence ribbon button and file one round of suggestions. +2. File a second round, then press Undo repeatedly (24 undo clicks were logged between 09:04:05 and 09:05:53). +3. Press Cancel. +4. Observe the Outlook keyboard is unusable in the native Outlook window. In a separate run the same Cancel left the background loader running until it crashed 4 seconds after the next launch. + +## Expected Behavior + +- A High Confidence run that has scored items but found none at or above the cutoff within the first-batch deadline keeps scanning until the first acceptance or until the candidate queue is exhausted, subject to a hard cap on scanned items, and reports progress. It never opens an empty dialog while unscanned candidates remain. +- The cutoff in effect and the scan progress are logged at launch and at every deadline decision. +- Cancel performs a complete, ordered teardown: cancellation is signalled, the background loader is stopped and awaited before any datamodel field is nulled, form and item keyboard handlers are unregistered before the item rows are removed, the keyboard-active flag is reset, WebView2 focus is parked and any open breadcrumb dropdown is cancelled (the same routine that `FormViewer_Deactivated` runs), and the ribbon release callback runs even if an earlier step throws. +- Every stage of the Cancel teardown writes a log line through the existing log4net pattern, including any exception, so a future sporadic occurrence can be read from the log. + +## Actual Behavior + +- `QfcStreamingDequeueConfidenceGate.DequeueAsync` returns `DeadlineExpired` with an empty accepted list when `accepted.Count == 0` after 12 seconds, and `QfcHomeController.RunAsync` loads zero rows. Three runs today logged `First-batch deadline expired [DequeueAsync] Accepted=0 Scanned=38|44|42 Deadline=00:00:12` (09:43:54, 09:45:36, 10:08:06). Scores were real, not zero: those runs peaked at 928 and 960 after the deadline had already expired, while accepting runs peaked at 997 to 1000. The cutoff (900) is never logged. +- After Cancel, `QfcDatamodel.Cleanup()` cancels the token and calls `worker.CancelAsync()` but does not await `LoadRemainingEmailsToQueueAsync`, then nulls `_moveMonitor`, `_globals`, `_masterQueue`, and `_worker`. The still-running loader then throws at `QfcDatamodel.cs:355-358` (`new QfcRemainingQueueAdmission(_masterQueue.AddLast, _moveMonitor.HookItem, ...)`): `ERROR QfcDatamodel - LoadRemainingEmailsToQueue Error. Delegate to an instance method cannot have null 'this'.` followed by `Error in Worker_DoWork` (log 2026-09-06 10:08:10.910 and 10:08:10.985, the last two lines of the file). +- `ActionCancelAsync` (`QfcFormController.EventHandlers.cs:84-93`) calls `_parent?.TokenSource?.Cancel()`, awaits the UI sync context, hides the form, then `_groups?.Cleanup()` and `Cleanup()`. It does not reset `KbdActive` (the OK path does, `EventHandlers.cs:125-128`), does not call `ParkFocusOffWebView2()` or `CancelBreadcrumbSelector()` (both exist only in `QfcFormController.Deactivate.cs:26-58`, wired to `FormDeactivated`, which the Cancel path unsubscribes), and has no `try`/`finally`. `ButtonCancel_Click` is `async void`, so an exception escaping `ActionCancelAsync` is lost. +- `QfcFormController.Cleanup()` (`SetupDisposal.cs:213-261`) calls `UnregisterFormEventHandlers()` after `_groups.Cleanup()` has already removed the item rows from the table layout, so the recursive `Controls.ForAllControls` unsubscribe no longer reaches the item controls' `PreviewKeyDown`/`KeyDown` subscriptions added at `SetupDisposal.cs:156-168`. The guard at `:180-183` also returns early when `_formViewer?.Controls` or `_parent?.KeyboardHandler` is already null. +- `QfcHomeController.Cleanup()` (`QfcHomeController.cs:370-379`) calls `_datamodel.Cleanup()` and then `ParentCleanup.Invoke()` with no `try`/`finally`; if the datamodel cleanup throws, `RibbonController.ReleaseQuickFiler()` never runs, `_quickFilerLoaded` stays true, and both ribbon buttons become no-ops. `_tokenSource` is never disposed and `Worker_RunWorkerCompleted` is never detached. +- The Cancel path, `QfcDatamodel.Cleanup()`, and `ParkFocusOffWebView2()` contain no logging. After the 09:05:53 undo burst the log is silent for 37 minutes 39 seconds until the next launch at 09:43:32 (no restart; Outlook restarted only at 09:53:24). + +## Logs / Screenshots + +- [x] Attached minimal logs or screenshot +- Snippet (from `TaskMaster\bin\Debug\logs\debug_2026-09-06.log`): + +``` +2026-09-06 09:43:54,214 [44] DEBUG QfcStreamingDequeueConfidenceGate - First-batch deadline expired [DequeueAsync] Accepted=0 Scanned=38 Deadline=00:00:12 +2026-09-06 09:45:36,727 [53] DEBUG QfcStreamingDequeueConfidenceGate - First-batch deadline expired [DequeueAsync] Accepted=0 Scanned=44 Deadline=00:00:12 +2026-09-06 10:08:06,149 [29] DEBUG QfcStreamingDequeueConfidenceGate - First-batch deadline expired [DequeueAsync] Accepted=0 Scanned=42 Deadline=00:00:12 +2026-09-06 10:08:10,910 [5] ERROR QuickFiler.Controllers.QfcDatamodel - LoadRemainingEmailsToQueue Error. + Delegate to an instance method cannot have null 'this'. + at System.MulticastDelegate.CtorClosed(Object target, IntPtr methodPtr) + at QuickFiler.Controllers.QfcDatamodel.d__41.MoveNext() ... QfcDatamodel.cs:line 355 + at QuickFiler.Controllers.QfcDatamodel.d__40.MoveNext() ... QfcDatamodel.cs:line 330 +2026-09-06 10:08:10,985 [5] ERROR QuickFiler.Controllers.QfcDatamodel - Error in Worker_DoWork Delegate to an instance method cannot have null 'this'. +``` + +Timeline evidence (same log): launches at 08:52:09 (accepted, rows at 08:53:39), 09:43:32 (Accepted=0), 09:45:14 (Accepted=0), 10:04:26 (accepted), 10:05:12 (accepted, relaunch 46 s after the previous), 10:07:45 (Accepted=0). Undo burst 09:04:05 to 09:05:53, then no log output until 09:43:32. + +## Impact / Severity + +- [ ] Blocker +- [x] High +- [ ] Medium +- [ ] Low + +High: the deadline defect makes High Confidence mode unusable for any view whose top-scoring items are not near the front, with no message and no recovery other than filing items some other way. The teardown defect can leave the whole Outlook keyboard unusable until Outlook is restarted, and the surviving background loader crashes against the next launch's state. + +## Suspected Cause / Notes + +- Gate loop `QfcStreamingDequeueConfidenceGate.cs:168-237`: the deadline is checked only while `accepted.Count == 0`; `scanned++` at `:205` runs only after `_scoreLoader` returns, so `Scanned=N Accepted=0` means N real scores all below `_cutoff` (`:129`, per-mille). Scan order is `_masterQueue.TryTakeFirst()` (`QfcDatamodel.QueueProcessing.cs:185`), populated from the Explorer view (`QfcDatamodel.FrameBuilding.cs:13-67`), so the outcome is a function of view order and scoring throughput (about 2 to 3 items per second observed). Rejected items are dropped from the queue for the session (`:211-222`). +- The 12 second first-batch deadline was introduced by #424 and adjusted by #446 and #608; those changes handled the post-UI iteration and the undersized-batch cases, not the zero-accepted first batch. +- `Worker_DoWork` (`QfcDatamodel.cs:175-213`) is `async void`; `BackgroundWorker.IsBusy` goes false at its first await while production continues, and `LoadRemainingEmailsToQueueAsync` observes the token only at `:322` and `:324`. +- Keyboard mechanism: no `SetWindowsHookEx`, `AddMessageFilter`, or `KeyPreview` exists anywhere in the repo (confirmed again today). #677 identified WebView2 focus retention and an open breadcrumb `ToolStripDropDown` as the mechanism and fixed it on the `Form.Deactivate` path only. `AlwaysOnKeyActionsAsync` (`KeyboardHandler.cs:155-160`) suppresses keys regardless of `KbdActive`. +- The breadcrumb WebView2 failed to initialize twice today (`WebView2BreadcrumbHost - Breadcrumb CoreWebView2 initialization failed ... 0x8007139F` at 08:55:22 and 10:06:51). That is a separate defect, filed as its own potential entry, and is not in scope here. +- Related closed issues: #424, #446, #608 (deadline lineage), #677 (Deactivate focus fix), #731 (controller lifecycle disposal), #737 (breadcrumb keyboard navigation). All are closed and their fixes are on `7c8ac9ae`. +- Unknown: whether the 09:05 keyboard lock cleared on Escape, on focus change, or only on restart. The user could not reproduce it. + +## Proposed Fix / Validation Ideas + +- [ ] Unit coverage areas: gate behavior when the deadline expires with zero accepted and candidates remain (continue, hard cap, exhaustion); cutoff and progress logging; `ActionCancelAsync` ordering (token, loader awaited, handlers unregistered before rows removed, `KbdActive` reset, focus parked, breadcrumb selector cancelled, release callback invoked under exception); `QfcDatamodel.Cleanup()` awaiting the loader before nulling fields; `QfcHomeController.Cleanup()` invoking `ParentCleanup` under a `finally`. +- [ ] Integration scenario to retest: High Confidence launch against a view whose first 40 items score below cutoff; Cancel after an undo burst; relaunch after Cancel. +- [ ] Manual verification notes: record a live-Outlook evidence note as was done for #677; confirm the new Cancel-path log lines appear and that no `null 'this'` error follows a Cancel. + +## Acceptance Criteria + +- [ ] AC1: A High Confidence run that has found no item at or above the cutoff when the first-batch deadline expires continues scanning until the first acceptance or until the candidate queue is exhausted, subject to a hard cap on items scanned, and never opens an empty dialog while unscanned candidates remain. The cutoff in effect and the scanned/accepted counts are logged at launch and at each deadline decision. Covered by deterministic MSTest regression tests using a fake time provider. +- [ ] AC2: The Cancel teardown completes cleanly and in order: the background loader is stopped and awaited before any datamodel field is nulled; form and item keyboard handlers are unregistered before item rows are removed; the keyboard-active flag is reset; WebView2 focus is parked and any open breadcrumb dropdown is cancelled on the Cancel path; the ribbon release callback runs under a `finally`; and every stage, including any exception, is logged. Covered by deterministic MSTest regression tests, plus a manual live-Outlook evidence note. + +## Next Step + +- [x] Promote to GitHub issue (bug-report template) +- [x] Move to active fix folder / branch diff --git a/docs/features/potential/promoted/2026-09-06-quickfiler-teardown-disposed-tokensource-and-unprotected-release-link.md b/docs/features/potential/promoted/2026-09-06-quickfiler-teardown-disposed-tokensource-and-unprotected-release-link.md new file mode 100644 index 000000000..de5d4eedd --- /dev/null +++ b/docs/features/potential/promoted/2026-09-06-quickfiler-teardown-disposed-tokensource-and-unprotected-release-link.md @@ -0,0 +1,68 @@ +# quickfiler-teardown-disposed-tokensource-and-unprotected-release-link (Issue #793) + +- Date captured: 2026-09-06 +- Author: Dan Moisan +- Status: Promoted -> docs/features/active/quickfiler-teardown-disposed-tokensource-and-unprotected-release-link/ (Issue #793) + +> Automation note: Keep the section headings below unchanged; the promotion tooling maps each of them into the GitHub bug issue template. + +- Issue: #793 +- Issue URL: https://github.com/drmoisan/TaskMaster/issues/793 +- Last Updated: 2026-09-06 +## Summary + +Two residual teardown defects found by the #791 feature review (code-review.2026-09-06T15-31.md findings N1 and N2). (1) `QfcHomeController.Cleanup()` now disposes `_tokenSource` but does not null the field, and the same `CancellationTokenSource` is shared with the datamodel and the form controller, whose `Cleanup()` and `QuiesceLoaderAsync()` both call `_tokenSource?.Cancel()`; a call after disposal throws `ObjectDisposedException`. (2) `QfcFormController.Cleanup()` invokes `_parentCleanup?.Invoke()` as its last statement with no `try`/`finally`, so a throw from the viewer dispose immediately before it skips the ribbon release callback, contradicting the "release invoked exactly once regardless of which stage threw" invariant recorded in the #791 spec. + +## Environment + +- OS/version: Windows 11 Pro 10.0.26200 +- Python version: n/a (C# / .NET Framework 4.8 VSTO add-in) +- Command/flags used: static review of branch bug/quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791 at 59536368 +- Data source or fixture: none (code review finding) + +## Steps to Reproduce + +1. Read QuickFiler/Controllers/QfcHomeController.cs `Cleanup()` (around line 389 on the #791 branch): `_tokenSource?.Dispose()` with no `_tokenSource = null;`. +2. Read QuickFiler/Controllers/QfcDatamodel.cs `Cleanup()` and QfcDatamodel.QueueProcessing.cs `QuiesceLoaderAsync()`: both open with `_tokenSource?.Cancel()` on the shared source. +3. Read QuickFiler/Controllers/QfcFormController.SetupDisposal.cs `Cleanup()` (line 251 disposes the viewer; line 259 invokes `_parentCleanup`), with no `finally`. + +## Expected Behavior + +- Disposing the shared token source cannot cause a later `Cancel()` on a still-referenced copy to throw; the field is nulled after disposal, or ownership of the source is single and the sharers hold only the token. +- The ribbon release callback runs from `QfcFormController.Cleanup()` under a `finally`, so a throwing viewer dispose cannot skip it. + +## Actual Behavior + +- `Cleanup()` disposes the source and leaves the field set; a second `Cancel()` through any sharer throws `ObjectDisposedException`. Today this is unreachable only because `QfcFormController.Cleanup()` nulls `_parentCleanup` and `_parent` first and `RibbonController` never calls `QfcHomeController.Cleanup()` directly. Before #791 the source was never disposed, so the throw is newly possible. +- `_parentCleanup?.Invoke()` at SetupDisposal.cs:259 is unprotected; that file was an explicit non-goal of #791 (AC5), which is why the fix was not applied there. + +## Logs / Screenshots + +- [ ] Attached minimal logs or screenshot +- Snippet: none; static finding. Source: docs/features/active/2026-09-06-quickfiler-high-confidence-cancel-teardown-and-deadline-defects-791/code-review.2026-09-06T15-31.md (N1, N2). + +## Impact / Severity + +- [ ] Blocker +- [ ] High +- [x] Medium +- [ ] Low + +Medium: both are latent today, but each breaks the single-release-callback invariant #791 established once any future caller reaches the unguarded paths. + +## Suspected Cause / Notes + +- N1 was introduced by #791's `QfcHomeController.Cleanup()` hardening (dispose added without nulling); the sharing of one `CancellationTokenSource` across three controllers predates it. +- N2 predates #791 and sits in QfcFormController.SetupDisposal.cs, which #791 kept out of scope. +- Related: #791, #731 (controller lifecycle disposal). + +## Proposed Fix / Validation Ideas + +- [ ] Unit coverage areas: `QfcHomeController.Cleanup()` called twice does not throw; a sharer's `Cancel()` after home-controller cleanup does not throw; `QfcFormController.Cleanup()` invokes `_parentCleanup` exactly once when viewer dispose throws. +- [ ] Integration scenario to retest: Cancel then immediate relaunch from the ribbon. +- [ ] Manual verification notes: none beyond the #791 runbook. + +## Next Step + +- [x] Promote to GitHub issue (bug-report template) +- [ ] Move to active fix folder / branch