fix(#505): synchronous getPressed via toggle-state coordinator, awaited toggles, and guarded engine dereferences - #526
Merged
Conversation
…s deref to #524 The atomic-executor deferred plan task P6-T1 item 2 to the orchestrator because the promotion MCP tools are not in its tool set and gh issue create is blocked by enforce-promotion-mcp-only.ps1. Re-verified independently that no existing issue covered it, then ran the lifecycle: new_potential_bug_entry -> potential_to_issue (bug, full-bug), which created #524. full-bug rather than minor-audit because the affected-site list is indicative rather than exhaustive, so the production-file budget cannot be bounded until a full enumeration is done. All four out-of-scope research items are now dispositioned (#504, #524, #511, plus one resolved during authoring), satisfying AC-17. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… delivery Three reusable findings from this run: - The msbuild /t:Build analyzer gate is vacuous by construction after any earlier build of the same tree (18 CoreCompile skips, 0 csc.exe, EXIT 0). Measured at preflight. Requires /t:Rebuild plus a csc.exe-count acceptance. - An aggregate 9-assembly vstest run that aborts with 'Test host process crashed' reports no verdict at all; per-assembly /InIsolation is the decisive check and showed 6435 passed / 0 failures. - A stale orchestrator checkpoint is not evidence of a dead delegation, because executors do not own the checkpoint. Never launch a second executor into a live worktree; recover by re-verifying the committed tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…505 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…se review actions Feature review returned PASS with 0 blocking findings on all three artifacts, so the exit gate is satisfied and no remediation cycle is required. Dispositions the two non-gating pre-merge actions the review recommended: - CR-1 (Major, non-blocking): EngineToggleStateCoordinator.ApplyPrimeAsync can let an in-flight prime overwrite a fresher toggle-written cache value, and the retained prime marker then blocks any re-prime. Promoted as #525 together with CR-2 (canceled prime silently blocks re-priming) and CR-3 (two uncovered defensive-guard lines), which sit in the same type and the same test seam. Promoted rather than fixed: it is display-only, self-correcting on the next click, strictly better than the merge base, and violates no acceptance criterion. The tradeoff is stated explicitly in the evidence artifact. - CR-4 (Minor): corrected the stale issue.md Delivery Note bullet that still called the item-2 promotion deferred; it now cites #524, and a new point 4 records #525. No production file was touched, so the completed audit remains valid. AC-22 remains PENDING-MANUAL by design; its live-Outlook checklist is committed for the maintainer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes three coupled defects in the Spam Config and Triage Config ribbon submenu callbacks, delivered as one change because all three land in
TaskMaster/Ribbon/RibbonViewer.EngineCommands.csand #518's call sites overlap the exact methods #505 and #506 rewrite.SpamBayesEnabled_GetPressedandTriageEnabled_GetPressedwere declaredasync Task<bool>, but Office'sgetPressedcontract requires a synchronousbool GetPressed(Office.IRibbonControl control). VSTO ignores a signature mismatch silently, so both toggle buttons never reflected real engine activation state.SpamBayesEnabled_ClickandTriageEnabled_Clickwerevoidmethods whose body was an unawaitedToggleEngineAsync(...). The returnedTaskwas discarded, so the toggle had no ordering guarantee and any fault vanished into an unobserved task.Controller.Engines.<member>dereferences had no null guard. Bug: ribbon-controller-engines-null-unsafe #507 (merged) made the propertyGlobals?.Engines, which returnsnullinstead of throwing — relocating theNullReferenceExceptionto the call site rather than eliminating it.These are causally coupled: a synchronous
GetPressedneeds cached state, that cache is only correct if the toggle is awaited and refreshes it in a defined order, and both must respect the #507 null contract. Fixing them separately would have meant three passes over one file with three fan-in conflicts.Closes #505
Closes #506
Closes #518
Design: two guard shapes, because there are two different semantics
The single most important decision in this change, and the one worth reviewing first.
The obvious approach — route all ten sites through the existing
RunEngineCommandAsync/EngineReadinessGatefrom #503 — is wrong for four of them. That gate is keyed onInboxEngines, andAppItemEngines.InitAsyncfilters a disabled engine out ofInboxEnginesentirely. Gating the enable/disable toggle on it would mean a disabled engine could never be re-enabled: the gate would refuse the only command that could turn it back on.So the ten sites split by what actually backs them:
SpamBayesEnabled_Click/_GetPressed,TriageEnabled_Click/_GetPressed)ToggleEngineAsync/EngineActiveAsyncreadGlobals.AF.Manager.Configuration)EngineToggleStateCoordinatorShowDiskDialogx4,ShowSaveInfox2)InboxEngines(they no-op without the key)RunEngineCommandAsyncgateFor the six command sites the readiness gate is semantically exact, so they reuse the #503 mechanism plus six new
EngineCommandCatalogentries and six matchinggetEnabledattributes inRibbonExplorer.xml(an existing set-equality test forces those two to change atomically). The two toggle controls arecheckBoxelements and deliberately stay out ofEngineCommandCatalog, which an existing test requires to contain only buttons.The ordering invariant
EngineToggleStateCoordinator.ExecuteToggleAsyncperforms, in exactly this order:Updating the cache before invalidating is load-bearing: Office answers an invalidation by re-querying
getPressed, so invalidating first would be answered from stale state. The test pins this by probingGetPressedfrom inside the invalidation sink — the exact instant Office would re-query — so the invariant is verified as observable behavior rather than as a mock-call sequence.GetPresseditself is a lock-freeConcurrentDictionaryread that defaults tofalseand starts a background prime on first miss. It never blocks the STA:.Result/.Wait()/GetAwaiter().GetResult()are prohibited in the new code and their absence is gated, because blocking would deadlock against the capturedWindowsFormsSynchronizationContextand freeze the menu during the configuration disk load.The
async voidboundaryBoth
*_Clickhandlers are now single awaited expressions intoHandleEngineToggleClickAsync, whose returned task cannot fault: the type's onlycatchwrapsExecuteToggleAsyncand routes tologErrorwithout rethrowing. The prime path is observed by a continuation that readsTask.Exception, so noUnobservedTaskExceptionremains. This matches the shape the sibling*SaveNetwork_Click/*SaveLocal_Clickhandlers in the same regions already used.Verified counts
RibbonViewer.EngineCommands.csheld 11Engines.references at the merge base.TestSpam_Clickwas already gated (its dereference sits inside a lambda passed toRunEngineCommandAsync), leaving 10 unguarded at lines 120, 123, 126, 129, 132, 189, 192, 195, 198, 201. This was derived independently and matches the restated table in the #518 comment exactly.At head: 10 newly guarded, 1 pre-existing gate, 0 unguarded production dereferences.
TestSpam_Clickis byte-identical.RibbonController.Intelligence.cshas a zero-line diff, so #507'sGlobals?.Enginesis intact and was not reverted.Two corrections to the generated PR context
artifacts/pr_context.summary.txtcontains two lines that are false for this branch; recording them so a reader does not rely on them:ghis at/c/Program Files/GitHub CLI/gh, authenticated asdrmoisan. The autoclose section consequently reports "None (GitHub CLI unavailable)" and lists stale author-asserted candidates (Bug: quickfiler-high-confidence-queue-init-stall #424, Bug: ribbon-engine-readiness-guard #503, Bug: ribbon-dead-callback-names #504, Bug: ribbon-controller-engines-null-unsafe #507, Bug: wpf-dispatcher-yield-test-order-dependent #508, Bug: winformspumphost-tests-load-flaky-visible-window #511) inherited from earlier branches. The actual closing set for this PR is exactly Bug: ribbon-async-getpressed-signature #505, Bug: ribbon-toggle-engine-fire-and-forget #506, Bug: Bug: ribbon-engines-callers-unguarded-null-deref #518.+1650/-21): 7 production.cs/.csproj/.xmland 6 test files. The classifier bucketed the entire C# diff as docs.Testing
Red-first per the
CLAUDE.mdbugfix workflow: regression tests R1-R5 were written and demonstrated failing against pre-fix source before any fix landed, each with a captured red-run artifact.EngineToggleStateCoordinatorTests.cs(459 lines) — cached-read semantics, the ordering invariant, fault observation at the boundary, prime lifecycle, null-engines degradation.EngineToggleCatalogTests.cs,RibbonViewerEngineCallbackShapeTests.cs— the callback signatures are pinned by reflection, including anAsyncStateMachineAttributecheck, so a regression toasync Task<bool>fails the build instead of failing silently in Office.MSTest + Moq (strict) + FluentAssertions. Determinism comes from
TaskCompletionSource, not sleeps: noThread.Sleep,Task.Delay, wall-clock reads, temp files,Form,MessageBox,BackgroundWorker, or message pump in any new test.Toolchain
Independently re-verified by the orchestrator against the committed tree, in addition to the executor's own fingerprint-proven single pass:
csharpier check ./t:Rebuild)csc.exeinvocations/t:Rebuild)The 6 warnings are byte-identical to the merge base (2x pre-existing
CS2002inUtilitiesCS.Test, 4 System.Reactivepackages.configadvisories). Zero new diagnostics.Two notes on how those numbers were obtained, both of which produced misleading results first:
/t:Rebuild, not/t:Build. MSBuild's legacy up-to-date check is timestamp-based and does not invalidate on a/p:change, so a/t:Buildanalyzer run following any earlier build skipsCoreCompileon all 18 projects and returnsEXIT 0having compiled nothing (measured: 18 skips, 0csc.exe). The gate now asserts a non-zerocsc.execount as a non-vacuity proof.Test host process crashedat differing points (1476 and 1840 tests in), reportingTotal tests: Unknown. Per-assembly/InIsolationruns resolved it — every assembly green. This is pre-existing load-driven instability in theQuickFiler.TestWinFormsPumpHostfamily (Bug: winformspumphost-tests-load-flaky-visible-window #511);QuickFiler.csprojdoes not referenceTaskMaster, so this change cannot reach it. No test was weakened and no retry or sleep was added.The type-check step deliberately uses CI's command and omits
/p:Nullable=enable, whichCLAUDE.mdprescribes. That variant is defective and separately tracked as #522: this repo uses per-file#nullable enableopt-in, and forcing the flag reports 200-414 errors that are red onmainregardless of any change..github/workflows/ci.ymlomits it for the same reason. The deviation is documented in the feature'sspec.md.Coverage
Repo-wide line 85.89% -> 85.92%, branch 79.34% -> 79.36%. New non-exempt types:
EngineToggleStateCoordinator.cs99.15% (133/135) andEngineToggleCatalog.cs100.00%, against the 90% new-code floor.RibbonViewerandRibbonControllercarry[ExcludeFromCodeCoverage]under the ratified VSTO/COM ribbon-handler exemption, so the modified handlers add little coverage surface and a nearly flat repo-wide figure is the expected outcome, not a regression. The exemption was neither removed nor widened; it was empirically confirmed still honored (both files are absent from the Cobertura document). All extracted logic is host-neutral and tested.Review artifacts
policy-audit,code-review, andfeature-audit(all2026-08-08T21-59) are committed under the feature folder. All three: PASS, 0 blocking findings.Twenty-two of 23 acceptance criteria are delivered and checked off. AC-22 is PENDING-MANUAL by design: VSTO callback binding cannot be observed outside a live Outlook process — which is precisely why #505 went undetected — so a maintainer checklist is committed at
evidence/manual-verification/ac22-checklist.2026-08-08T21-44.mdrather than the criterion being checked off on the strength of unit tests.Known limitation shipped deliberately
The review raised one Major, non-blocking finding, tracked as issue #525 rather than fixed here: in
EngineToggleStateCoordinator.ApplyPrimeAsync, an in-flight prime that read engine state before a toggle flipped it can land afterwards and overwrite the fresher toggle-written value, and the retained prime marker then prevents a re-prime — so a stale toggle display can persist until the next click.Stated plainly rather than buried: this ships a known display-only race in newly added code. It was promoted rather than fixed because it is display-only (the underlying configuration is always correct), the window is narrow, it self-corrects on the next click, it violates no acceptance criterion, and it is strictly better than the merge-base behavior in which the toggles never reflected engine state at all. The fix is one line (
TryAddinstead of the indexer, invalidating only on a successful add) plus tests at an already-established seam; pull it forward into this PR if you would rather not merge with it open.Out of scope
Held to three issues. Everything else found was promoted, not fixed:
Globalsdereferences inRibbonController.Intelligence.cs, the same defect class as Bug: Bug: ribbon-engines-callers-unguarded-null-deref #518 but at different call sites.onActioncallbacks inRibbonExplorer.xml.WinFormsPumpHostload flakiness.Issue #522 (the defective nullable type-check command) is documentation-only and separately tracked; it is not addressed here.
User-visible change to call out
The six Spam/Triage save-options buttons (Network, Local, Current Location) now render disabled until their engine finishes loading, instead of being always enabled and silently doing nothing. They re-enable automatically after the post-load refresh.
🤖 Generated with Claude Code