fix(OPS94): fila de claim não morre mais em issue ready sem frontmatter id (skip + aviso; #659 ganha id B195-F1)

- buildClaimQueue exclui Issues ready sem frontmatter id (não viram worktree
  por construção) e withoutIdReadyIssues() expõe as puladas
- agent:claim / worktree next / pnpm issue next avisam com os números em vez
  de morrer — uma Issue quebrada não trava mais a fila
- pin novo em agentPoolEligibility.unit.spec.ts

Closes #864
This commit is contained in:
Francisco Solla 2026-08-24 17:05:18 -03:00
parent 87ffda56e7
commit 74452cde4f
6 changed files with 61 additions and 7 deletions

View file

@ -0,0 +1,3 @@
# OPS94 — Fila de claim: Issue `ready` sem frontmatter id sai da fila com aviso (2026-08-24)
**OPS94 (Issue #864) — `worktree next` morria com "Issue sem frontmatter id" e a Issue travava a fila inteira (2026-08-24):** a limpeza manual do tracker deu `ready` a débitos antigos e a #659 (B195/D11, wizard "Registrar pedido") — única `ready` sem bloco frontmatter — **envenenava a fila**: todo pick sem `--issue` caía nela e morria antes do flip de labels (o design já morre antes do claim — "fail loudly, never invent" — mas trava a fila atrás dela indefinidamente). **Correção em duas camadas.** _Dados:_ #659 ganhou frontmatter (`id: B195-F1`, `depends: []`, `priority: P2`, `kind: defect`), título renomeado para `B195-F1 — …` e label `prio:P2` — volta a claimar normalmente (ao vivo: `agent:claim --dry-run` lista `#659 [prio:P2] B195-F1`). _Código:_ `buildClaimQueue` agora **exclui** Issues `ready` sem id (não viram worktree por construção — `branchNameForIssue` falha por contrato); novo `withoutIdReadyIssues()` expõe as puladas; `agent:claim` / `worktree next` / `pnpm issue next` **avisam com os números** em vez de morrer — uma Issue quebrada nunca mais trava a fila. _Teste:_ pin novo em `agentPoolEligibility.unit.spec.ts` (exclusão da fila + lista de puladas). Verificação: tsc limpo, lint limpo, suíte unit completa 2458/2458 verde.

View file

@ -21,6 +21,7 @@ import {
dieAgent,
issuesById,
parseArgs,
withoutIdReadyIssues,
} from './lib/agent-forgejo.mjs'
import { githubApi as api } from './lib/github-api.mjs'
@ -33,12 +34,24 @@ const openReady = await api.listIssues({ state: 'open', labels: 'ready', limit:
// identical ordering/filtering, pinned by agentPoolEligibility.unit.spec.ts).
const queue = buildClaimQueue(openReady, await issuesById())
const skippedWithoutId = withoutIdReadyIssues(openReady)
if (skippedWithoutId.length > 0) {
console.warn(
`[agent:claim] ${skippedWithoutId.length} issue(s) \`ready\` sem frontmatter id — fora da fila (não viram worktree): ` +
`${skippedWithoutId.map(({ issue }) => `#${issue.number}`).join(', ')} — adicione o bloco frontmatter (id/depends/...) e re-role.`,
)
}
if (flags['dry-run'] || flags['dryrun']) {
console.log(`[agent:claim] ${queue.length} unblocked ready issue(s):`)
for (const entry of queue) {
const model = entry.meta.model ? ` model:${entry.meta.model}` : ''
console.log(` #${entry.issue.number} [${entry.priority}]${model} ${entry.issue.title}`)
}
for (const { issue } of skippedWithoutId) {
console.log(` #${issue.number} [SKIP — sem frontmatter id] ${issue.title}`)
}
process.exit(0)
}

View file

@ -19,9 +19,15 @@ import { githubApi as api } from './lib/github-api.mjs'
const die = dieAgent('issue')
const cmdNext = async () => {
const pick = await nextClaimableIssue()
const { entry: pick, skippedWithoutId } = await nextClaimableIssue()
if (!pick) {
die('Fila vazia — nada `ready` desbloqueado. Rode `pnpm issue all` para a overview.')
const skipped =
skippedWithoutId.length > 0
? ` Issues \`ready\` sem frontmatter id fora da fila: ${skippedWithoutId.map(({ issue }) => `#${issue.number}`).join(', ')}.`
: ''
die(
`Fila vazia — nada \`ready\` desbloqueado. Rode \`pnpm issue all\` para a overview.${skipped}`,
)
}
const { issue, meta, priority, satisfiedWithoutIssue } = pick
const id = typeof meta.id === 'string' ? meta.id : null

View file

@ -100,6 +100,18 @@ const entryForIssue = (issue, byId, doneIds) => {
}
}
/** Ready issues without a frontmatter `id` — unworkable: `branchNameForIssue`
* fails loudly and they can never become a worktree, so they stay out of the
* queue instead of poisoning `worktree next` forever.
*
* @param {Array<any>} openReady
* @returns {Array<{ issue: any, meta: Record<string, any> }>}
*/
export const withoutIdReadyIssues = (openReady) =>
openReady
.map((issue) => ({ issue, meta: parseFrontmatter(issue.body).meta }))
.filter(({ meta }) => typeof meta.id !== 'string' || meta.id.length === 0)
/**
* Claim queue shared by `agent:claim` and the agent pool: open `ready` issues
* filtered to UNBLOCKED (every frontmatter `depends` id is closed or labeled
@ -115,15 +127,17 @@ const entryForIssue = (issue, byId, doneIds) => {
export const buildClaimQueue = (openReady, byId) => {
const doneIds = doneIdsOf(byId)
return openReady
const queue = openReady
.map((issue) => entryForIssue(issue, byId, doneIds))
.filter((entry) => entry.blockedBy.length === 0)
.filter((entry) => typeof entry.meta.id === 'string' && entry.meta.id.length > 0)
.sort((a, b) => {
const rank =
priorityRank(a.priority.replace('prio:', '')) -
priorityRank(b.priority.replace('prio:', ''))
return rank !== 0 ? rank : a.issue.createdAt.localeCompare(b.issue.createdAt)
})
return queue
}
/**
@ -225,7 +239,7 @@ export const setLabels = (number, { add = [], remove = [] }) =>
export const nextClaimableIssue = async () => {
const openReady = await api.listIssues({ state: 'open', labels: 'ready', limit: 200 })
const queue = buildClaimQueue(openReady, await issuesById())
return queue[0] ?? null
return { entry: queue[0] ?? null, skippedWithoutId: withoutIdReadyIssues(openReady) }
}
export const parseArgs = (argv, flagsWithValue) => {

View file

@ -466,9 +466,15 @@ const pickNextIssue = async ({ requestedIssueNumber, die }) => {
return { entry, reopened: false, directed: true }
}
const pick = await nextClaimableIssue()
const { entry: pick, skippedWithoutId } = await nextClaimableIssue()
if (!pick) {
die('Fila vazia — nada `ready` desbloqueado. Rode `pnpm agent:status` para ver a fila.')
const skipped =
skippedWithoutId.length > 0
? ` Issues \`ready\` sem frontmatter id fora da fila (corrija antes de re-rodar): ${skippedWithoutId.map(({ issue }) => `#${issue.number}`).join(', ')}.`
: ''
die(
`Fila vazia — nada \`ready\` desbloqueado. Rode \`pnpm agent:status\` para ver a fila.${skipped}`,
)
}
return { entry: pick, reopened: false, directed: false }
}

View file

@ -6,6 +6,7 @@ import {
buildClaimQueue,
claimQueueEntry,
claimTargetVerdict,
withoutIdReadyIssues,
} from '../../scripts/lib/agent-forgejo.mjs'
import {
buildPoolQueue,
@ -167,8 +168,9 @@ describe('buildClaimQueue parity (shared with agent:claim)', () => {
number: 2,
createdAt: '2026-07-03T00:00:00Z',
labels: [{ name: 'ready' }, { name: 'prio:P0' }],
body: fm([]),
})
const p2New = issue({ number: 3, createdAt: '2026-07-02T00:00:00Z' })
const p2New = issue({ number: 3, createdAt: '2026-07-02T00:00:00Z', body: fm([]) })
const queue = buildClaimQueue([p2New, p2Old, p0], byId)
expect(queue.map((item) => item.issue.number)).toEqual([2, 1, 3])
expect(queue[1]!.satisfiedWithoutIssue).toEqual(['B9'])
@ -180,6 +182,16 @@ describe('buildClaimQueue parity (shared with agent:claim)', () => {
const queue = buildClaimQueue([blocked, free], byId)
expect(queue.map((item) => item.issue.number)).toEqual([2])
})
it('excludes ready issues without a frontmatter id and surfaces them via withoutIdReadyIssues', () => {
const withId = issue({ number: 1, body: fm([]) })
const withoutId = issue({ number: 2, body: 'defeito sem frontmatter' })
const queue = buildClaimQueue([withoutId, withId], byId)
expect(queue.map((item) => item.issue.number)).toEqual([1])
expect(
withoutIdReadyIssues([withoutId, withId]).map(({ issue: skipped }) => skipped.number),
).toEqual([2])
})
})
describe('claimQueueEntry (single-issue shape for `worktree next --issue` reopen)', () => {