OPER-73
Classify legacy IN_PROGRESS tickets so OPER-64 gate can gate them + stuck-loop reaper
Suggested
Justin Cooke
Body
feature_id: FEAT-studio-planning-validation
## Context
The OPER-64 plan-size gate (PR #178, merged [REDACTED-DOB]) refuses tickets whose declared `size` is `L` or `XL`. But it can only refuse tickets whose `size` column is populated. Legacy tickets imported from Linear before OPER-D5 landed classification carry `size=null`, `band=null`, `writeSets=[]` on the row — the YAML frontmatter in the body says `size: L` but that value was never persisted.
Concrete example (root-cause of the dispatch loop that triggered this ticket): POR-168 "Build Group Advocacy Network" has `size=null`, `band=null`, `writeSets=[]` on the Task row. YAML in the body says `size: L`. Last PlanningValidation was [REDACTED-DOB], three days before OPER-64 landed. Between [REDACTED-DOB] and [REDACTED-DOB] the dispatcher opened and closed 33 PRs for POR-168, all ~1900 LOC, all `size-override-blocked`, burning ~4 CI-hours in a single day.
Root cause: `evaluatePlanSizeBudget({size:null, band:null}, empty-plan)` returns `promotable=true` because it estimates ~0 LOC on empty writeSets and there's no declared size to refuse against.
DOC memo ([REDACTED-DOB]) authorized this fix. See `docs/doc/[REDACTED-DOB]-por-168-dispatch-loop.md` (to be added in this PR).
## Acceptance Criteria
- [ ] `scripts/classify-legacy-tickets.ts` reads all `Task` rows where `status IN ('BACKLOG','READY_FOR_PLANNING','PLANNING_VALIDATED','IN_PROGRESS')` AND `size IS NULL`.
- [ ] For each row, parses the YAML frontmatter in `body` (if present) and extracts `size:` if the value is a valid `TaskSize` enum member.
- [ ] For rows without parseable YAML `size`, invokes the existing OPER-53 estimator (`estimatePlanDiff` on the writeSets/§4 file list) to derive a band, and maps band -> size (`XLARGE`->L, `LARGE`->M, `MEDIUM`->M, `SMALL`->S, `XSMALL`->XS).
- [ ] `--dry-run` mode (default) writes proposed classifications to `scripts/classify-legacy-tickets.dry-run.json` and prints a summary table. No DB writes.
- [ ] `--apply` mode writes `Task.size` and `Task.band` for each row and emits `legacy_ticket.classified` event per row with `{ ticketId, taskId, priorSize, priorBand, newSize, newBand, sourceOfTruth: 'body-yaml' | 'estimator' | 'unclassifiable' }`.
- [ ] Idempotent: re-running `--apply` writes nothing for rows already classified.
- [ ] Unclassifiable rows (no YAML, no writeSets) are labelled `needs-triage` and reported in the summary but not written to `size`/`band`.
- [ ] Adds a `stuck-loop` label + reaper detection: if a Task has closed >3 PRs in the trailing 24h window, label it `stuck-loop` and set `status='AWAITING_HUMAN'` with `parkedReason='stuck-loop'`.
- [ ] Reaper emits `dispatcher.stuck_loop_detected` event with `{ ticketId, taskId, priorStatus, currentStatus, attemptCount, windowHours: 24 }`.
- [ ] `#incidents` Slack alert wired on `dispatcher.stuck_loop_detected` events (via existing event -> Slack pipeline; no new webhook).
- [ ] Runbook entry `docs/operations/stuck-loop-runbook.md` added with detection query, resolution steps, and escalation path.
- [ ] Unit tests for the classifier: parseable YAML happy path, malformed YAML, missing YAML falls through to estimator, unclassifiable path, idempotency.
- [ ] Unit tests for reaper: 3-PR-in-24h threshold on synthetic Task+PR fixtures.
- [ ] `pnpm lint && pnpm typecheck && pnpm test` all green in CI.
## Non-Negotiables (DOC memo [REDACTED-DOB])
1. Roan (SRE Veteran) — `legacy_ticket.classified` event per row, no silent writes.
2. Nika (Chaos Engineer) — `--dry-run` output reviewed by Justin before `--apply`.
3. Devi (Platform PM) — `stuck-loop` label auto-applied by reaper on >3 PRs in 24h.
4. Priya (Incident Commander) — Runbook + `#incidents` Slack alert.
5. Toma (Observability Engineer) — both events (`legacy_ticket.classified`, `dispatcher.stuck_loop_detected`) with full payload.
6. Suki (DevEx Advocate) — `pnpm tsx scripts/classify-legacy-tickets.ts --dry-run` runs locally with no infra deps.
## Files / Modules in Scope
### Create
- `scripts/classify-legacy-tickets.ts` — main classifier
- `scripts/classify-legacy-tickets.test.ts` — unit tests
- `apps/web/src/lib/stuck-loop-detector.ts` — reaper's stuck-loop detection
- `apps/web/src/lib/__tests__/stuck-loop-detector.test.ts` — unit tests
- `docs/operations/stuck-loop-runbook.md` — runbook
- `docs/doc/[REDACTED-DOB]-por-168-dispatch-loop.md` — the DOC memo that authorized this ticket
### Modify
- `apps/web/src/app/api/cron/reaper/route.ts` (or wherever the workflow-reaper lives) — wire stuck-loop detection into the tick
- `apps/web/src/lib/plan-size-budget.ts` — export `estimatePlanDiff` if it isn't already exported (needed by classifier)
## Migration Safety
No schema change. Writes to existing `Task.size` and `Task.band` columns only. All writes gated behind explicit `--apply` flag. `--dry-run` is the default. Rollback: `UPDATE Task SET size=NULL, band=NULL WHERE identifier IN (<dry-run manifest>)` — one SQL per row group.
## Verification
`pnpm tsx scripts/classify-legacy-tickets.ts --dry-run` locally + full CI green.
```yaml
ticket_id: OPER-73
size: M
has_migration: false
phi_in_scope: false
verification_cmd: "pnpm test --filter operant-studio-web && pnpm tsx scripts/classify-legacy-tickets.ts --dry-run"
target_branch: main
module: dispatcher
```
## Dependencies
- Blocked by: none (OPER-64 already shipped)
- Blocks: no downstream ticket blocked; POR-168 remains AWAITING_HUMAN until this ships and reclassifies it.
Attachments
Loading attachments…
Comments
Loading comments…