导航路径版本门:异步结果过期后不得覆盖新目标
角色先点击 A 点,随即改点 E 点,两个异步寻路任务可能反序完成。若 Godot C# 节点只在回调中设置 NavigationAgent3D.TargetPosition 或路径数组,较慢的旧结果会把角色拉回已经取消的目标。2026-07-23-2000 探针用请求版本定义提交资格:控制节点拥有当前意图,工作线程只计算带版本的候选路径,版本门独占可见路径。
// .tmp/daily-labs/2026-07-23-2000/PathVersionGate.cs
namespace NavigationProbe;
public sealed class PathResult
{
public long Version { get; }
public IReadOnlyList<string> Points { get; }
public PathResult(long version, IEnumerable<string> points)
{
Version = version;
Points = Array.AsReadOnly(points.ToArray());
}
}
public sealed class PathVersionGate
{
public long RequestedVersion { get; private set; }
public PathResult? Committed { get; private set; }
public long BeginRequest() => ++RequestedVersion;
public bool TryCommit(PathResult candidate, out string error)
{
if (candidate.Version != RequestedVersion)
{
error = "stale_version";
return false;
}
if (candidate.Points.Count < 2)
{
error = "incomplete_path";
return false;
}
Committed = new PathResult(candidate.Version, candidate.Points);
error = "none";
return true;
}
}

版本所有权
BeginRequest 是唯一产生版本的入口,版本表示意图顺序而非任务完成顺序。工作线程可以持有 PathResult,却无权直接改变角色路径。三个不变量必须同时成立:只有 candidate.Version == RequestedVersion 才能提交;可见路径至少包含起点和下一节点;公开的 Points 只能读取。构造与提交各执行一次防御性复制,只读包装阻止消费者绕过版本门改写已提交路径。
在 Godot 场景树中,版本门应由角色控制节点或其专用导航组件持有,而不是放进共享线程池。线程池只负责计算;主线程回调把结果交给门禁后,才允许更新代理的后续转向输入。这样节点释放、重新入树或目标变化都能先推进版本,再处理迟到回调。
正常时序
AgentNode -> VersionGate : BeginRequest() => v2
Worker -> PathQuery : compute(v2, target E)
Worker -> VersionGate : PathResult(v2, [A,D,E])
VersionGate -> AgentNode : commit copied path
失败时序
Worker(v1) -> VersionGate : PathResult(v1, [A,B,C])
VersionGate -> Worker(v1) : stale_version
VersionGate -> AgentNode : keep current visible path
提交判定
版本校验必须先于路径内容校验。旧任务即使产生几何上合法的路径,也不能再次获得状态所有权;最新任务若只返回一个点,同样不能用不完整结果清空既有路径。当前版本使用进程内 long 单调递增,比较为 O(1),路径复制为 O(p),其中 p 是节点数。版本不是网络时间,也不需要跨机器同步。
单调序号还把“取消”和“失效”分开:取消是节省计算的优化,失效是提交前必须执行的正确性判定。即使底层查询不支持取消,系统仍可拒绝旧结果;即使取消 API 返回成功,也不能跳过版本比较,因为完成回调可能已经排队。
// .tmp/daily-labs/2026-07-23-2000/Program.cs
var gate = new PathVersionGate();
var passed = 0;
void Check(bool condition, string name)
{
if (!condition) throw new InvalidOperationException(name);
passed++;
}
var first = gate.BeginRequest();
var second = gate.BeginRequest();
Check(first == 1 && second == 2, "monotonic_versions");
Check(!gate.TryCommit(
new(first, ["A", "B", "C"]), out var stale),
"reject_stale");
Check(stale == "stale_version" && gate.Committed is null,
"stale_atomicity");
Check(!gate.TryCommit(
new(second, ["A"]), out var incomplete),
"reject_incomplete");
Check(incomplete == "incomplete_path" && gate.Committed is null,
"incomplete_atomicity");
Check(gate.TryCommit(
new(second, ["A", "D", "E"]), out var accepted),
"commit_latest");
Check(accepted == "none" &&
gate.Committed?.Points.SequenceEqual(["A", "D", "E"]) == true,
"latest_visible");
var exposed = (IList<string>)gate.Committed!.Points;
CheckThrows<NotSupportedException>(
() => exposed[1] = "X", "read_only_exposure");
Check(gate.Committed.Points.SequenceEqual(["A", "D", "E"]),
"consumer_mutation_isolated");
void CheckThrows<T>(Action action, string name) where T : Exception
{
try { action(); }
catch (T) { passed++; return; }
throw new InvalidOperationException(name);
}
过期拒绝
固定场景连续申请 v1 与 v2,随后刻意让 v1 先返回。门禁以 stale_version 拒绝它,且 Committed 仍为空;v2 的单点坏结果再以 incomplete_path 被拒绝。只有完整的 [A,D,E] 获得可见权。消费者把公开集合转为 IList 后尝试写入,操作抛出 NotSupportedException,随后提交值仍为 [A,D,E]。恢复不依赖取消工作线程:旧计算可以自然结束,只要其结果无法穿过版本门。
{
"slot": "2026-07-23-2000",
"tests": { "passed": 9, "failed": 0, "skipped": 0 },
"duration_ms": 3.608,
"requested_version": 2,
"committed_version": 2,
"committed_points": ["A", "D", "E"],
"failures": [
"stale_version",
"incomplete_path"
]
}

运行证据
Release 运行完成九个断言,无失败与跳过;其中两项专门覆盖只读暴露与消费者改写隔离。输出中的请求版本、提交版本与路径点构成技术图的唯一数据源。进程计时包含构建和启动,仅证明命令在当前环境完成;由于没有长路径样本与重复测量,本文不对寻路吞吐或延迟作性能结论。
cd "$WORK_DIR"
/usr/bin/time -p dotnet run \
--project .tmp/daily-labs/2026-07-23-2000/NavigationProbe.csproj \
-c Release
# exit code: 0
# tests: passed=9 failed=0 skipped=0
# process timing:
real 1.67
user 0.82
sys 0.19
系统取舍
尝试取消旧任务看似更节省计算,却不能替代提交校验:取消存在竞态,任务可能在收到信号前已经返回。版本门允许旧任务浪费有限 CPU,换取可见状态的确定性。当前设计只覆盖单个 Agent 的本地意图;当导航请求需要跨帧存档、分区服务器或多代理共享时,应把版本扩展为“代理 ID + 单调序号”,并增加导航网格代际,防止地图重烘焙后的旧路径提交。
若角色死亡或节点退出场景,最小恢复动作不是提交空路径,而是推进版本并清除所有者引用。随后到达的结果会自然变成过期候选,既不需要定位每个后台任务,也不会在已释放节点上触发移动。
当前边界: 单 Agent、单进程、只读路径快照
复杂度: 版本判定 O(1),路径复制 O(p)
失效信号: stale_version / incomplete_path
演进触发: 网格热更新、跨分区计算、共享请求队列
后续合同: PathResult(AgentId, RequestVersion, NavMeshGeneration, Points)
架构结论
异步寻路的正确性不由“最后完成”决定,而由“是否仍代表最新意图”决定。单调版本把完成顺序与状态所有权分开;完整性校验再阻止最新但无效的结果。Godot 节点可以继续负责移动,但只有穿过版本门的复制路径才应成为它的输入。