Problem
Once the groomer marks an issue blocked or not-ready, that issue can never be groomed again, so the field that parked it can never be cleared. Recording the blocker is what makes the issue unreviewable.
buildGroomingStateExclusionWhere in src/lib/issue-filters.ts excludes any issue with a non-null blockedReason or notReadyReason:
return {
AND: [
{ OR: [{ groomedAt: null }, { groomedAt: { lt: cutoff } }] },
{ blockedReason: null },
{ notReadyReason: null },
],
};
selectGroomingCandidate in src/lib/groomer/selector.ts applies that unconditionally. Those two fields are cleared only inside the groom route in src/app/api/issues/groom/route.ts, which runs only on an issue the selector picked. That is a closed loop.
Note the asymmetry with the neighbouring clause: the groomedAt cooldown is time-bounded and self-clears after 24h. The blocked/not-ready exclusion has no clearing condition at all.
There is also no manual escape. The exclusion is appended after the options.issueNumber filter is applied, so a targeted re-groom of a specific blocked issue also returns nothing.
Evidence
misospace/alert-triage#36. The groomer parked it on 2026-08-13 and identified its prerequisites correctly:
groomedBy: hosted-groomer
groomedAt: 2026-08-13T05:58:37Z
blockedReason: "Blocked by unresolved dependencies and validation gate ..."
needsInfoReason: "Dependencies #34 for path, #35 for diff, and prerequisite #17
for evidence signal are not confirmed resolved ..."
nextGroomingAction: "mark_needs_info"
All three prerequisites have since closed: alert-triage#34 on 2026-08-14, #17 on 2026-08-14, #35 on 2026-08-16. The issue has been genuinely unblocked since 08-16 and still carries status/backlog with groomedAt frozen at 08-13. It is invisible to the queue because src/lib/agent-queue.ts filters status/backlog.
The intent to revisit is already recorded and unread. route.ts sets nextGroomingAction to "Revisit once the blocking condition is resolved" for mark_not_ready and "Resolve the blocking dependency" for mark_blocked. Nothing consumes either string.
Scope note: dispatch#765 is in the same state, but its blockedReason is an accepted risk rather than a dependency, so it is parked correctly. The bug is that nothing can revisit it either.
Proposal
Any one of these fixes the deadlock; the third is the one that matches what the groomer already knows.
- Make
options.issueNumber bypass the grooming-state exclusion, so a targeted re-groom is possible. Minimum viable, restores a manual lever.
- Give blocked/not-ready a cooldown the way
groomedAt has one, so parked issues re-enter the candidate pool periodically instead of never.
- Parse
#N references out of blockedReason and needsInfoReason and make the issue eligible again once every referenced issue is closed. The groomer already writes those numbers.
Acceptance
- An issue with a non-null
blockedReason can be re-groomed, by whichever trigger is chosen.
- alert-triage#36 specifically becomes selectable again and is re-evaluated against its now-closed prerequisites.
- A test covers the deadlock directly: set
blockedReason, assert the issue is still reachable by the selector under the chosen condition.
- dispatch#765 stays parked, since its blocker is an accepted risk rather than a dependency, but is re-evaluated rather than excluded outright.
Files: src/lib/issue-filters.ts (buildGroomingStateExclusionWhere is the exclusion), src/lib/groomer/selector.ts (selectGroomingCandidate applies it after the issueNumber filter), src/app/api/issues/groom/route.ts (the only place the reason fields are cleared).
Problem
Once the groomer marks an issue blocked or not-ready, that issue can never be groomed again, so the field that parked it can never be cleared. Recording the blocker is what makes the issue unreviewable.
buildGroomingStateExclusionWhereinsrc/lib/issue-filters.tsexcludes any issue with a non-nullblockedReasonornotReadyReason:selectGroomingCandidateinsrc/lib/groomer/selector.tsapplies that unconditionally. Those two fields are cleared only inside the groom route insrc/app/api/issues/groom/route.ts, which runs only on an issue the selector picked. That is a closed loop.Note the asymmetry with the neighbouring clause: the
groomedAtcooldown is time-bounded and self-clears after 24h. The blocked/not-ready exclusion has no clearing condition at all.There is also no manual escape. The exclusion is appended after the
options.issueNumberfilter is applied, so a targeted re-groom of a specific blocked issue also returns nothing.Evidence
misospace/alert-triage#36. The groomer parked it on 2026-08-13 and identified its prerequisites correctly:
All three prerequisites have since closed: alert-triage#34 on 2026-08-14, #17 on 2026-08-14, #35 on 2026-08-16. The issue has been genuinely unblocked since 08-16 and still carries
status/backlogwithgroomedAtfrozen at 08-13. It is invisible to the queue becausesrc/lib/agent-queue.tsfiltersstatus/backlog.The intent to revisit is already recorded and unread.
route.tssetsnextGroomingActionto"Revisit once the blocking condition is resolved"formark_not_readyand"Resolve the blocking dependency"formark_blocked. Nothing consumes either string.Scope note: dispatch#765 is in the same state, but its
blockedReasonis an accepted risk rather than a dependency, so it is parked correctly. The bug is that nothing can revisit it either.Proposal
Any one of these fixes the deadlock; the third is the one that matches what the groomer already knows.
options.issueNumberbypass the grooming-state exclusion, so a targeted re-groom is possible. Minimum viable, restores a manual lever.groomedAthas one, so parked issues re-enter the candidate pool periodically instead of never.#Nreferences out ofblockedReasonandneedsInfoReasonand make the issue eligible again once every referenced issue is closed. The groomer already writes those numbers.Acceptance
blockedReasoncan be re-groomed, by whichever trigger is chosen.blockedReason, assert the issue is still reachable by the selector under the chosen condition.Files:
src/lib/issue-filters.ts(buildGroomingStateExclusionWhereis the exclusion),src/lib/groomer/selector.ts(selectGroomingCandidateapplies it after the issueNumber filter),src/app/api/issues/groom/route.ts(the only place the reason fields are cleared).