Skip to content

A loop edge into a parallel row is drawn through the sibling card #2138

Closed

Description

@HugoXK

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment on this issue.

Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: c3195c5338ed4be889da8f36b469548b
Branch: dev/4-394-loop-wire
Base: ddb4930
At: 2026-11-02T09:21:42Z

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment.

Written-by: FlyBot Lead

What the user sees

A team adds a loop edge to the workflow graph — the sidebar's Add link offers kind=Loop, and a loop back into a parallel layer (say, a failed deploy returning to Review) is the normal use. The loop is drawn, but its return run passes behind the card of the sibling node: the visible segment looks like the loop goes to the wrong node, and only a short stub with an arrowhead shows at the right edge.

Reproduction

Repository factory graph, no hand-written coordinates:

const graph = normalizeGraph(deliveryWorkflow());
graph.edges.push({ from: 'deployer', to: 'reviewer', loop: true, max: 3 });
wireRoutes(graph.nodes, graph.edges);

ranksOf skips loop edges, so reviewer stays on the same rank as tester (world positions: planner(0,0) writer(0,108) coder(0,216) reviewer(-94,324) tester(94,324) deployer(0,432); reviewer box [-178,-10], tester box [10,178], same top 324).

The back-edge route comes out as M 84 468 L 192 468 L 192 360 L -10 360: its horizontal run at y = 360 crosses the tester card box [10,178]×[324,396], hitting at [176,360]. The reverse loop { from: 'reviewer', to: 'coder', loop: true } crosses on its departure run at [12,360] at the default card height ([12,348] at the measured-48 one).

This holds at every card height (authored 72 → y 360; measured ~48 → 348; test 96 → 372) and at every width: fitLayout scales the whole world together (reach 202; inner width 600 → scale 1, 300 → scale 0.743), so the hit is preserved.

Root cause

core/src/client/workflows.js — wireRoutes handles forward edges (to.top >= from.top) with a lane search and the straight guard that consults cutsCard(boxes, x1, y1, x2, y2, from, to). The back-edge branch (to.top < from.top) emits M x1 y1 L lane y1 L lane y2 L x2 y2 with no card check at all, so a horizontal run through a sibling on either rank is never detected.

#2126 fixed the forward-side crossing and its fixture offsets the two same-rank cards by 51px (claim(-94,108) vs forensics(94,159)), so the same-rank case never ran — the rule missed back edges rather than the implementation lagging the rule.

Expected behaviour

A back edge must not pass through a non-endpoint card, the same rule forward edges already obey. Either:

  • turn each horizontal run at its own card's right edge, step vertically to just outside the rank band (top − 14 / bottom + 14), and cross ranks there; or
  • apply the seam rule #1662/#1663 use for a wire into a wrapped flow layer, if it fits this geometry better.

Scope (XS)

  • core/src/client/workflows.js: the back-edge branch only.
  • core/test/workflows-page.test.js: two cases — deliveryWorkflow() plus the deployer→reviewer loop, and the reviewer→coder loop — each asserting that sample points along the drawn path fall in no non-endpoint card box (not pixel comparison). Both red on base ddb4930e, green after. The file bundles workflows.js through an esbuild stdin entry, so the factory graph is reachable by adding deliveryWorkflow from plugins/workflow/src/template.js to that entry (or by reproducing the same six-node shape explicitly if a cross-package import is unwanted — the factory graph is preferred, since it is what the Product reproduction uses).
  • Mirror the source change into patches/ds-bot/ and re-run generate/check as usual.
  • changelog.d/<slug>.fixed.md (user-visible rendering fix).

Dedupe (checked before opening)

Open issues: no same topic (back edge sibling card — five hits, all closed and different surfaces; #1662/#1663 are the same class of crossing on the wrapped-layer path, #2125/#2126 are the forward-edge routing change this one regresses). Held backlog: no "back edge crossing" entry.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions