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..75e8a00a --- /dev/null +++ b/docs/issue-lifecycle-events.md @@ -0,0 +1,172 @@ +# 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 — or closed by the hosted groomer. Distinct from the card reaching Done. | `reconciler`, `sync`, `hosted-groomer` | +| `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` (only the moves the plan actually made), `issue_closed` (when the plan closed the issue) | +| 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. The one +keyed case is the stale sweep's ownership-only release: the sweep knows the +AgentWork row it is releasing, which is one claim epoch, so it supplies that as +the release's identity and a retry of the same sweep — which finds the agent +label already gone and repairs the cache — lands on the same `claim_released` +row instead of recording the release twice. + +**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||||