fix(virtual-tags): resolve High code-review finding (Core.VirtualTags-001)

OnScriptSetVirtualTag updated the value cache, notified observers, and
recorded history for the written path but never scheduled a cascade for
tags depending on that path. This contradicts docs/VirtualTags.md, which
states ctx.SetVirtualTag writes "still participate in change-trigger
cascades": a change-triggered virtual tag reading a script-written tag
went stale until an unrelated trigger fired.

OnScriptSetVirtualTag now launches a fire-and-forget CascadeAsync for the
written path, mirroring OnUpstreamChange. The cascade is scheduled rather
than invoked inline because the callback runs inside EvaluateInternalAsync
while the non-reentrant _evalGate semaphore is held.

Added regression test
SetVirtualTag_within_script_cascades_to_dependents_of_the_written_tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 06:17:40 -04:00
parent e3f8fa535a
commit 66e8bfbab3
3 changed files with 43 additions and 3 deletions

View File

@@ -275,6 +275,38 @@ public sealed class VirtualTagEngineTests
engine.Read("Driver").Value.ShouldBe(5);
}
[Fact]
public async Task SetVirtualTag_within_script_cascades_to_dependents_of_the_written_tag()
{
// Regression for Core.VirtualTags-001: a ctx.SetVirtualTag write must schedule
// the change-trigger cascade for tags depending on the written path, exactly
// like an upstream delta. "Dependent" reads "Target" and is ChangeTriggered, so
// a write to Target via ctx.SetVirtualTag must re-evaluate Dependent.
var up = new FakeUpstream();
up.Set("In", 5);
using var engine = Build(up);
engine.Load([
new VirtualTagDefinition("Target", DriverDataType.Int32,
"""return 0;""", ChangeTriggered: false), // operator-written via SetVirtualTag
new VirtualTagDefinition("Dependent", DriverDataType.Int32,
"""return (int)ctx.GetTag("Target").Value + 1;""", ChangeTriggered: true),
new VirtualTagDefinition("Writer", DriverDataType.Int32,
"""
var v = (int)ctx.GetTag("In").Value;
ctx.SetVirtualTag("Target", v * 100);
return v;
"""),
]);
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
// Writer set Target = 500; the cascade must have re-evaluated Dependent to 501.
engine.Read("Target").Value.ShouldBe(500);
await WaitForConditionAsync(() => Equals(engine.Read("Dependent").Value, 501));
engine.Read("Dependent").Value.ShouldBe(501,
"a ctx.SetVirtualTag write must cascade to change-triggered dependents");
}
[Fact]
public async Task Type_coercion_from_script_double_to_config_int32()
{