14 KiB
WaitAsync / WaitForAttribute compile-surface & cross-process mirror gaps — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Make the already-shipped Attributes.WaitAsync/WaitForAsync (template/call scripts) and
Route.To(...).WaitForAttribute(...) (inbound scripts) actually usable, by closing the
compile-/analysis-surface mirror gaps that currently reject them at validation time, and to harden the
parity test so this class of bug can't recur.
Background (why this exists): The WaitAsync feature (spec docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md
- deferred-items
docs/plans/2026-06-17-waitfor-deferred-items.md) is merged toorigin/mainand the runtime works. But scripts that call it fail deployment because the compile-validation layer — fake "compile surface" classes that scripts are Roslyn-compiled against during validation — was never updated to mirror the new methods. This was hit live: deploying a template script callingAttributes.WaitAsync(...)to wonder-app-vd03 failed with:
'ScriptCompileSurface.CompileAttributeAccessor' does not contain a definition for 'WaitAsync'
…which forced a bounded-poll-loop workaround in the DELMIA/MES receiver scripts on prod. Once this plan
ships, those scripts can be switched back to the cheaper event-driven WaitAsync.
Tech Stack: C#/.NET 10, Roslyn (Microsoft.CodeAnalysis.CSharp.Scripting), xUnit. No new dependencies.
Branch/worktree: Implement off origin/main (currently c2e89e9, which carries the WaitAsync runtime +
the routed WaitForAttribute runtime). Create a fresh git worktree off origin/main — do NOT use the
~/Desktop/ScadaBridge main checkout (it sits on feat/ipsen-mes-movein) and do NOT touch the existing
.claude/worktrees/* (other sessions are active there). E.g.:
git worktree add .claude/worktrees/waitasync-surface origin/main -b fix/waitasync-compile-surface.
Verified current source state (origin/main @ c2e89e9):
- Runtime methods EXIST:
src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs—AttributeAccessor.WaitAsync(2 overloads, lines ~110/136) +WaitForAsync(2 overloads, lines ~158/175), each(..., bool requireGoodQuality = false).CompositionAccessor.AttributesreturnsAttributeAccessor(soChildren["x"].Attributes.WaitAsync(...)uses the same type).src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs—public async Task<bool> WaitForAttribute(string attributeName, object? targetValue, TimeSpan timeout, CancellationToken cancellationToken = default)(line ~220).IInstanceRouter.RouteToWaitForAttributeAsync+ the site handlerDeploymentManagerActor.RouteInboundApiWaitForAttribute(line ~1091) already exist and work.WaitResult:src/ZB.MOM.WW.ScadaBridge.Commons/Types/WaitResult.cs—public readonly record struct WaitResult(bool Matched, object? Value, string? Quality, bool TimedOut).
- Mirror gaps (the bugs):
- GAP 1 (PRIMARY):
src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs→CompileAttributeAccessor(class at line ~171) hasthis[],GetAsync,SetAsync,Resolve— but noWaitAsync/WaitForAsync. This is the compile surface forcall/template scripts (and, viaCompileCompositionAccessor.Attributes, forChildren["x"].Attributestoo). - GAP 3 (secondary): the inbound-script analysis mirrors —
src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/InboundScriptHost.cs(RouteTarget, line ~40) andsrc/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs(RouteTarget, line ~38) — exposeCall/GetAttributes/SetAttributesbut noWaitForAttribute. (Inbound scripts compile/run at deploy time against the realInboundScriptContextviaglobalsType: typeof(InboundScriptContext)inInboundScriptExecutor, so deploy is fine — but the CentralUI editor's static analysis uses these mirrors, soRoute.To(...).WaitForAttribute(...)shows as an error there.) - GAP 4 (defensive):
DeploymentManagerActor.RouteInboundApiWaitForAttributereturnsRouteToWaitForAttributeResponsewitht.Result.Valuepassed straight through (NOT normalized), unlikeRouteInboundApiCallwhich wraps the return inNormalizeRoutedReturnValue(...)(added because anon/complex types don't survive the cross-process Newtonsoft serializer). The waitValueis a single attribute value (scalar/string/array) and normally round-trips, but this is the same risk surface — verify and harden if needed.
- GAP 1 (PRIMARY):
- Parity test that SHOULD have caught GAP 1:
tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs.
Task 1: Mirror WaitAsync/WaitForAsync on the template compile surface (PRIMARY FIX)
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 3
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs(CompileAttributeAccessor, ~line 171) - Test:
tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.csand/ortests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs
Step 1: Write the failing test. Add a test that a call-script body using each wait form passes
validation (compiles against the surface). Cover root scope and composed scope. Use the same harness the
existing GetAsync/SetAsync validation tests use (see RoslynScriptCompilerTests / CompileSurfaceParityTests).
Example bodies that must compile:
await Attributes.WaitAsync("Flag", true, System.TimeSpan.FromSeconds(5));
await Attributes.WaitAsync("Flag", v => v != null, System.TimeSpan.FromSeconds(5), true);
var r = await Attributes.WaitForAsync("Flag", true, System.TimeSpan.FromSeconds(5));
await Children["LeftMESReceiver"].Attributes.WaitAsync("MoveInCompleteFlag", true, System.TimeSpan.FromSeconds(5));
Step 2: Run it — expect FAIL with "does not contain a definition for 'WaitAsync'".
Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ -f (or the SiteRuntime parity test project).
Step 3: Add the four mirror methods to CompileAttributeAccessor, signatures identical to the
runtime AttributeAccessor (ScopeAccessors.cs), bodies throwing the existing CompileOnly sentinel like
the sibling members:
/// <summary>Mirrors <c>AttributeAccessor.WaitAsync</c>.</summary>
public Task<bool> WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false) => throw new NotSupportedException(CompileOnly);
public Task<bool> WaitAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false) => throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors <c>AttributeAccessor.WaitForAsync</c>.</summary>
public Task<WaitResult> WaitForAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false) => throw new NotSupportedException(CompileOnly);
public Task<WaitResult> WaitForAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false) => throw new NotSupportedException(CompileOnly);
WaitResult resolves via the existing using ZB.MOM.WW.ScadaBridge.Commons.Types; (already at the top of the
file). Ensure using System; (for Func<>/TimeSpan) and System.Threading.Tasks are present (they are).
Only CompileAttributeAccessor needs editing — CompileCompositionAccessor.Attributes already returns it, so
Children["x"].Attributes.WaitAsync(...) is covered automatically.
Step 4: Run the test — expect PASS. Also run the full ScriptAnalysis + SiteRuntime script test projects to confirm no regressions.
Step 5: Commit (pathspec form): git commit -m "fix(scriptanalysis): mirror WaitAsync/WaitForAsync on CompileAttributeAccessor" -- src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs tests/...
Task 2: Harden CompileSurfaceParityTests so missing mirrors fail the build
Classification: standard Estimated implement time: ~4 min Parallelizable with: none (do after Task 1 so the new methods exist)
Files:
- Modify:
tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs
Why: This test exists to keep the compile surface in lockstep with the runtime script API, yet it did not catch the WaitAsync omission. Close that hole.
Steps:
- Read the current test to learn its parity strategy (how it maps runtime types ↔ compile-surface types).
- Add/strengthen a reflection check: for each public instance method on the runtime
AttributeAccessor(ScopeAccessors.cs), assert a method of the same name and matching parameter count/arity exists onScriptCompileSurface.CompileAttributeAccessor. Do the same mapping forCompositionAccessor↔CompileCompositionAccessorandChildrenAccessor↔CompileChildrenAccessorif not already covered. (Match by name + parameter count to tolerate theobject?-vs-mirror type differences; the goal is to fail when a runtime method has no counterpart, as WaitAsync did.) - Run it — it must PASS now (Task 1 added the methods). Temporarily delete one mirror method locally to confirm the test goes RED, then restore.
- Commit (pathspec form).
Task 3: Mirror WaitForAttribute on the inbound-script analysis surfaces
Classification: small Estimated implement time: ~3 min Parallelizable with: Task 1
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/InboundScriptHost.cs(RouteTarget, ~line 40) - Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs(RouteTarget, ~line 38) - Test:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs
Note: Inbound deploy/runtime already works (it compiles against the real InboundScriptContext), so this
is purely to fix the CentralUI editor static analysis flagging a valid call. If, after reading the test,
you find inbound scripts are not statically analysed against these mirror RouteTargets anywhere a user
sees the result, this task may be dropped — confirm before deciding.
Steps:
- Add a
WaitForAttributemethod to each analysisRouteTarget, mirroring the runtimeRouteHelper.WaitForAttributesignature, in the same throwing/CompileOnlystyle as the siblingCall:(Match each file's existing convention for the throw/sentinel and namespacing.)public System.Threading.Tasks.Task<bool> WaitForAttribute( string attributeName, object? targetValue, System.TimeSpan timeout, System.Threading.CancellationToken cancellationToken = default) => throw new NotSupportedException(/* CompileOnly */); - Add a test asserting an inbound script using
await Route.To("X").WaitForAttribute("Flag", true, System.TimeSpan.FromSeconds(5))passes analysis. - Run the CentralUI analysis test project; commit (pathspec form).
Task 4: Verify (and if needed normalize) the routed WaitForAttribute response value
Classification: standard Estimated implement time: ~4 min Parallelizable with: none
Files:
- Inspect/Modify:
src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs(RouteInboundApiWaitForAttribute, ~line 1091;NormalizeRoutedReturnValue, ~line 999) - Test:
tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/(the routed-wait / DeploymentManager tests)
Why: RouteInboundApiCall normalizes its return value before sending it back across the Central↔Site
Akka boundary (anon/complex types are silently dropped by the cross-process serializer → caller Ask
hangs to timeout). RouteInboundApiWaitForAttribute passes t.Result.Value through un-normalized. The wait
value is a single attribute value and usually round-trips, so this may already be safe.
Steps:
- Determine whether a
WaitForAttributeValuecan be a type that does NOT round-trip the configured cross-process serializer (check whatInstanceActorputs inWaitForAttributeResponse.Value— raw boxed attribute value? aList<...>for array attributes? already codec-encoded?). - If any non-trivially-serializable shape is possible, wrap it:
NormalizeRoutedReturnValue(t.Result.Value)in the success branch (reuse the existing helper). If it is provably always a primitive/string/null, instead add a brief code comment documenting why it is safe and a test asserting an array-valued wait round-trips end-to-end (a regression guard). - Add/extend a test for the routed-wait response carrying a representative value across the boundary.
- Run the SiteRuntime actor tests; commit (pathspec form).
After all tasks
- Full solution build + run the affected test projects (ScriptAnalysis, SiteRuntime, CentralUI, InboundAPI).
- Dispatch a final reviewer over the cumulative diff.
- Use superpowers-extended-cc:finishing-a-development-branch.
Downstream follow-up (NOT part of this plan — do after it ships & deploys)
Once this is merged and deployed to wonder-app-vd03, the DELMIA/MES receiver scripts there
(MesMoveIn/MesMoveOut on T1 + ProcessRecipeDownload on T1, provisioned 2026-06-17) currently use a
bounded poll loop as the WaitAsync fallback. They can be switched to the event-driven form via the mgmt API
UpdateTemplateScript, replacing:
bool done = false;
for (int i = 0; i < 100 && !done && !CancellationToken.IsCancellationRequested; i++) {
await System.Threading.Tasks.Task.Delay(250, CancellationToken);
done = IsTrue(await recv.Attributes.GetAsync("MoveInCompleteFlag"));
}
if (!done) return /* timeout */;
with:
if (!await recv.Attributes.WaitAsync("MoveInCompleteFlag", true, System.TimeSpan.FromSeconds(30)))
return /* timeout */;
(and the analogous MoveOutCompleteFlag / RecipeProcessedFlag lines). Verify the deployed build includes
Task 1 first (a deploy of a WaitAsync script will otherwise fail validation again).