Operant Studio
OPER-69

Split flow must demote parent to CANCELED + close its stale PR (POR-249 caused #1257)

Suggested
Justin Cooke

Body

feature_id: FEAT-studio-dispatcher

## Context

On [REDACTED-DOB], POR-249 was split into POR-509 / POR-510 / POR-511 via the manual "split ticket" flow (PR #1244 was rejected as size/XL, three split children were filed). PR #1244 was closed. POR-249's Task row was **not** demoted to a terminal state — it stayed at `IN_PROGRESS`.

Later the same day the auto-planner cron re-enumerated POR-249, dispatched it, and opened PR #1257 — an identical size/XL implementation from the same head commit as #1244. The re-attempt was blocked by `Check PR size` and `Validate reviewer artifacts` (correctly), but consumed:

- 1 agent slot for ~15 minutes
- 1 Vercel preview deploy
- 1 Neon branch
- The human attention it took to spot the duplicate and close it

Direct cost is low; the systemic issue is that a ticket-split has no atomic "supersede the parent" side effect. Any parent left at a promotable status (`READY_FOR_PLANNING`, `PLANNING_VALIDATED`) or an in-flight status (`IN_PROGRESS`, `IN_REVIEW`) will be re-picked-up as soon as its previous PR closes. The manual remediation is `PATCH /tasks/[id] { status: "CANCELED", labels: [...] }` after every split, and it is easy to forget.

Adjacent issue: `Task.canceledAt` was never populated on the manual `CANCELED` write. The PATCH route only stamps `completedAt` on `DONE` transitions. Terminal-state timestamps are inconsistent — see `apps/web/src/app/api/studio/[team]/tasks/[id]/route.ts` around the `completedAt` block.

## Scope

Two related fixes, one PR:

1. **Split-parent supersede.** When the split flow creates N child tickets from a parent, the parent must atomically:
   - Transition to `CANCELED` (or a new `SUPERSEDED` state if the team wants a distinct terminal — recommend `CANCELED` with a `split-superseded` label to keep the enum flat).
   - Get a `supersedes-<child-identifier>` label for each child.
   - Stamp `canceledAt` (see fix #2).
   - Cancel any open PR authored by the auto-planner for this ticket (via `gh pr close --comment "superseded by split; see POR-XXX / POR-YYY / POR-ZZZ"`).
   - Emit an `AuditLog(action="task.split.superseded")` row with `after: { childIdentifiers: [...] }`.

2. **Terminal-state timestamp consistency.** Extend the PATCH route in `apps/web/src/app/api/studio/[team]/tasks/[id]/route.ts` to stamp `canceledAt` on any `status -> "CANCELED"` transition and `firstReadyAt` on any first `READY_FOR_PLANNING` transition. Mirror the existing `completedAt` on `DONE` pattern. Preserve existing stamps on re-writes.

## Acceptance Criteria

- A ticket-split flow (invoke it via the existing UI or the API endpoint that backs it — grep for `splitTicket` or `POST /tasks/[id]/split` first) demotes the parent to `CANCELED` with `split-superseded` + one `supersedes-*` label per child, in the same transaction as the children being created.
- Any open PR whose head branch matches `agent/{parentIdentifier}-*` is closed with the auto-comment "superseded by split; see <children>".
- `canceledAt` is populated on a `PATCH /tasks/[id] { status: "CANCELED" }` — verified against a fresh row in the integration test.
- A regression test in `apps/web/src/lib/__tests__/task-split.integration.test.ts` (or the existing split test file if one exists — check first) covers: parent-goes-to-CANCELED, children-exist, existing-PR-closed, audit-row-written.
- `docs/runbooks/split-ticket-manual-cleanup.md` is deleted or replaced with a one-line "no manual cleanup needed" pointer to this ticket.

## Non-goals

- Auto-splitting oversized tickets. Splits are still manual today; automation is out of scope.
- Introducing a new `SUPERSEDED` enum value. `CANCELED` + label carries the signal fine and avoids the migration cost.
- Cleaning up historical split-parents that are stuck at `IN_PROGRESS` today. Enumerate them in a follow-up if there are more than a handful (POR-249 was the only one I found; that one is manually fixed).

## Verification

`pnpm --filter web vitest run src/lib/__tests__/task-split.integration.test.ts`

Then manually: create a synthetic parent ticket, split it, and confirm (a) the parent is CANCELED with the labels, (b) `canceledAt` is stamped, (c) any prior PR for that identifier is closed with the auto-comment.

Attachments

Loading attachments…

Comments

Loading comments…