diff --git a/docs/runbooks/ft546-shadow-20260909-observed-traffic-repair.md b/docs/runbooks/ft546-shadow-20260909-observed-traffic-repair.md new file mode 100644 index 0000000..5e60c1c --- /dev/null +++ b/docs/runbooks/ft546-shadow-20260909-observed-traffic-repair.md @@ -0,0 +1,30 @@ +# FT546 影子版本:动态占用修复 + +本次源码版本标签:`ft546-shadow-20260909-observed-traffic-v1`。 + +## 修复内容 + +- 每次影子快照额外记录车辆当前站点、忙碌车辆的 PLC path,以及已下发活动队列的 path_info。 +- 直线段按现场地图逐格展开,同时保护节点和边资源;路径缺失、非法、跨层或与当前位置断开时,对该车所在楼层保守保持占用约束。 +- 观察约束只用于本轮影子判断,不写入、不释放持久化预约;车辆自己的当前位置不会阻止自己离开。 +- 已观察车辆在下一完整批次缺失时,本轮停止预测,避免把数据缺失误判成道路释放。 +- 避让路线如果穿过观察约束,转换为 `HOLD / OBSERVED_TRAFFIC_CONFLICT`,不能用避让绕过现场车辆。 + +## 验证结果 + +本地离线验证覆盖 28 个测试:调度核心、影子占用边界、延迟比较和现场 50 份输入重放均通过。50 份原始输入哈希均保持一致;三组已知风险任务 501、60935、31372 在不带观察约束的反事实中可放行,带观察约束时分别被车辆 2/3、3、3 阻挡。该重放没有重建历史预约账本,也没有连接或修改现场数据库。 + +## 避让点结论 + +现场正式 mapper 只把 `remark='cache'` 的点作为缓存候选,并排除当前车辆站点;`BaseSRgvThread` 还按 `cargo_place_site` 查询任务占用。只读地图核验显示: + +- 1–7 层 `050201/050301`、`050202/050302` … `050207/050307`,8 层 `050308/050408` 是明确 cache 点; +- `0502xx` 在导出地图上通常只有一个通行邻接,不满足当前安全点至少两个邻接的约束; +- `0503xx` 和 8 层 cache 点虽然通常有两个邻接,但属于通道割点,停放可能切断通道;1 层 `050201` 在 50 份样本中还出现过 18 次车辆占用,`050301` 出现过 4 次,并有活动任务目标; +- `050101`、`070101` 是业务货位,`070301`、`090301` 是 `outCache`,均不作为候选。 + +因此 `safe-wait-sites` 和 `safe-yield-sites` 仍保持空配置。当前没有证据允许自动启用某个避让点;若现场确认某点可以停放,需同时确认占用语义、回退路线、非割点通道和 PLC/WMS 业务互斥后再单独配置。 + +## 现场动作 + +用户停止影子程序后,在本地工作树编译并启动新源码,核对 `/shadow/status` 的 `runtimeIdentity.buildId` 为上述标签、启动检查全部 PASS。不要停止正式 WCS,不要执行生产库 SQL。先运行观察,后续再用新日志评估。 diff --git a/docs/superpowers/plans/2026-09-09-shadow-observed-traffic.md b/docs/superpowers/plans/2026-09-09-shadow-observed-traffic.md new file mode 100644 index 0000000..e1f7a89 --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-shadow-observed-traffic.md @@ -0,0 +1,20 @@ +# Shadow observed traffic repair + +**Goal:** Prevent shadow predictions from crossing observed vehicle positions and issued routes. Production SQL and runtime are out of scope. + +**Architecture:** Add immutable, per-snapshot resource owners, separate from the persisted prediction ledger. Existing DispatchSnapshot constructors supply an empty map. Only the shadow assembler supplies observed owners. The coordinator checks before reservation and also checks proposed yield routes. Unknown moving paths block their floor; missing previously observed vehicles reject the snapshot. + +## Work and verification + +- [x] Reproduce with ShadowObservedTrafficTest: 8 tests, 5 expected assertion failures; baseline reactor passes. +- [ ] Add snapshot overload accepting Map> observedResourceOwners; defensively copy and validate against topology resources. +- [ ] Add ShadowObservedTrafficProjector.project(batch, topology); project current sites, expand orthogonal live paths through all map nodes/edges, include active issued queue routes; ignore finished queue history. Keep an in-process high-water vehicle roster and reject missing/contradictory vehicle facts. +- [ ] Coordinator checks other owners before DeadlockGuard reservation; convert conflicting yield into HOLD, without writing observations to the ledger. +- [ ] Run `mvn -o -pl ruoyi-wcs-shadow -am -Dtest=ShadowObservedTrafficTest -Dsurefire.failIfNoSpecifiedTests=false test`; expand boundary tests and run the full reactor. +- [ ] Replay the three captured risk cases with real assembler/coordinator; retain input hashes and clearly distinguish this replay from reconstruction of the historical ledger. +- [ ] Review cache points against formal mapper rules and map geometry. Document candidate points only; keep safe-wait/yield configuration empty. +- [ ] Review diff, update build identity and repair notes, deliver source without deploying or restarting either field application. + +## Safety constraints + +No production database writes, no PLC/WMS calls, no remote update/restart. Do not relax allowSend eligibility or release persisted UNKNOWN/OCCUPIED reservations. Old shared dispatch constructors retain existing behavior. A complete source read is not a PLC-atomic observation; this repair is for shadow validation, not approval of production takeover. diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java index 5eff6ec..d6a847b 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java @@ -41,6 +41,7 @@ public final class ShadowDispatchSnapshotAssembler { private final Clock clock; private final java.util.Set safeWaitSites; private final java.util.Set safeYieldSites; + private final ShadowObservedTrafficProjector observedTraffic = new ShadowObservedTrafficProjector(); public ShadowDispatchSnapshotAssembler( String warehouseCode, @@ -72,7 +73,7 @@ public final class ShadowDispatchSnapshotAssembler { stateLoader.apply(topology), "stateLoader result"); return new DispatchSnapshot(tasks(batch, topology), vehicles(batch, topology), topology, state.getLedger(), - state.getWaitForGraph(), state.getSnapshotVersion()); + state.getWaitForGraph(), state.getSnapshotVersion(), observedTraffic.project(batch, topology)); } private EffectiveTopology topology(List facts) { diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowObservedTrafficProjector.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowObservedTrafficProjector.java new file mode 100644 index 0000000..05421e1 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowObservedTrafficProjector.java @@ -0,0 +1,121 @@ +package com.ruoyi.shadow.kernel; + +import com.ruoyi.shadow.facts.ShadowFactBatch; +import com.ruoyi.shadow.facts.ShadowQueueFact; +import com.ruoyi.shadow.facts.ShadowVehicleFact; +import com.ruoyi.wcs.domain.topology.EffectiveTopology; +import com.ruoyi.wcs.domain.topology.TopologyEdge; +import com.ruoyi.wcs.domain.topology.TopologyNode; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Pure read-only observations; never creates or releases persisted reservations. */ +final class ShadowObservedTrafficProjector { + private final Set observedVehicleIds = new LinkedHashSet(); + + synchronized Map> project(ShadowFactBatch batch, EffectiveTopology topology) { + Map> owners = new LinkedHashMap>(); + Map vehicles = new LinkedHashMap(); + for (ShadowVehicleFact vehicle : batch.getVehicles()) { + if (vehicles.put(vehicle.getDeviceId(), vehicle) != null) { + throw new IllegalArgumentException("OBSERVED_VEHICLE_DUPLICATE:" + vehicle.getDeviceId()); + } + } + if (vehicles.isEmpty()) throw new IllegalArgumentException("OBSERVED_VEHICLES_EMPTY"); + observedVehicleIds.addAll(vehicles.keySet()); + Set missing = new LinkedHashSet(observedVehicleIds); + missing.removeAll(vehicles.keySet()); + if (!missing.isEmpty()) throw new IllegalArgumentException("OBSERVED_VEHICLE_MISSING:" + missing); + Map coordinates = new LinkedHashMap(); + for (String id : topology.getNodeIds()) { + TopologyNode node = topology.node(id); + coordinates.put(coordinate(node.getFloorCode(), node.getX(), node.getY()), id); + } + for (ShadowVehicleFact vehicle : batch.getVehicles()) { + TopologyNode current = topology.node(vehicle.getCurrentSite()); + if (current == null) throw new IllegalArgumentException("OBSERVED_POSITION_UNKNOWN:" + vehicle.getDeviceId()); + if (vehicle.getCurrentFloor() != null + && !current.getFloorCode().equals("F" + vehicle.getCurrentFloor())) { + throw new IllegalArgumentException("OBSERVED_FLOOR_CONFLICT:" + vehicle.getDeviceId()); + } + add(owners, "NODE:" + current.getNodeId(), vehicle.getDeviceId()); + boolean confirmedIdle = Integer.valueOf(1).equals(vehicle.getStatus()) + && Integer.valueOf(1).equals(vehicle.getAllowSend()); + if (!confirmedIdle) { + if (!addPath(owners, vehicle.getPath(), vehicle.getDeviceId(), current, topology, coordinates)) { + blockFloor(owners, current.getFloorCode(), vehicle.getDeviceId(), topology); + } + } + } + for (ShadowQueueFact queue : batch.getQueues()) { + if (!queue.isActive() || (!Integer.valueOf(1).equals(queue.getIssued()) + && !Integer.valueOf(3).equals(queue.getExecutionStatus()))) continue; + String owner = queue.getAgvId() == null ? "" : queue.getAgvId().trim(); + ShadowVehicleFact vehicle = vehicles.get(owner); + if (vehicle == null) throw new IllegalArgumentException("OBSERVED_QUEUE_VEHICLE_UNKNOWN:" + queue.getGuid()); + if (!addPath(owners, queue.getPathInfo(), owner, topology.node(vehicle.getCurrentSite()), topology, coordinates)) { + blockFloor(owners, topology.node(vehicle.getCurrentSite()).getFloorCode(), owner, topology); + } + } + return owners; + } + + // path/path_info are movement-ordered, not the PLC [start,end,turns] encoding. + // Expand straight segments through topology coordinates, including WY/TD nodes. + private static boolean addPath(Map> owners, String path, String owner, + TopologyNode current, EffectiveTopology topology, Map coordinates) { + if (path == null || path.trim().isEmpty()) return false; + List anchors = new ArrayList(); + for (String token : path.split(",")) { + String id = token.trim(); + if (id.isEmpty() || !topology.containsNode(id)) return false; + if (!topology.node(id).getFloorCode().equals(current.getFloorCode())) { + throw new IllegalArgumentException("OBSERVED_PATH_CROSS_FLOOR:" + owner); + } + anchors.add(id); + } + if (anchors.isEmpty()) return false; + Set resources = new LinkedHashSet(); + resources.add("NODE:" + anchors.get(0)); + for (int i = 1; i < anchors.size(); i++) { + TopologyNode from = topology.node(anchors.get(i - 1)); + TopologyNode to = topology.node(anchors.get(i)); + if (!from.getFloorCode().equals(to.getFloorCode()) + || (from.getX() != to.getX() && from.getY() != to.getY())) return false; + int dx = Integer.compare(to.getX(), from.getX()); + int dy = Integer.compare(to.getY(), from.getY()); + int x = from.getX(), y = from.getY(); + String previous = from.getNodeId(); + while (x != to.getX() || y != to.getY()) { + x += dx; + y += dy; + String next = coordinates.get(coordinate(from.getFloorCode(), x, y)); + if (next == null || topology.edge(previous, next) == null) return false; + resources.add("NODE:" + next); + resources.add(TopologyEdge.canonicalResourceKey(previous, next)); + previous = next; + } + } + if (!resources.contains("NODE:" + current.getNodeId())) return false; + for (String key : resources) add(owners, key, owner); + return true; + } + + private static void blockFloor(Map> owners, String floor, + String owner, EffectiveTopology topology) { + for (String id : topology.getNodeIds()) { + if (topology.node(id).getFloorCode().equals(floor)) add(owners, "NODE:" + id, owner); + } + } + + private static void add(Map> owners, String resource, String owner) { + owners.computeIfAbsent(resource, key -> new LinkedHashSet()).add(owner); + } + + private static String coordinate(String floor, int x, int y) { return floor + ':' + x + ':' + y; } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowRuntimeIdentity.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowRuntimeIdentity.java index c0132bf..6057bb1 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowRuntimeIdentity.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowRuntimeIdentity.java @@ -14,7 +14,7 @@ import java.util.TreeMap; /** Identifies this source revision and the nonsecret dispatch/storage settings, not a built artifact. */ public final class ShadowRuntimeIdentity { - public static final String BUILD_ID = "ft546-shadow-20260909-recovery-pairing-v3"; + public static final String BUILD_ID = "ft546-shadow-20260909-observed-traffic-v1"; private ShadowRuntimeIdentity() { } public static Map describe(ShadowProperties properties, Environment env) { diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java index b2bdf8c..445cad1 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java @@ -139,7 +139,7 @@ class Ft546ShadowApplicationTest { .andExpect(jsonPath("$.status").value("STARTING")) .andExpect(jsonPath("$.checkedAt").exists()) .andExpect(jsonPath("$.evidenceBacklog").value(0)) - .andExpect(jsonPath("$.runtimeIdentity.buildId").value("ft546-shadow-20260909-recovery-pairing-v3")) + .andExpect(jsonPath("$.runtimeIdentity.buildId").value("ft546-shadow-20260909-observed-traffic-v1")) .andExpect(jsonPath("$.runtimeIdentity.effectiveSettingsSha256").exists()) .andExpect(jsonPath("$.runtimeIdentity.safePointConfiguration").value("UNCONFIGURED")) .andExpect(jsonPath("$.startupChecks.SHADOW_ENABLED") diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowDeferredComparisonTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowDeferredComparisonTest.java index 027f705..6543a31 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowDeferredComparisonTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowDeferredComparisonTest.java @@ -74,7 +74,6 @@ class ShadowDeferredComparisonTest { builder(1, "70501,70502").taskType("1").build(), builder(1, "70501,70502").taskNo("30002").build(), builder(1, "70501,70502").createdAt(NOW.minusSeconds(1)).build(), - builder(1, "70501,70502").agvId("VEH-8").build(), builder(1, "70501,70502").startSite("70503").build(), builder(1, "70501,70502").default02("1").build(), builder(1, "70501,70502").default08("2").build()); @@ -86,6 +85,19 @@ class ShadowDeferredComparisonTest { } } + @Test + void issuedQueueWithUnobservedVehicleCannotBePairedOrDispatched() { + Harness h = new Harness(); + h.run(0, queue(0, null)); + ShadowQueueFact unknownOwner = builder(1, "70501,70502").agvId("VEH-8").build(); + assertEquals("INSUFFICIENT_FACTS", h.runBatch(h.batch(5, + Collections.singletonList(unknownOwner), true)).getStatusCode()); + ShadowCycleEvidence evidence = h.repository.saved.get(h.repository.saved.size() - 1); + assertTrue(evidence.getEncodedWrites().isEmpty()); + assertNull(deferredOrNull(evidence)); + assertEquals(1, h.gateway.getRecorded().size(), "only the earlier valid pending prediction was encoded"); + } + @Test void missingOrStaleLegacyPathRemainsUnavailable() { for (String path : new String[] {null, "70502,70501"}) { diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowObservedTrafficFieldReplayTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowObservedTrafficFieldReplayTest.java new file mode 100644 index 0000000..7fd9167 --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowObservedTrafficFieldReplayTest.java @@ -0,0 +1,73 @@ +package com.ruoyi.shadow.kernel; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.shadow.evidence.ShadowCycleEvidence; +import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector; +import com.ruoyi.shadow.facts.ShadowFactBatch; +import com.ruoyi.wcs.application.dispatch.DispatchCoordinator; +import com.ruoyi.wcs.application.dispatch.DispatchCycleResult; +import com.ruoyi.wcs.application.dispatch.DispatchSnapshot; +import com.ruoyi.wcs.application.dispatch.HeldDispatch; +import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy; +import com.ruoyi.wcs.domain.traffic.*; +import com.ruoyi.wcs.port.out.DispatchPolicy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import java.nio.file.*; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Private evidence replay; no production connection and no invented historical ledger. */ +@EnabledIfSystemProperty(named = "shadow.replay.occupancy-directory", matches = ".+") +class ShadowObservedTrafficFieldReplayTest { + @Test void capturedConflictingRoutesAreHeldAndEveryRawInputHashIsPreserved() throws Exception { + ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + Map risks = new LinkedHashMap(); + risks.put("8023c472-5e3b-4883-a37c-3985398a1f92", "501"); + risks.put("12e15b0b-b9cb-45bb-9f86-10ee217234f5", "60935"); + risks.put("84366e8d-8658-4a4f-b95c-e4041ea1685c", "31372"); + Set checked = new HashSet(); + int count = 0, grants = 0, holds = 0; + try (DirectoryStream paths = Files.newDirectoryStream(Paths.get( + System.getProperty("shadow.replay.occupancy-directory")), "input-*.json")) { + for (Path path : paths) { + JsonNode export = json.readTree(path.toFile()); + String raw = export.get("capturedJson").asText(); + ShadowFactBatch batch = ShadowCapturedInputReplayTest.batch(json.readTree(raw)); + ShadowCycleEvidence input = new ShadowCycleEvidenceCollector(UUID.randomUUID(), 1L, json) + .insufficient(batch, "INDEPENDENT_INPUT_REPLAY", batch.getDatabaseCapturedAt()).get(0); + assertEquals(raw, input.getInputJson(), path.toString()); + assertEquals(export.get("contentHash").asText(), input.getInputBatchHash(), path.toString()); + DispatchSnapshot snapshot = new ShadowDispatchSnapshotAssembler("FT546", 1L, + topology -> new ShadowTrafficState(new ResourceLedger(topology), new WaitForGraph(), + 0L, Collections.emptyMap()), Clock.fixed(batch.getDatabaseCapturedAt(), ZoneOffset.UTC)).assemble(batch); + AStarRoutePlanner routes = new AStarRoutePlanner(); + DispatchCoordinator coordinator = new DispatchCoordinator(new ShadowVehicleAssignmentPolicy( + new DefaultVehicleAssignmentPolicy(routes, DispatchPolicy.NONE)), + new DeadlockGuard(new WaitForGraph(), routes), commit -> true, () -> UUID.randomUUID().toString()); + DispatchCycleResult result = coordinator.dispatch(snapshot); + String id = export.get("cycleId").asText(); + if (risks.containsKey(id)) { + String task = risks.get(id); + // Counterfactual isolates this repair from eligibility and historical ledger changes. + DispatchCycleResult withoutObservation = coordinator.dispatch(new DispatchSnapshot(snapshot.getReadySteps(), + snapshot.getVehicleSnapshots(), snapshot.getTopology(), snapshot.workingLedger(), 0L)); + assertTrue(withoutObservation.getGranted().stream().anyMatch(g -> task.equals(g.getOrderId())), task); + assertFalse(result.getGranted().stream().anyMatch(g -> task.equals(g.getOrderId())), task); + HeldDispatch held = result.getHeld().stream().filter(h -> task.equals(h.getOrderId())).findFirst().get(); + assertEquals("OBSERVED_TRAFFIC_CONFLICT", held.getReasonCode(), task); + assertTrue(held.getBlockingVehicleIds().contains("3"), task + " must account for observed vehicle 3"); + checked.add(task); + System.out.println("OCCUPANCY_REPLAY task=" + task + " blockers=" + held.getBlockingVehicleIds()); + } + grants += result.getGranted().size(); holds += result.getHeld().size(); count++; + } + } + assertEquals(new HashSet(risks.values()), checked); + assertEquals(50, count, "the fixed 2026-09-09 export contains 50 independent observations"); + System.out.println("OCCUPANCY_REPLAY inputs=" + count + " grants=" + grants + " holds=" + holds + + " rawHashesVerified=50 historicalLedgerReconstructed=false"); + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowObservedTrafficTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowObservedTrafficTest.java new file mode 100644 index 0000000..ccb4c9a --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowObservedTrafficTest.java @@ -0,0 +1,164 @@ +package com.ruoyi.shadow.kernel; + +import com.ruoyi.shadow.facts.*; +import com.ruoyi.wcs.application.dispatch.*; +import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy; +import com.ruoyi.wcs.domain.traffic.*; +import com.ruoyi.wcs.port.out.DispatchPolicy; +import org.junit.jupiter.api.Test; +import java.time.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import static org.junit.jupiter.api.Assertions.*; + +class ShadowObservedTrafficTest { + private static final Instant NOW = Instant.parse("2026-09-09T07:00:00Z"); + private static final String ACTIVE = "shadow_ro.v_wcs_srgv_queue"; + + @Test void anotherIdleVehicleOnTheRouteMustNotBeGrantedOver() { + DispatchCycleResult result = dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1), + vehicle("2", "030101", "", 1)), Collections.emptyList())); + assertTrue(result.getGranted().isEmpty(), "a reported occupied point is not free road"); + assertEquals("OBSERVED_TRAFFIC_CONFLICT", result.getHeld().get(0).getReasonCode()); + assertEquals(Collections.singleton("2"), result.getHeld().get(0).getBlockingVehicleIds()); + } + + @Test void executingPathProtectsTheEntireStraightSegmentIncludingUnreportedIntermediateNodes() { + DispatchCycleResult result = dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1), + vehicle("2", "030201", "030201,030001", 2)), Collections.emptyList())); + assertTrue(result.getGranted().isEmpty(), "crossing a live path is blocked, not just its current point"); + assertEquals("OBSERVED_TRAFFIC_CONFLICT", result.getHeld().get(0).getReasonCode()); + } + + @Test void issuedQueueProtectsACommittedPathNotPresentInTheVehicleSegment() { + ShadowQueueFact running = queue("RUNNING", "2", "030201", "030001") + .issued(1).executionStatus(3).pathInfo("030201,030101,030001,").build(); + DispatchCycleResult result = dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1), + vehicle("2", "030201", "030201", 2)), Collections.singletonList(running))); + assertTrue(result.getGranted().isEmpty(), "an issued route remains an external constraint"); + } + + @Test void ownCurrentPositionDoesNotBlockOwnDepartureAndDoesNotEnterThePersistentLedger() { + DispatchSnapshot snapshot = snapshot(Collections.singletonList(vehicle("1", "010101", "", 1)), Collections.emptyList()); + assertEquals(0, snapshot.workingLedger().usage("NODE:010101")); + AtomicReference committed = new AtomicReference<>(); + DispatchCycleResult result = coordinator(commit -> { committed.set(commit); return true; }).dispatch(snapshot); + assertEquals(1, result.getGranted().size()); + assertEquals(1, committed.get().getResultingLedger().snapshotState().getReservations().size()); + } + + @Test void aMovingVehicleWithMissingOrBrokenPathMustNotDisappearFromTrafficProtection() { + for (String path : Arrays.asList("", "UNKNOWN", "030201,010101")) { + DispatchCycleResult result = dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1), + vehicle("2", "030201", path, 2)), Collections.emptyList())); + assertTrue(result.getGranted().isEmpty(), "uncertain live path: " + path); + } + } + + @Test void idleVehiclesDoNotKeepAnOldCompletedPathButKeepTheirActualPosition() { + DispatchCycleResult result = dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1), + vehicle("2", "030201", "030201,030101,030001", 1)), Collections.emptyList())); + assertEquals(1, result.getGranted().size()); + } + + @Test void finishedQueueRowsDoNotCreateFreshExternalReservations() { + ShadowQueueFact finished = ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue_finish", "OLD") + .taskNo("2").taskType("1").issued(1).executionStatus(3).agvId("2") + .pathInfo("030201,030101,030001").startSite("030201").endSite("030001") + .default02("4").theFloor("1").createdAt(NOW.minusSeconds(10)).build(); + assertEquals(1, dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1), + vehicle("2", "030201", "", 1)), Collections.singletonList(finished))).getGranted().size()); + } + + @Test void aNewCompleteObservationCanShowTheRouteFreeWithoutReleasingAnyPersistedReservation() { + assertTrue(dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1), + vehicle("2", "030101", "", 1)), Collections.emptyList())).getGranted().isEmpty()); + assertEquals(1, dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1), + vehicle("2", "030201", "", 1)), Collections.emptyList())).getGranted().size()); + } + + @Test void missingPreviouslySeenVehicleRejectsTheSnapshotUntilItReturns() { + ShadowDispatchSnapshotAssembler assembler = assembler(); + assembler.assemble(batch(Arrays.asList(vehicle("1", "010101", "", 1), + vehicle("2", "030101", "", 1)), Collections.emptyList())); + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> + assembler.assemble(batch(Collections.singletonList(vehicle("1", "010101", "", 1)), Collections.emptyList()))); + assertTrue(failure.getMessage().startsWith("OBSERVED_VEHICLE_MISSING:")); + assertEquals(1, dispatch(assembler.assemble(batch(Arrays.asList(vehicle("1", "010101", "", 1), + vehicle("2", "030201", "", 1)), Collections.emptyList()))).getGranted().size()); + } + + @Test void duplicateVehicleIdentityMustNotHideOneOfItsPositions() { + assertThrows(IllegalArgumentException.class, () -> snapshot(Arrays.asList( + vehicle("1", "010101", "", 1), vehicle("2", "030101", "", 1), + vehicle("2", "030201", "", 1)), Collections.emptyList())); + } + + @Test void aPathDetachedFromTheMovingVehiclesCurrentPointIsUncertain() { + assertTrue(dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1), + vehicle("2", "030201", "030001", 2)), Collections.emptyList())).getGranted().isEmpty()); + } + + @Test void unsendableVehicleMustNotBeMistakenForConfirmedIdle() { + ShadowVehicleFact transitioning = ShadowVehicleFact.builder("2", "2").currentSite("030201") + .currentFloor(1).workMode(5).status(1).allowSend(0).finish(0).battery(80) + .path("030201,030001").capturedAt(NOW).build(); + assertTrue(dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1), transitioning), + Collections.emptyList())).getGranted().isEmpty()); + } + + @Test void clearedFinishHandshakeDoesNotTurnASendableIdleVehicleIntoAMovingOne() { + ShadowVehicleFact idle = ShadowVehicleFact.builder("2", "2").currentSite("030201") + .currentFloor(1).workMode(5).status(1).allowSend(1).finish(0).battery(80) + .path("").capturedAt(NOW).build(); + assertEquals(1, dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1), idle), + Collections.emptyList())).getGranted().size()); + } + + @Test void inconsistentReportedFloorCannotLeaveTheActualFloorUnprotected() { + ShadowVehicleFact inconsistent = ShadowVehicleFact.builder("2", "2").currentSite("030201") + .currentFloor(2).workMode(5).status(2).allowSend(0).battery(80).path("").capturedAt(NOW).build(); + assertThrows(IllegalArgumentException.class, () -> snapshot( + Arrays.asList(vehicle("1", "010101", "", 1), inconsistent), Collections.emptyList())); + } + + @Test void liveSegmentIncludesBothDirectionsOfTheEdgeAndDoesNotInventAnOffRouteBlocker() { + DispatchSnapshot observed = snapshot(Arrays.asList(vehicle("1", "010101", "", 1), + vehicle("2", "030201", "030201,030001", 2)), Collections.emptyList()); + assertEquals(Collections.singleton("2"), observed.observedBlockers(Collections.singletonList( + com.ruoyi.wcs.domain.topology.TopologyEdge.canonicalResourceKey("030101", "030201")), "1")); + assertTrue(observed.observedBlockers(Collections.singletonList("NODE:050101"), "1").isEmpty()); + } + + private static DispatchCycleResult dispatch(DispatchSnapshot snapshot) { return coordinator(commit -> true).dispatch(snapshot); } + private static DispatchCoordinator coordinator(com.ruoyi.wcs.port.out.TrafficStateRepository repository) { + RoutePlanner planner = new AStarRoutePlanner(); + return new DispatchCoordinator(new ShadowVehicleAssignmentPolicy(new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE)), + new DeadlockGuard(new WaitForGraph(), planner), repository, () -> UUID.randomUUID().toString()); + } + private static DispatchSnapshot snapshot(List vehicles, List extra) { + return assembler().assemble(batch(vehicles, extra)); + } + private static ShadowFactBatch batch(List vehicles, List extra) { + List queues = new ArrayList<>(extra); + queues.add(queue("PENDING", "1", "010101", "050101").build()); + List maps = new ArrayList<>(); + for (int x=1; x<=5; x++) maps.add(new ShadowMapFact("M"+x, String.format("%02d0101",x),x,1,1,1)); + maps.add(new ShadowMapFact("UP","030201",3,2,1,1)); + maps.add(new ShadowMapFact("DOWN","030001",3,0,1,1)); + return ShadowFactBatch.create(NOW,NOW,queues,vehicles,maps); + } + private static ShadowDispatchSnapshotAssembler assembler() { + return new ShadowDispatchSnapshotAssembler("FT546",1L, topology -> new ShadowTrafficState( + new ResourceLedger(topology),new WaitForGraph(),0L,Collections.emptyMap()),Clock.fixed(NOW,ZoneOffset.UTC)); + } + private static ShadowVehicleFact vehicle(String id,String site,String path,int status) { + return ShadowVehicleFact.builder(id,id).currentSite(site).currentFloor(1).workMode(5).status(status) + .allowSend(status==1 ? 1 : 0).battery(80).alarm(0).errorCode(0).cargoFlag(0).path(path).capturedAt(NOW).build(); + } + private static ShadowQueueFact.Builder queue(String guid,String agv,String source,String target) { + return ShadowQueueFact.builder(ACTIVE,guid).taskNo("PENDING".equals(guid)?"1":"2").taskType("1") + .priority(2L).issued(0).executionStatus(1).startSite(source).endSite(target).cargoPickSite(target) + .cargoPlaceSite(target).theFloor("1").agvId(agv).createdAt(NOW.minusSeconds(5)).default02("4"); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinator.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinator.java index b1dc6d1..6bf8d1d 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinator.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinator.java @@ -102,6 +102,16 @@ public final class DispatchCoordinator { VehicleSnapshot vehicle = removeVehicle( available, assignment.getVehicleId()); + Set observedBlockers = snapshot.observedBlockers( + assignment.getRoute().getResourceKeys(), assignment.getVehicleId()); + if (!observedBlockers.isEmpty()) { + held.add(new HeldDispatch(task.getOrderId(), task.getStepId(), + assignment.getVehicleId(), TrafficDecisionType.HOLD, + "OBSERVED_TRAFFIC_CONFLICT", observedBlockers, null, null)); + events.add(event(task, assignment.getVehicleId(), TrafficDecisionType.HOLD, + "OBSERVED_TRAFFIC_CONFLICT", snapshot)); + continue; + } String reservationId = requireGeneratedId(idGenerator.nextId()); ReservationRequest request = new ReservationRequest(reservationId, task.getOrderId(), task.getStepId(), assignment.getVehicleId(), @@ -109,6 +119,13 @@ public final class DispatchCoordinator { TrafficDecision traffic = cycleGuard.decide(new TrafficCandidate( request, vehicle.getLocationNodeId(), assignment.getRoute()), ledger, snapshot.getTopology()); + if (traffic.getType() == TrafficDecisionType.YIELD) { + Set yieldBlockers = snapshot.observedBlockers( + traffic.getYieldRoute().getResourceKeys(), assignment.getVehicleId()); + if (!yieldBlockers.isEmpty()) { + traffic = TrafficDecision.hold("OBSERVED_TRAFFIC_CONFLICT", yieldBlockers); + } + } if (traffic.getType() == TrafficDecisionType.GRANT) { ReservationDecision reservation = traffic.getReservationDecision(); granted.add(new GrantedDispatch(task.getOrderId(), task.getStepId(), diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchSnapshot.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchSnapshot.java index 2ad5dae..6afc74b 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchSnapshot.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchSnapshot.java @@ -10,6 +10,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.Map; +import java.util.Set; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; public final class DispatchSnapshot { private final List readySteps; @@ -18,6 +22,7 @@ public final class DispatchSnapshot { private final ResourceLedger resourceLedger; private final WaitForGraph waitForGraph; private final long snapshotVersion; + private final Map> observedResourceOwners; public DispatchSnapshot(List readySteps, List vehicleSnapshots, @@ -34,6 +39,17 @@ public final class DispatchSnapshot { ResourceLedger resourceLedger, WaitForGraph waitForGraph, long snapshotVersion) { + this(readySteps, vehicleSnapshots, topology, resourceLedger, waitForGraph, + snapshotVersion, Collections.>emptyMap()); + } + + public DispatchSnapshot(List readySteps, + List vehicleSnapshots, + EffectiveTopology topology, + ResourceLedger resourceLedger, + WaitForGraph waitForGraph, + long snapshotVersion, + Map> observedResourceOwners) { this.readySteps = immutable(readySteps, "readySteps"); this.vehicleSnapshots = immutable(vehicleSnapshots, "vehicleSnapshots"); this.topology = Objects.requireNonNull(topology, "topology"); @@ -44,6 +60,22 @@ public final class DispatchSnapshot { throw new IllegalArgumentException("snapshotVersion must not be negative"); } this.snapshotVersion = snapshotVersion; + Map> owners = new LinkedHashMap>(); + for (Map.Entry> entry : Objects.requireNonNull( + observedResourceOwners, "observedResourceOwners").entrySet()) { + if (topology.resource(entry.getKey()) == null) { + throw new IllegalArgumentException("unknown observed resource: " + entry.getKey()); + } + Set vehicles = new LinkedHashSet(); + for (String owner : Objects.requireNonNull(entry.getValue(), "resource owners")) { + if (owner == null || owner.trim().isEmpty()) { + throw new IllegalArgumentException("observed vehicle id must not be blank"); + } + vehicles.add(owner.trim()); + } + owners.put(entry.getKey(), Collections.unmodifiableSet(vehicles)); + } + this.observedResourceOwners = Collections.unmodifiableMap(owners); } public List getReadySteps() { @@ -70,6 +102,17 @@ public final class DispatchSnapshot { return snapshotVersion; } + /** Observation-only constraints, deliberately excluded from the persistent ledger. */ + public Set observedBlockers(List resourceKeys, String vehicleId) { + Set blockers = new LinkedHashSet(); + for (String key : resourceKeys) { + Set owners = observedResourceOwners.get(key); + if (owners != null) blockers.addAll(owners); + } + blockers.remove(vehicleId); + return Collections.unmodifiableSet(blockers); + } + private static List immutable(List source, String field) { if (source == null) { throw new IllegalArgumentException(field + " must not be null"); diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinatorTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinatorTest.java index 85b8429..c98505b 100644 --- a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinatorTest.java +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinatorTest.java @@ -27,11 +27,14 @@ import java.util.Collections; import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; +import java.util.LinkedHashSet; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; class DispatchCoordinatorTest { @@ -100,6 +103,63 @@ class DispatchCoordinatorTest { result.getGranted().get(0).getCommandParameters()); } + @Test + void oldSnapshotConstructorsRemainUnconstrainedAndObservationCopiesCannotBeMutated() { + EffectiveTopology topology = topology(); + Set owners = new LinkedHashSet(Collections.singleton("V2")); + Map> observations = new LinkedHashMap>(); + observations.put("NODE:X", owners); + DispatchSnapshot guarded = new DispatchSnapshot(Collections.singletonList(task("O1", "S1", "A", 1)), + Collections.singletonList(vehicle("V1", "A")), topology, new ResourceLedger(topology), + new WaitForGraph(), 11L, observations); + owners.clear(); + observations.clear(); + assertThrows(UnsupportedOperationException.class, () -> guarded.observedBlockers( + Collections.singletonList("NODE:X"), "V1").clear()); + RecordingTrafficRepository repository = new RecordingTrafficRepository(true); + assertEquals("OBSERVED_TRAFFIC_CONFLICT", coordinator(repository).dispatch(guarded).getHeld().get(0).getReasonCode()); + assertTrue(repository.lastCommit.getResultingLedger().snapshotState().getReservations().isEmpty()); + assertEquals(1, coordinator(repository).dispatch(new DispatchSnapshot(guarded.getReadySteps(), + guarded.getVehicleSnapshots(), topology, guarded.workingLedger(), 11L)).getGranted().size()); + assertEquals(1, coordinator(repository).dispatch(new DispatchSnapshot(guarded.getReadySteps(), + guarded.getVehicleSnapshots(), topology, guarded.workingLedger(), new WaitForGraph(), 11L)).getGranted().size()); + } + + @Test + void invalidObservationKeysAreRejectedRatherThanSilentlyIgnored() { + EffectiveTopology topology = topology(); + assertThrows(IllegalArgumentException.class, () -> new DispatchSnapshot(Collections.emptyList(), + Collections.emptyList(), topology, new ResourceLedger(topology), new WaitForGraph(), 0L, + Collections.singletonMap("NODE:missing", Collections.singleton("V2")))); + } + + @Test + void yieldMustNotBypassObservedTrafficAndPersistedReservationsStayIntact() { + EffectiveTopology topology = new TopologyCompiler().compile(new TopologyDefinition("FT546", 7L, true, + Arrays.asList(new TopologyNode("A", "F1", 0, 0, 1, false, false), + new TopologyNode("X", "F1", 1, 0, 1, false, false), + new TopologyNode("H", "F1", 0, 1, 1, true, true), + new TopologyNode("Y", "F1", 1, 1, 1, false, false)), + Arrays.asList(new TopologyEdge("A", "X", 1, 1), new TopologyEdge("A", "H", 1, 1), + new TopologyEdge("H", "Y", 1, 1), new TopologyEdge("Y", "X", 1, 1)), + Collections.emptyList(), Collections.emptyList(), Collections.emptyList())); + ResourceLedger ledger = new ResourceLedger(topology); + ledger.reserve(new com.ruoyi.wcs.domain.traffic.ReservationRequest("existing", "O2", "S2", "V2", + Collections.singletonList("NODE:X"))); + DispatchSnapshot unguarded = new DispatchSnapshot(Collections.singletonList(task("O1", "S1", "A", 1)), + Collections.singletonList(vehicle("V1", "A")), topology, ledger, 11L); + RecordingTrafficRepository repository = new RecordingTrafficRepository(true); + assertEquals("H", coordinator(repository).dispatch(unguarded).getYielded().get(0).getYieldNodeId()); + DispatchCycleResult result = coordinator(repository).dispatch(new DispatchSnapshot(unguarded.getReadySteps(), + unguarded.getVehicleSnapshots(), topology, ledger, new WaitForGraph(), 11L, + Collections.singletonMap("NODE:H", Collections.singleton("V3")))); + assertTrue(result.getGranted().isEmpty()); + assertTrue(result.getYielded().isEmpty()); + assertEquals(Collections.singleton("V3"), result.getHeld().get(0).getBlockingVehicleIds()); + assertEquals(1, repository.lastCommit.getResultingLedger().usage("NODE:X")); + assertEquals(0, repository.lastCommit.getResultingLedger().usage("NODE:H")); + } + private static DispatchCoordinator coordinator(TrafficStateRepository repository) { AStarRoutePlanner routePlanner = new AStarRoutePlanner(); AtomicInteger sequence = new AtomicInteger();