Ask: Make status/blocked recoverable — require a reason on the status route, let the groomer revisit blocked issues that have no reason, and make the targeted re-groom lever actually bypass the eligibility filter.
Expected files: src/app/api/issues/status/route.ts, src/lib/groomer/selector.ts
Problem
An issue moved to status/blocked via the MCP set_issue_status tool is stranded permanently, with no record of why it was blocked and nothing that will ever surface or revisit it.
Two live examples, both parked 2026-08-23 and untouched since: misospace/foreman-dispatch-bridge#55 and misospace/llmkube-images#199. Both had blockedReason: null and nextGroomingAction: "promote_to_ready", and both had grooming summaries reading "Ready for implementation". Their groomedAt stayed frozen at 2026-08-22 for two days while their repository rows updated every few minutes. They were only noticed because the queue happened to be empty; on a busy queue they are invisible.
Root cause
Entry is unvalidated on one of two paths. src/app/api/issues/groom/route.ts:176 rejects mark_blocked with a 400 unless blockedReason is a non-empty string. src/app/api/issues/status/route.ts accepts any of the six statuses, swaps the label, and never reads or writes the reason fields. So the groom path cannot block without a justification and the status path cannot record one.
Exit is closed by two filters that between them cover every case. src/lib/agent-queue.ts:169 excludes status/blocked from the work queue by design ("parks the issue until a human moves it back"). src/lib/groomer/selector.ts:94 computes:
const eligible =
isUnlabeled || !hasStatus || !hasPriority || !hasAgent || !hasLane || isBacklog;
An issue is groomable only if it is missing a label or sitting in backlog. A fully-labelled blocked issue has a status, a priority, an agent, and a lane, and is not in backlog — every clause is false. It is ineligible forever. Invisible to the coder, invisible to the groomer.
The escape hatch does not reach it. selector.ts:50 added a targeted re-groom for exactly this, with a comment calling the previous behaviour "a one-way door" (#793). But it only skips buildGroomingStateExclusionWhere. The eligible filter still runs afterwards, so a targeted re-groom by issue number still returns no candidate for a fully-labelled blocked issue.
Acceptance
src/app/api/issues/status/route.ts requires a non-empty reason when the target status is blocked, and persists it to blockedReason — parity with groom/route.ts:176. Other statuses are unaffected.
src/lib/groomer/selector.ts treats a status/blocked issue with blockedReason: null as eligible. A null reason means no decision was recorded, so it should be revisited rather than stranded.
- The targeted re-groom path (
options.issueNumber supplied) bypasses the eligible filter as well as the state exclusion, so run_groomer against a specific blocked issue can actually reach it.
- Tests cover: blocking via the status route without a reason is rejected; a blocked issue with a null reason is selected by the groomer; a blocked issue with a reason is still excluded from routine grooming (the existing park behaviour must not regress); and a targeted re-groom returns a fully-labelled blocked issue as a candidate.
- Do not change
agent-queue.ts — excluding blocked issues from the work queue is correct and deliberate. The fix is that issues should not be able to enter that state unexplained, and should be recoverable once there.
Note
The two example issues above have been manually moved back to status/ready, so they are no longer reproductions. Construct the test cases from the described state rather than expecting to find them blocked.
Ask: Make
status/blockedrecoverable — require a reason on the status route, let the groomer revisit blocked issues that have no reason, and make the targeted re-groom lever actually bypass the eligibility filter.Expected files:
src/app/api/issues/status/route.ts,src/lib/groomer/selector.tsProblem
An issue moved to
status/blockedvia the MCPset_issue_statustool is stranded permanently, with no record of why it was blocked and nothing that will ever surface or revisit it.Two live examples, both parked 2026-08-23 and untouched since:
misospace/foreman-dispatch-bridge#55andmisospace/llmkube-images#199. Both hadblockedReason: nullandnextGroomingAction: "promote_to_ready", and both had grooming summaries reading "Ready for implementation". TheirgroomedAtstayed frozen at 2026-08-22 for two days while their repository rows updated every few minutes. They were only noticed because the queue happened to be empty; on a busy queue they are invisible.Root cause
Entry is unvalidated on one of two paths.
src/app/api/issues/groom/route.ts:176rejectsmark_blockedwith a 400 unlessblockedReasonis a non-empty string.src/app/api/issues/status/route.tsaccepts any of the six statuses, swaps the label, and never reads or writes the reason fields. So the groom path cannot block without a justification and the status path cannot record one.Exit is closed by two filters that between them cover every case.
src/lib/agent-queue.ts:169excludesstatus/blockedfrom the work queue by design ("parks the issue until a human moves it back").src/lib/groomer/selector.ts:94computes:An issue is groomable only if it is missing a label or sitting in backlog. A fully-labelled blocked issue has a status, a priority, an agent, and a lane, and is not in backlog — every clause is false. It is ineligible forever. Invisible to the coder, invisible to the groomer.
The escape hatch does not reach it.
selector.ts:50added a targeted re-groom for exactly this, with a comment calling the previous behaviour "a one-way door" (#793). But it only skipsbuildGroomingStateExclusionWhere. Theeligiblefilter still runs afterwards, so a targeted re-groom by issue number still returns no candidate for a fully-labelled blocked issue.Acceptance
src/app/api/issues/status/route.tsrequires a non-empty reason when the target status isblocked, and persists it toblockedReason— parity withgroom/route.ts:176. Other statuses are unaffected.src/lib/groomer/selector.tstreats astatus/blockedissue withblockedReason: nullas eligible. A null reason means no decision was recorded, so it should be revisited rather than stranded.options.issueNumbersupplied) bypasses theeligiblefilter as well as the state exclusion, sorun_groomeragainst a specific blocked issue can actually reach it.agent-queue.ts— excluding blocked issues from the work queue is correct and deliberate. The fix is that issues should not be able to enter that state unexplained, and should be recoverable once there.Note
The two example issues above have been manually moved back to
status/ready, so they are no longer reproductions. Construct the test cases from the described state rather than expecting to find them blocked.