From 1953df97eccf5a0620d3004acaa315b104c25b66 Mon Sep 17 00:00:00 2001 From: Courier Date: Tue, 13 Oct 2026 21:11:26 +0000 Subject: [PATCH 1/2] feat(lifecycle): persist idempotent claim and status transition evidence (#1202) Add `IssueLifecycleEvent`, the durable worker-neutral event stream for a work item: claim, release, status, completion and merge facts in one append-only table that belongs to the Issue it describes. `Issue` holds only current state and `AuditLog` is per-request and not deduplicated, so neither can tell a first claim from a third attempt. All writers go through one contract (`src/lib/issue-lifecycle.ts`) so the taxonomy, idempotency rules and timestamps cannot drift per route: claim, unclaim and the stale-AgentWork sweep (via the shared release core), board move, manual/agent status, the operator groom route, the hosted groomer, the reconciler, issue sync (including closed-issue reconciliation, now a shared store), the GitHub webhook and the worker task report. Idempotency has two rules, because the writers are not equally repeatable: - Dispatch-authored writes record only a real transition (a claim also counts when ownership changed hands without the status moving), so a redelivered claim or a same-column drag records nothing while claim -> release -> reclaim keeps both attempts and the first claim. - Externally observed events carry the source's own identity as a unique dedupeKey and upsert with an empty update, so a GitHub redelivery or a repeated sync collapses onto the row that already exists. Observed events keep the source's occurrence time in `occurredAt` and the wall clock in `observedAt`, and are never replayed onto Issue state, so a late or out-of-order observation cannot move the present status. Dispatch-authored writes commit their event in the same transaction as the state they describe; observed paths (sync, webhook, reconciler, groomer, worker report) record best-effort so an evidence row can never fail a sync run or a worker report. Keeps the distinctions the issue asks for separate: claim released, failed or blocked attempt, kanban done, issue closed and PR merged are distinct kinds, and a merge is only ever recorded from an observed merged PR - never from a worker's success report. No API/worker behaviour change, no new worker requirement, token or model field; the migration is additive and backfill-free. Taxonomy, idempotency rules and migration notes are in docs/issue-lifecycle-events.md. --- AGENTS.md | 2 + docs/issue-lifecycle-events.md | 128 ++++++ .../migration.sql | 56 +++ prisma/schema.prisma | 64 +++ .../[agentName]/tasks/report/route.test.ts | 91 ++++ .../agents/[agentName]/tasks/report/route.ts | 34 ++ src/app/api/issues/claim/route.test.ts | 100 ++++- src/app/api/issues/claim/route.ts | 29 +- src/app/api/issues/groom/route.test.ts | 68 +++ src/app/api/issues/groom/route.ts | 25 +- src/app/api/issues/move/route.test.ts | 66 +++ src/app/api/issues/move/route.ts | 24 +- src/app/api/issues/reconcile/route.test.ts | 137 ++++++ src/app/api/issues/reconcile/route.ts | 129 +++++- src/app/api/issues/status/route.test.ts | 66 +++ src/app/api/issues/status/route.ts | 38 +- src/app/api/issues/unclaim/route.test.ts | 82 ++++ src/app/api/issues/unclaim/route.ts | 5 +- src/app/api/issues/webhook/route.test.ts | 94 +++- src/app/api/issues/webhook/route.ts | 68 +++ src/app/api/sync/scheduled/route.ts | 16 +- src/lib/groomer/run.test.ts | 29 ++ src/lib/groomer/run.ts | 25 ++ src/lib/heartbeat.ts | 10 +- src/lib/issue-claim.test.ts | 120 +++++ src/lib/issue-claim.ts | 94 +++- src/lib/issue-lifecycle.test.ts | 357 +++++++++++++++ src/lib/issue-lifecycle.ts | 424 ++++++++++++++++++ src/lib/issue-sync.test.ts | 131 +++++- src/lib/issue-sync.ts | 128 +++++- src/lib/report-close.test.ts | 76 ++++ src/lib/report-close.ts | 34 ++ src/lib/stale-work.ts | 11 +- 33 files changed, 2681 insertions(+), 80 deletions(-) create mode 100644 docs/issue-lifecycle-events.md create mode 100644 prisma/migrations/20261013120000_add_issue_lifecycle_events/migration.sql create mode 100644 src/lib/issue-lifecycle.test.ts create mode 100644 src/lib/issue-lifecycle.ts diff --git a/AGENTS.md b/AGENTS.md index 21031f80..d817fe68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,6 +192,7 @@ src/ github.ts # GitHub label/comment API helpers auth.ts # authorizeRequest, getAuthorizedActor (bearer/basic/oidc) lease.ts # Lease TTL, upsert, release, resolveActiveWork + issue-lifecycle.ts # IssueLifecycleEvent taxonomy + idempotent recorder agent-work.ts # AgentWork start/checkpoint/finish, stale recovery assignment-conflicts.ts # label conflict analysis (agent/owner add/remove) kanban.ts # getIssuesByStatus, getIssueStatus @@ -211,6 +212,7 @@ Cross-cutting concerns and where they live. Read these first when working in tha | Assign / unassign (label-only, no lease) | `app/api/issues/actions/route.ts`, `app/api/issues/unassign/route.ts`, `lib/assignment-conflicts.ts` | | AgentWork lifecycle | `app/api/agent-work/route.ts`, `app/api/agent-work/{start,checkpoint,finish}/route.ts`, `lib/agent-work.ts` | | Auth & audit actor identity | `lib/auth.ts` (`authorizeRequest`, `getAuthorizedActor`) | +| Lifecycle event stream (claim/status evidence, #1202) | `lib/issue-lifecycle.ts`, `docs/issue-lifecycle-events.md` | | Board UI component tree | `app/board/page.tsx` → `kanban-board-client.tsx` → `components/kanban-board.tsx` → `components/issue-card.tsx` | | App shell / header / nav | `app/layout.tsx` | | Label & status constants | `types/index.ts` (`BOARD_COLUMNS`, `StatusLabel`, `AGENT_PREFIX`, `OWNER_PREFIX`) | diff --git a/docs/issue-lifecycle-events.md b/docs/issue-lifecycle-events.md new file mode 100644 index 00000000..e36edbf2 --- /dev/null +++ b/docs/issue-lifecycle-events.md @@ -0,0 +1,128 @@ +# Issue lifecycle events + +`IssueLifecycleEvent` is Dispatch's durable, worker-neutral record of what +happened to a work item over its life: who claimed it, who let it go, which +status label moved, and when. It exists because the current state and the audit +log cannot answer a claim-to-merge question: + +- `Issue` holds only the *current* status, so the first claim is + indistinguishable from the third. +- `AuditLog` is per-request, is not deduplicated, and is written by each writer + in its own vocabulary. A redelivered claim, a repeated sync and a real second + attempt all look alike. + +The stream is append-only, belongs to the `Issue` it describes (there is no +product-specific table or side channel), and is written through one contract — +`src/lib/issue-lifecycle.ts` — so the taxonomy, the idempotency rules and the +timestamps cannot drift between routes. + +Non-goals for now: PR merge/link backfill, a duration API or UI, and worker +attempt telemetry/cohorts. + +## Taxonomy + +| Kind | Meaning | Typical source | +|---|---|---| +| `claim` | An agent took ownership. Recorded when the status label moved **or** the `agent/*` label changed (a force-claim between agents moves no status). | `api:claim` | +| `claim_released` | Ownership was given up. Kept distinct from a failed attempt: the status the release left behind is recorded, including when it deliberately retained `status/in-review`. | `api:unclaim`, `stale-recovery` | +| `attempt_failed` | A worker reported a `failed` or `blocked` outcome. Recorded once per report; an idempotent duplicate replays the original run and adds nothing. | `agent-report` | +| `status_changed` | The `status/*` label moved. `fromStatus`/`toStatus` carry the values. | `api:move`, `api:status`, `api:groom`, `hosted-groomer`, `reconciler`, `sync`, `webhook` | +| `completed` | A worker report closed the item as already done (`status/done` + GitHub close). Not evidence that a PR merged. | `agent-report` | +| `issue_closed` | The GitHub issue was observed closing. Distinct from the card reaching Done. | `reconciler`, `sync` | +| `issue_reopened` | The issue was observed reopening after a close. | `sync` | +| `pr_merged` | A PR referencing the item was observed merged. Only ever recorded from an observation of a merge — never from a worker's success report. | `reconciler` | + +`kind` and `source` are stored as strings, not enums, so a new writer can be +added without a migration; `LIFECYCLE_EVENT_KINDS` and `LIFECYCLE_SOURCES` in +`src/lib/issue-lifecycle.ts` are the authoritative lists. + +## Columns that carry provenance + +| Column | Meaning | +|---|---| +| `issueId` | The work item the event belongs to (FK, cascades on delete). | +| `repoFullName`, `issueNumber` | Denormalized for reads that only know GitHub coordinates. | +| `source` | Which writer produced it — `api:*` for routes, `stale-recovery`, `hosted-groomer`, `reconciler`, `sync`, `webhook`, `agent-report`. | +| `actor` | The identity responsible: an agent name, an operator actor, `hosted-groomer`, `reconciler`, or GitHub's sender login. | +| `agentName` | Set when the event is about an agent's work (claim, release, failed attempt). | +| `occurredAt` | When the transition happened. For a Dispatch-authored write this is "now". For an observed event it is the source's own timestamp — the PR's `merged_at`, the issue's `updated_at`, its `closed_at`. | +| `observedAt` | When Dispatch saw it. Never backdated to `occurredAt`. | +| `sourceEventId` | The event's identity in its source system when it has one (GitHub delivery id, `pr-`). Provenance only. | +| `dedupeKey` | Uniqueness identity for an observed event (unique index). See below. | +| `metadata` | Small JSON bag for the extra shape of that writer (force-claim details, grooming run id, pull-request number, block reason). | + +## Which writers record what + +| Writer | Where | What it records | +|---|---|---| +| `POST /api/issues/claim` | `src/app/api/issues/claim/route.ts` | `claim` | +| `POST /api/issues/unclaim`, stale-AgentWork sweep | `src/lib/issue-claim.ts` (shared core) | `claim_released` | +| Board drag-and-drop | `src/app/api/issues/move/route.ts` | `status_changed` | +| Manual / agent status set | `src/app/api/issues/status/route.ts` | `status_changed` | +| Operator grooming decision | `src/app/api/issues/groom/route.ts` | `status_changed` | +| Hosted groomer plan application | `src/lib/groomer/run.ts` | `status_changed` | +| Reconciler | `src/app/api/issues/reconcile/route.ts` | `status_changed`, `pr_merged`, `issue_closed` | +| Issue sync | `src/lib/issue-sync.ts` (`makePrismaIssueStore`, `makePrismaReconcileIssueStore`) | `status_changed`, `issue_closed`, `issue_reopened` | +| GitHub webhook | `src/app/api/issues/webhook/route.ts` | `status_changed` | +| Worker report | `src/app/api/agents/[agentName]/tasks/report/route.ts`, `src/lib/report-close.ts` | `attempt_failed`, `completed` | + +## Idempotency + +Two independent rules, because the writers are not equally repeatable. + +**Transitions, not writes.** For a write Dispatch authors itself (claim, move, +status, groom), the event is recorded only when the status label actually +changed — or, for a claim, when the assigned agent changed. A redelivered claim +of an already-claimed issue, a drag that lands back in the same column and a +set-status to the current status therefore record nothing, while a genuine +release → reclaim pair records both attempts and leaves the first claim intact. +These events carry no `dedupeKey`; the guard is what makes them single. + +**Source identity.** For an externally observed event the source usually has +its own identity, which becomes a unique `dedupeKey` and the write is an +upsert with an empty update, so redelivery lands on the row that already +exists without rewriting its timestamps: + +- webhook — `webhook||||