Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
144
planned
1,365
shipped

Motir · Roadmap

MOTIR-3927Done

17 Roadmap canvas draws ARCHIVED cards as red "blocked elsewhere" ghosts — the level read excludes them as nodes, then its edge read puts them straight back, by name

Opened by Zhu Yue ·

Type · code · Repo motir-core · One PR. Parent · parentless at the ROOT. Found out-of-band (not inside any story's run), so there is no finding story to carry an edge into, and no discovery epic — the same placement MOTIR-3490 and MOTIR-3557 hold. Discovered in · manual dogfooding of /roadmap, 2026-08-29 — archived cards appearing on the canvas as "blocked elsewhere" cards.

The symptom

An archived work item is drawn on the roadmap canvas as a red, hatched "blocked elsewhere" ghost anchor — named, with its identifier and title — attached by a red cross edge to a live card that still carries a blocked_by to it. The live card is flagged crossBlocked in consequence.

The sharpest form: archive a card that is a blocker of its own sibling, and it leaves the level as a node and immediately comes back beside its former position as a warning. The canvas both hides the card and shouts about it.

Root cause — VERIFIED, one missing predicate

The roadmap's per-level read is built from three queries that disagree about archived rows:

readarchived rows
workItemRepository.findProjectTreeLevel (lib/repositories/workItemRepository.ts:3213)EXCLUDEDAND w."archivedAt" IS NULL
workItemLinkRepository.findBlockedByEdges (lib/repositories/workItemLinkRepository.ts:285-295)INCLUDEDwhere: { fromId: { in: itemIds }, kind: 'is_blocked_by' }, and nothing else
workItemRepository.findRoadmapBlockerStubs (lib/repositories/workItemRepository.ts:991)INCLUDEDwhere: { id: { in: ids } }

So getProjectRoadmap (lib/services/workItemsService.ts:3555-3584) drops the archived row from nodes, keeps its edge, computes offLevelIds = blockers not on the level — which now includes it because it was dropped — and hands it a full naming stub. buildWorkItemLevel (components/planning/workItemLevel.tsx:209-245) then finds the blocked end on the level, the blocker off it, pushes a cross dep, adds the blocked card to crossBlocked, and mints a <GhostAnchor> from the stub. WorkItemNode renders roadmap.canvas.node.blockedElsewhere (components/planning/WorkItemNode.tsx:311).

findBlockedByEdges is the ONE is_blocked_by read in its own file that is missing the rule. Its three siblings all carry toItem: { archivedAt: null }, each citing MOTIR-1328an archived blocker is a STALE edge; it must not appear and it must not gate: findBlockersForItem (:227), findBlockerStatesForItems (:263), findBlockerEdgesForItems (:347); findBlockingItems applies the mirror on fromItem (:464). That sweep reached the readiness reads and stopped short of the roadmap's edge read, which did not exist in a form it recognised.

The consequence is a canvas that contradicts itself in one frame, measured below: the same card reads readiness: { ready: true, openBlockerIds: {} } — because readiness goes through findBlockerStatesForItems, which does exclude the archived blocker — while the canvas beside it draws that card red and cross-blocked. One surface, two reads, opposite verdicts, and the loud one is wrong.

Reproduction — MEASURED, real Postgres, 2026-08-29

Repro run against a local cluster on the harness of tests/integration/work-items/project-roadmap.test.ts (vitest run … --no-file-parallelism). Both cases FAIL on origin/main @ b8a433e00:

(a) cross-story blocker. E › {Story A › A1, Story B › B1}, A1 blocked_by B1, then B1 archived. getProjectRoadmap(project, StoryA) returns:

EDGES [{"blockedId":"<A1>","blockerId":"<B1>"}]
STUBS [{"id":"<B1>","identifier":"PROD-5","title":"Subtask B1",
        "parentTitle":"Story B","isDone":false,"inActiveSprint":false}]
VERDICT (getReadiness on A1) {"ready":true,"openBlockerIds":{},"blockedByAncestorId":null}

Expected edges: [], offLevelBlockers: [].

(b) same-level blocker — the disappearing-then-reappearing card. Story A › {A1, A2}, A1 blocked_by A2, then A2 archived:

CTRL nodes ["PROD-3"]                       ← A2 correctly gone from the level…
EDGES [{"blockedId":"<A1>","blockerId":"<A2>"}]
STUBS [{"id":"<A2>","identifier":"PROD-4","title":"Subtask A2","parentTitle":"Story A",
        "isDone":false,"inActiveSprint":false}]   ← …and back as a named ghost

Both scopes are affected: in scope: 'sprint' the stub's isDone is terminalKeys.has(status) and inActiveSprint is a sprint-id compare, so a card archived while todo and out of the sprint passes the sprint-scope suppression at workItemLevel.tsx:222 too.

Fix direction

Add toItem: { archivedAt: null } to findBlockedByEdges's where, and put the MOTIR-1328 sentence in its doc comment beside the three identical ones already in that file. That is the whole product change: with the archived id never in edges, it never reaches offLevelIds, so no stub is read and no anchor is minted.

Do NOT "also" filter findRoadmapBlockerStubs for safety. The stub list is deliberately BEST-EFFORT — an unresolved blocker still anchors, named by its bare id, which tests/components/workItemLevel.test.tsx:193 guarantees on purpose and MOTIR-3557's amendment records as shipped behaviour. Filtering there would convert this defect from a named false ghost into an anonymous one — strictly worse, and it would hide the real fix.

Acceptance criteria

  1. workItemLinkRepository.findBlockedByEdges (lib/repositories/workItemLinkRepository.ts:285-295) carries toItem: { archivedAt: null } in its where, and its doc comment states the MOTIR-1328 rule in the same words its three siblings at :214, :250 and :309 use.
  2. New integration case in tests/integration/work-items/project-roadmap.test.ts (repro (a)): A1 blocked_by B1 cross-story with B1 archived ⇒ getProjectRoadmap on Story A returns edges: [] and offLevelBlockers: []. It fails before the change and passes after.
  3. New integration case (repro (b)): A1 blocked_by A2 same-level with A2 archived ⇒ the level returns exactly [A1] in nodes, edges: [] and offLevelBlockers: [] — an archived sibling does not leave the level as a node and return as an anchor.
  4. The MOTIR-1331 signal is unweakened: the shipped case "returns an OFF-level blocker edge + a naming STUB for the cross-story anchor" in that same file passes unmodified, with a LIVE blocker still producing one edge and one named stub.
  5. findRoadmapBlockerStubs is unchanged, and the PR body says why in one line (the best-effort-stub guarantee above) so the omission reads as a decision rather than a miss.
  6. The PR body names the two other findBlockedByEdges callers — lib/services/aiSprintPlanningService.ts:236 and :458 — and states their behaviour change: an edge to an archived item stops constraining a proposed sprint packing's order, which is what validate_sprint already does through findBlockerEdgesForItems. Their existing suites stay green.
  7. prettier --check clean and the touched vitest suites green; the diff touches lib/repositories/workItemLinkRepository.ts and tests/integration/work-items/project-roadmap.test.ts only.

Not in scope, stated so it is not silently absorbed

In 'project' scope the off-level path never consults stub.isDone (workItemLevel.tsx:222 gates that on sprint scope), so a satisfied cross-container dependency between two done cards still draws red. That is the shipped MOTIR-1379 framing and MOTIR-3557 already declared it out of scope; it is a different question from this one, because archived is not a status at all — an archived row is soft-REMOVED, not terminal, and no status predicate can reach it.

No design is owed. The fix changes a server read only; the canvas draws strictly FEWER nodes and no new treatment. Every element it still draws — the GhostAnchor face, the cross edge, the crossBlocked ring — is already specified in design/roadmap/edges.mock.html and design-notes.md.

Context refs

  • lib/repositories/workItemLinkRepository.ts:285-295findBlockedByEdges, the read this card changes; :214, :250, :309, :439 — the four sibling doc comments carrying the MOTIR-1328 rule it is missing.
  • lib/repositories/workItemRepository.ts:3213findProjectTreeLevel's AND w."archivedAt" IS NULL, the exclusion the edge read contradicts; :975-991findRoadmapBlockerStubs, deliberately unchanged.
  • lib/services/workItemsService.ts:3555-3584getProjectRoadmap's edge read, offLevelIds and the offLevelBlockers stub mapping.
  • components/planning/workItemLevel.tsx:209-245 — the edge classifier that mints the cross dep and the GhostAnchor; :222 — the sprint-only isDone suppression.
  • components/planning/WorkItemNode.tsx:311 — the node.blockedElsewhere copy; :455GhostAnchor.
  • lib/services/aiSprintPlanningService.ts:236, :458 — the two other callers of the changed read.
  • tests/integration/work-items/project-roadmap.test.ts:129"excludes archived items from the level", the sibling assertion this defect sits directly beside; :206-235 — the off-level-blocker cases the new ones join.
  • tests/components/workItemLevel.test.tsx:193 — the shipped best-effort-anchor guarantee that rules out the findRoadmapBlockerStubs "fix".
  • MOTIR-1328 (the rule this read is missing), MOTIR-2050 (the same class on the detail page), MOTIR-3557 (the prior defect in this exact edge path).

Resolution

Open.

Discussion

No comments yet.

Adding to this discussion signs you in on app.motir.co and brings you back to this request.

Add a comment