diff --git a/tools/agent-loop/work-lifecycle-state-machine.test.ts b/tools/agent-loop/work-lifecycle-state-machine.test.ts new file mode 100644 index 0000000000..bc62636281 --- /dev/null +++ b/tools/agent-loop/work-lifecycle-state-machine.test.ts @@ -0,0 +1,469 @@ +// tools/agent-loop/work-lifecycle-state-machine.test.ts +// +// Unit tests for the pure-logic exports of work-lifecycle-state-machine.ts. + +import { describe, expect, test } from "bun:test"; + +import { + applyTransition, + isTerminal, + leadTimeSeconds, + revisionCount, + type BacklogRow, + type WorkLifecycleState, +} from "./work-lifecycle-state-machine"; + +function row(id: string = "B-0867.5"): BacklogRow { + return { + id, + title: "Agent-loop MVP", + priority: "P1", + filePath: `docs/backlog/P1/${id}-foo.md`, + trajectory: "workflow-engine", + }; +} + +function backlog(): WorkLifecycleState { + return { tag: "Backlog", row: row() }; +} + +describe("happy path: backlog → claim → InProgress → PrOpen → InReview → Approved → Merged", () => { + test("Backlog → Claim → Claimed", () => { + const r = applyTransition(backlog(), { + tag: "Claim", + agent: "otto", + timestamp: "2026-05-28T00:00:00Z", + }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.state.tag).toBe("Claimed"); + if (r.state.tag === "Claimed") { + expect(r.state.claimedBy).toBe("otto"); + } + } + }); + + test("Claimed → StartWork → InProgress", () => { + const claimed: WorkLifecycleState = { + tag: "Claimed", + row: row(), + claimedBy: "otto", + claimAt: "2026-05-28T00:00:00Z", + }; + const r = applyTransition(claimed, { tag: "StartWork", branchRef: "feat/x" }); + expect(r.ok).toBe(true); + if (r.ok && r.state.tag === "InProgress") { + expect(r.state.branchRef).toBe("feat/x"); + expect(r.state.claimedBy).toBe("otto"); + } + }); + + test("InProgress → OpenPr → PrOpen", () => { + const inprogress: WorkLifecycleState = { + tag: "InProgress", + row: row(), + claimedBy: "otto", + branchRef: "feat/x", + }; + const r = applyTransition(inprogress, { + tag: "OpenPr", + prNumber: 5666, + openedBy: "otto", + openedAt: "2026-05-28T00:10:00Z", + }); + expect(r.ok).toBe(true); + if (r.ok && r.state.tag === "PrOpen") { + expect(r.state.prNumber).toBe(5666); + } + }); + + test("PrOpen → RequestReview → InReview", () => { + const pr: WorkLifecycleState = { + tag: "PrOpen", + row: row(), + prNumber: 5666, + openedBy: "otto", + openedAt: "2026-05-28T00:10:00Z", + }; + const r = applyTransition(pr, { + tag: "RequestReview", + reviewers: ["aaron", "copilot"], + }); + expect(r.ok).toBe(true); + if (r.ok && r.state.tag === "InReview") { + expect(r.state.reviewers).toEqual(["aaron", "copilot"]); + expect(r.state.threadCount).toBe(0); + } + }); + + test("InReview → ResolveAllThreads → Approved", () => { + const review: WorkLifecycleState = { + tag: "InReview", + row: row(), + prNumber: 5666, + reviewers: ["aaron"], + threadCount: 0, + }; + const r = applyTransition(review, { tag: "ResolveAllThreads" }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.state.tag).toBe("Approved"); + }); + + test("Approved → Merge → Merged", () => { + const approved: WorkLifecycleState = { + tag: "Approved", + row: row(), + prNumber: 5666, + approvedAt: "2026-05-28T00:30:00Z", + }; + const r = applyTransition(approved, { + tag: "Merge", + mergeCommit: "abc123def", + mergedAt: "2026-05-28T00:35:00Z", + }); + expect(r.ok).toBe(true); + if (r.ok && r.state.tag === "Merged") { + expect(r.state.mergeCommit).toBe("abc123def"); + } + }); +}); + +describe("cycle-push-review-a-few-times pattern (the operator's question)", () => { + test("InReview → ReceiveRevisionRequest → RevisionRequested", () => { + const review: WorkLifecycleState = { + tag: "InReview", + row: row(), + prNumber: 5666, + reviewers: ["copilot"], + threadCount: 0, + }; + const r = applyTransition(review, { + tag: "ReceiveRevisionRequest", + threadIds: ["RT_111", "RT_222"], + }); + expect(r.ok).toBe(true); + if (r.ok && r.state.tag === "RevisionRequested") { + expect(r.state.revisionCount).toBe(1); + expect(r.state.threadIds).toEqual(["RT_111", "RT_222"]); + } + }); + + test("RevisionRequested → PushRevision → RevisionPushed", () => { + const requested: WorkLifecycleState = { + tag: "RevisionRequested", + row: row(), + prNumber: 5666, + revisionCount: 1, + threadIds: ["RT_111"], + }; + const r = applyTransition(requested, { + tag: "PushRevision", + sha: "deadbeef", + }); + expect(r.ok).toBe(true); + if (r.ok && r.state.tag === "RevisionPushed") { + expect(r.state.lastPushSha).toBe("deadbeef"); + expect(r.state.revisionCount).toBe(1); + } + }); + + test("RevisionPushed → RequestReview → InReview (the cycle-push loop)", () => { + const pushed: WorkLifecycleState = { + tag: "RevisionPushed", + row: row(), + prNumber: 5666, + revisionCount: 1, + lastPushSha: "deadbeef", + }; + const r = applyTransition(pushed, { + tag: "RequestReview", + reviewers: ["copilot"], + }); + expect(r.ok).toBe(true); + if (r.ok && r.state.tag === "InReview") { + // threadCount carries forward the revision-cycle iteration + expect(r.state.threadCount).toBe(1); + } + }); + + test("Multiple revision cycles increment revisionCount", () => { + let state: WorkLifecycleState = { + tag: "InReview", + row: row(), + prNumber: 5666, + reviewers: ["copilot"], + threadCount: 0, + }; + + // Cycle 1: revision requested → pushed → re-review requested + let r = applyTransition(state, { + tag: "ReceiveRevisionRequest", + threadIds: ["RT_1"], + }); + expect(r.ok).toBe(true); + state = r.state; + expect(revisionCount(state)).toBe(1); + + r = applyTransition(state, { tag: "PushRevision", sha: "sha1" }); + expect(r.ok).toBe(true); + state = r.state; + expect(revisionCount(state)).toBe(1); + + r = applyTransition(state, { tag: "RequestReview", reviewers: ["copilot"] }); + expect(r.ok).toBe(true); + state = r.state; + + // Cycle 2: revision requested again → pushed again + r = applyTransition(state, { + tag: "ReceiveRevisionRequest", + threadIds: ["RT_2"], + }); + expect(r.ok).toBe(true); + state = r.state; + if (state.tag === "RevisionRequested") { + expect(state.revisionCount).toBe(2); + } + + // Cycle 3: revision requested again + r = applyTransition(state, { tag: "PushRevision", sha: "sha2" }); + expect(r.ok).toBe(true); + state = r.state; + r = applyTransition(state, { tag: "RequestReview", reviewers: ["copilot"] }); + expect(r.ok).toBe(true); + state = r.state; + r = applyTransition(state, { + tag: "ReceiveRevisionRequest", + threadIds: ["RT_3"], + }); + expect(r.ok).toBe(true); + state = r.state; + if (state.tag === "RevisionRequested") { + expect(state.revisionCount).toBe(3); + } + }); + + test("InReview after revision cycle → ResolveAllThreads → Approved (cycle resolves cleanly)", () => { + const review: WorkLifecycleState = { + tag: "InReview", + row: row(), + prNumber: 5666, + reviewers: ["copilot"], + threadCount: 2, // we've been through 2 revision cycles + }; + const r = applyTransition(review, { tag: "ResolveAllThreads" }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.state.tag).toBe("Approved"); + }); +}); + +describe("close + abandon paths", () => { + test("PrOpen → Close → Closed", () => { + const pr: WorkLifecycleState = { + tag: "PrOpen", + row: row(), + prNumber: 5666, + openedBy: "otto", + openedAt: "2026-05-28T00:00:00Z", + }; + const r = applyTransition(pr, { + tag: "Close", + closedAt: "2026-05-28T01:00:00Z", + reason: "superseded by other PR", + }); + expect(r.ok).toBe(true); + if (r.ok && r.state.tag === "Closed") { + expect(r.state.reason).toBe("superseded by other PR"); + } + }); + + test("Backlog → Abandon → Abandoned", () => { + const r = applyTransition(backlog(), { + tag: "Abandon", + reason: "operator-directed close", + }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.state.tag).toBe("Abandoned"); + }); + + test("Claimed → Abandon → Abandoned", () => { + const claimed: WorkLifecycleState = { + tag: "Claimed", + row: row(), + claimedBy: "otto", + claimAt: "2026-05-28T00:00:00Z", + }; + const r = applyTransition(claimed, { + tag: "Abandon", + reason: "scope no longer relevant", + }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.state.tag).toBe("Abandoned"); + }); +}); + +describe("illegal transitions", () => { + test("Backlog → StartWork → illegal (must claim first)", () => { + const r = applyTransition(backlog(), { tag: "StartWork", branchRef: "x" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toContain("illegal transition"); + }); + + test("Backlog → Merge → illegal (terminal transitions are not legal from Backlog)", () => { + const r = applyTransition(backlog(), { + tag: "Merge", + mergeCommit: "x", + mergedAt: "x", + }); + expect(r.ok).toBe(false); + }); + + test("Merged → anything → illegal (terminal state)", () => { + const merged: WorkLifecycleState = { + tag: "Merged", + row: row(), + prNumber: 5666, + mergeCommit: "abc", + mergedAt: "2026-05-28T00:00:00Z", + }; + const r = applyTransition(merged, { tag: "Approve", approvedAt: "x" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toContain("terminal state"); + }); + + test("Abandoned → anything → illegal", () => { + const r = applyTransition( + { tag: "Abandoned", row: row(), reason: "x" }, + { tag: "Claim", agent: "otto", timestamp: "x" }, + ); + expect(r.ok).toBe(false); + }); +}); + +describe("helpers", () => { + test("isTerminal: Merged is terminal", () => { + const merged: WorkLifecycleState = { + tag: "Merged", + row: row(), + prNumber: 5666, + mergeCommit: "abc", + mergedAt: "x", + }; + expect(isTerminal(merged)).toBe(true); + }); + + test("isTerminal: Backlog is NOT terminal", () => { + expect(isTerminal(backlog())).toBe(false); + }); + + test("isTerminal: Closed is terminal", () => { + const closed: WorkLifecycleState = { + tag: "Closed", + row: row(), + prNumber: 5666, + closedAt: "x", + reason: "y", + }; + expect(isTerminal(closed)).toBe(true); + }); + + test("revisionCount: in revision states", () => { + const rev: WorkLifecycleState = { + tag: "RevisionRequested", + row: row(), + prNumber: 5666, + revisionCount: 3, + threadIds: [], + }; + expect(revisionCount(rev)).toBe(3); + }); + + test("revisionCount: zero in non-revision states", () => { + expect(revisionCount(backlog())).toBe(0); + }); + + test("leadTimeSeconds: 30min lead time", () => { + const sec = leadTimeSeconds( + "2026-05-28T00:00:00Z", + "2026-05-28T00:30:00Z", + ); + expect(sec).toBe(1800); + }); +}); + +describe("integration: full happy path lifecycle", () => { + test("Backlog → Claim → InProgress → PR → Review → 1 revision cycle → Approved → Merged", () => { + let state: WorkLifecycleState = backlog(); + + // Claim + let r = applyTransition(state, { + tag: "Claim", + agent: "otto", + timestamp: "2026-05-28T00:00:00Z", + }); + expect(r.ok).toBe(true); + state = r.state; + expect(state.tag).toBe("Claimed"); + + // Start work + r = applyTransition(state, { tag: "StartWork", branchRef: "feat/x" }); + expect(r.ok).toBe(true); + state = r.state; + expect(state.tag).toBe("InProgress"); + + // Open PR + r = applyTransition(state, { + tag: "OpenPr", + prNumber: 5666, + openedBy: "otto", + openedAt: "2026-05-28T00:10:00Z", + }); + expect(r.ok).toBe(true); + state = r.state; + expect(state.tag).toBe("PrOpen"); + + // Request review + r = applyTransition(state, { tag: "RequestReview", reviewers: ["copilot"] }); + expect(r.ok).toBe(true); + state = r.state; + expect(state.tag).toBe("InReview"); + + // 1 revision cycle + r = applyTransition(state, { + tag: "ReceiveRevisionRequest", + threadIds: ["RT_1"], + }); + expect(r.ok).toBe(true); + state = r.state; + expect(state.tag).toBe("RevisionRequested"); + + r = applyTransition(state, { tag: "PushRevision", sha: "abc" }); + expect(r.ok).toBe(true); + state = r.state; + expect(state.tag).toBe("RevisionPushed"); + + r = applyTransition(state, { tag: "RequestReview", reviewers: ["copilot"] }); + expect(r.ok).toBe(true); + state = r.state; + expect(state.tag).toBe("InReview"); + + // Threads resolved → Approved + r = applyTransition(state, { tag: "ResolveAllThreads" }); + expect(r.ok).toBe(true); + state = r.state; + expect(state.tag).toBe("Approved"); + + // Merge + r = applyTransition(state, { + tag: "Merge", + mergeCommit: "abc123", + mergedAt: "2026-05-28T00:30:00Z", + }); + expect(r.ok).toBe(true); + state = r.state; + expect(state.tag).toBe("Merged"); + expect(isTerminal(state)).toBe(true); + + // Lead time: claimed at 00:00, merged at 00:30 = 1800 seconds + expect(leadTimeSeconds("2026-05-28T00:00:00Z", "2026-05-28T00:30:00Z")).toBe(1800); + }); +}); diff --git a/tools/agent-loop/work-lifecycle-state-machine.ts b/tools/agent-loop/work-lifecycle-state-machine.ts new file mode 100644 index 0000000000..7f0e01668e --- /dev/null +++ b/tools/agent-loop/work-lifecycle-state-machine.ts @@ -0,0 +1,506 @@ +// tools/agent-loop/work-lifecycle-state-machine.ts +// +// B-0867.5+ extension: work-lifecycle state machine — backlog row → +// claim → PR → review (possibly cycle review-push N times) → merge. +// +// Operator framing 2026-05-28: +// "And can we model backlog -> claim -> pr -> review -> myabe cycle +// push review a few times -> merge too with this?" +// +// Answer: yes. Same F# DU implicit state machine pattern as agent-loop +// state-machine.ts, but operating at WORK-LIFECYCLE scope instead of +// AGENT-CYCLE scope. The two state machines compose: +// +// - agent-loop's PickWork action transitions a Backlog work-item to +// Claimed (work-lifecycle transition) +// - agent-loop's ExecutingWork state IS the work-lifecycle's +// InProgress state for the picked item +// - agent-loop's EmittingResult action transitions work to next +// lifecycle state based on result (PrOpen if commit shipped; +// Abandoned if work didn't ship) +// +// The cycle-push-review-a-few-times pattern is the InReview ↔ +// RevisionRequested ↔ RevisionPushed loop. Can iterate N times before +// Approval. +// +// Composes with: +// - state-machine.ts (agent-loop) — agent-decisions level +// - tools/bus/claim.ts (existing) — claim acquisition substrate +// - tools/github/poll-pr-gate.ts (existing) — PR state inspection +// - B-0867 + B-0867.5 (workflow engine v1 substrate) +// - .claude/rules/claim-acquire-before-worktree-work.md (claim discipline) +// - .claude/rules/blocked-green-ci-investigate-threads.md (revision-request handling) + +import type { AgentPersona } from "./state-machine"; + +// ─── Work-item identity + metadata ─────────────────────────────────── + +export interface BacklogRow { + readonly id: string; // "B-0867.5" + readonly title: string; + readonly priority: "P0" | "P1" | "P2" | "P3"; + readonly filePath: string; // "docs/backlog/P1/B-0867.5-..." + readonly trajectory: string; // composes with B-0867 trajectory taxonomy +} + +// ─── Work-lifecycle state (F# DU) ──────────────────────────────────── + +/** + * WorkLifecycleState — where a single work-item is in its progression + * from filed-backlog to merged-on-main. + * + * F# DU equivalent: + * + * type WorkLifecycleState = + * | Backlog of row: BacklogRow + * | Claimed of row: BacklogRow * claimedBy: AgentPersona * claimAt: string + * | InProgress of row: BacklogRow * claimedBy: AgentPersona * branchRef: string + * | PrOpen of row: BacklogRow * prNumber: int * openedBy: AgentPersona * openedAt: string + * | InReview of row: BacklogRow * prNumber: int * reviewers: string list * threadCount: int + * | RevisionRequested of row: BacklogRow * prNumber: int * revisionCount: int * threadIds: string list + * | RevisionPushed of row: BacklogRow * prNumber: int * revisionCount: int * lastPushSha: string + * | Approved of row: BacklogRow * prNumber: int * approvedAt: string + * | Merged of row: BacklogRow * prNumber: int * mergeCommit: string * mergedAt: string + * | Closed of row: BacklogRow * prNumber: int * closedAt: string * reason: string + * | Abandoned of row: BacklogRow * reason: string + */ +export type WorkLifecycleState = + | { readonly tag: "Backlog"; readonly row: BacklogRow } + | { + readonly tag: "Claimed"; + readonly row: BacklogRow; + readonly claimedBy: AgentPersona; + readonly claimAt: string; + } + | { + readonly tag: "InProgress"; + readonly row: BacklogRow; + readonly claimedBy: AgentPersona; + readonly branchRef: string; + } + | { + readonly tag: "PrOpen"; + readonly row: BacklogRow; + readonly prNumber: number; + readonly openedBy: AgentPersona; + readonly openedAt: string; + } + | { + readonly tag: "InReview"; + readonly row: BacklogRow; + readonly prNumber: number; + readonly reviewers: readonly string[]; + readonly threadCount: number; + } + | { + readonly tag: "RevisionRequested"; + readonly row: BacklogRow; + readonly prNumber: number; + readonly revisionCount: number; // increments on each revision cycle + readonly threadIds: readonly string[]; + } + | { + readonly tag: "RevisionPushed"; + readonly row: BacklogRow; + readonly prNumber: number; + readonly revisionCount: number; + readonly lastPushSha: string; + } + | { + readonly tag: "Approved"; + readonly row: BacklogRow; + readonly prNumber: number; + readonly approvedAt: string; + } + | { + readonly tag: "Merged"; + readonly row: BacklogRow; + readonly prNumber: number; + readonly mergeCommit: string; + readonly mergedAt: string; + } + | { + readonly tag: "Closed"; + readonly row: BacklogRow; + readonly prNumber: number; + readonly closedAt: string; + readonly reason: string; + } + | { + readonly tag: "Abandoned"; + readonly row: BacklogRow; + readonly reason: string; + }; + +// ─── Transition events ─────────────────────────────────────────────── + +/** + * WorkLifecycleTransition — events that move a work-item between + * lifecycle states. Each transition has an authoritative source + * (claim coordinator / GitHub API / operator action / agent action); + * the transition function is defensive about which transitions are + * legal from which states. + * + * F# DU equivalent: + * + * type WorkLifecycleTransition = + * | Claim of agent: AgentPersona * timestamp: string + * | StartWork of branchRef: string + * | OpenPr of prNumber: int * openedBy: AgentPersona * openedAt: string + * | RequestReview of reviewers: string list + * | ReceiveRevisionRequest of threadIds: string list + * | PushRevision of sha: string + * | ResolveAllThreads + * | Approve of approvedAt: string + * | Merge of mergeCommit: string * mergedAt: string + * | Close of closedAt: string * reason: string + * | Abandon of reason: string + */ +export type WorkLifecycleTransition = + | { + readonly tag: "Claim"; + readonly agent: AgentPersona; + readonly timestamp: string; + } + | { readonly tag: "StartWork"; readonly branchRef: string } + | { + readonly tag: "OpenPr"; + readonly prNumber: number; + readonly openedBy: AgentPersona; + readonly openedAt: string; + } + | { readonly tag: "RequestReview"; readonly reviewers: readonly string[] } + | { + readonly tag: "ReceiveRevisionRequest"; + readonly threadIds: readonly string[]; + } + | { readonly tag: "PushRevision"; readonly sha: string } + | { readonly tag: "ResolveAllThreads" } + | { readonly tag: "Approve"; readonly approvedAt: string } + | { + readonly tag: "Merge"; + readonly mergeCommit: string; + readonly mergedAt: string; + } + | { + readonly tag: "Close"; + readonly closedAt: string; + readonly reason: string; + } + | { readonly tag: "Abandon"; readonly reason: string }; + +// ─── Transition function ───────────────────────────────────────────── + +/** + * Result type for the transition function. Encodes that transitions + * can succeed (returning new state) or fail (returning the original + * state + a reason why the transition was illegal from current state). + * + * Substrate-honest: invalid transitions are returned as failures + * (not exceptions) so the caller can decide whether to retry, escalate, + * or log + continue. Composes with non-coercion-invariant + asymmetric- + * critic-with-clarity-first disciplines. + */ +export type TransitionResult = + | { readonly ok: true; readonly state: WorkLifecycleState } + | { + readonly ok: false; + readonly state: WorkLifecycleState; + readonly reason: string; + }; + +/** + * applyTransition — pure function: current state + transition event → + * either new state (legal transition) or failure (illegal transition). + * + * Legal transition graph: + * + * Backlog --Claim--> Claimed + * Claimed --StartWork--> InProgress + * InProgress --OpenPr--> PrOpen + * PrOpen --RequestReview--> InReview + * InReview --ReceiveRevisionRequest--> RevisionRequested + * RevisionRequested --PushRevision--> RevisionPushed + * RevisionPushed --RequestReview--> InReview (the cycle-push loop) + * InReview --ResolveAllThreads--> Approved + * Approved --Merge--> Merged + * (most non-terminal states) --Close--> Closed + * (most non-terminal states) --Abandon--> Abandoned + * + * The cycle-push-review-a-few-times pattern is the InReview ↔ + * RevisionRequested ↔ RevisionPushed loop. revisionCount increments + * on each PushRevision. Operator-substrate-honest: high revisionCount + * is a signal that the work-item may need substrate-engineering + * attention (decomposition; alternative approach; escalation). + */ +export function applyTransition( + state: WorkLifecycleState, + event: WorkLifecycleTransition, +): TransitionResult { + switch (state.tag) { + case "Backlog": + if (event.tag === "Claim") { + return { + ok: true, + state: { + tag: "Claimed", + row: state.row, + claimedBy: event.agent, + claimAt: event.timestamp, + }, + }; + } + if (event.tag === "Abandon") { + return { + ok: true, + state: { tag: "Abandoned", row: state.row, reason: event.reason }, + }; + } + break; + + case "Claimed": + if (event.tag === "StartWork") { + return { + ok: true, + state: { + tag: "InProgress", + row: state.row, + claimedBy: state.claimedBy, + branchRef: event.branchRef, + }, + }; + } + if (event.tag === "Abandon") { + return { + ok: true, + state: { tag: "Abandoned", row: state.row, reason: event.reason }, + }; + } + break; + + case "InProgress": + if (event.tag === "OpenPr") { + return { + ok: true, + state: { + tag: "PrOpen", + row: state.row, + prNumber: event.prNumber, + openedBy: event.openedBy, + openedAt: event.openedAt, + }, + }; + } + if (event.tag === "Abandon") { + return { + ok: true, + state: { tag: "Abandoned", row: state.row, reason: event.reason }, + }; + } + break; + + case "PrOpen": + if (event.tag === "RequestReview") { + return { + ok: true, + state: { + tag: "InReview", + row: state.row, + prNumber: state.prNumber, + reviewers: event.reviewers, + threadCount: 0, + }, + }; + } + if (event.tag === "Close") { + return { + ok: true, + state: { + tag: "Closed", + row: state.row, + prNumber: state.prNumber, + closedAt: event.closedAt, + reason: event.reason, + }, + }; + } + break; + + case "InReview": + if (event.tag === "ReceiveRevisionRequest") { + return { + ok: true, + state: { + tag: "RevisionRequested", + row: state.row, + prNumber: state.prNumber, + revisionCount: state.threadCount === 0 ? 1 : state.threadCount + 1, + threadIds: event.threadIds, + }, + }; + } + if (event.tag === "ResolveAllThreads") { + return { + ok: true, + state: { + tag: "Approved", + row: state.row, + prNumber: state.prNumber, + approvedAt: new Date().toISOString(), + }, + }; + } + if (event.tag === "Approve") { + return { + ok: true, + state: { + tag: "Approved", + row: state.row, + prNumber: state.prNumber, + approvedAt: event.approvedAt, + }, + }; + } + if (event.tag === "Close") { + return { + ok: true, + state: { + tag: "Closed", + row: state.row, + prNumber: state.prNumber, + closedAt: event.closedAt, + reason: event.reason, + }, + }; + } + break; + + case "RevisionRequested": + if (event.tag === "PushRevision") { + return { + ok: true, + state: { + tag: "RevisionPushed", + row: state.row, + prNumber: state.prNumber, + revisionCount: state.revisionCount, + lastPushSha: event.sha, + }, + }; + } + if (event.tag === "Close") { + return { + ok: true, + state: { + tag: "Closed", + row: state.row, + prNumber: state.prNumber, + closedAt: event.closedAt, + reason: event.reason, + }, + }; + } + break; + + case "RevisionPushed": + // The cycle-push-review-a-few-times pattern: RevisionPushed loops + // back to InReview when reviewer re-evaluates the pushed revision + if (event.tag === "RequestReview") { + return { + ok: true, + state: { + tag: "InReview", + row: state.row, + prNumber: state.prNumber, + reviewers: event.reviewers, + threadCount: state.revisionCount, + }, + }; + } + if (event.tag === "ResolveAllThreads") { + // Reviewer resolved threads after push without requesting further changes + return { + ok: true, + state: { + tag: "Approved", + row: state.row, + prNumber: state.prNumber, + approvedAt: new Date().toISOString(), + }, + }; + } + break; + + case "Approved": + if (event.tag === "Merge") { + return { + ok: true, + state: { + tag: "Merged", + row: state.row, + prNumber: state.prNumber, + mergeCommit: event.mergeCommit, + mergedAt: event.mergedAt, + }, + }; + } + break; + + case "Merged": + case "Closed": + case "Abandoned": + // Terminal states; no further transitions legal + return { + ok: false, + state, + reason: `terminal state ${state.tag} cannot transition via ${event.tag}`, + }; + } + + return { + ok: false, + state, + reason: `illegal transition: ${state.tag} cannot accept ${event.tag}`, + }; +} + +// ─── Lifecycle metrics ─────────────────────────────────────────────── + +/** + * isTerminal — work-item has reached an end state (Merged / Closed / + * Abandoned). Used by aggregators to filter active work from completed. + */ +export function isTerminal(state: WorkLifecycleState): boolean { + return ( + state.tag === "Merged" || + state.tag === "Closed" || + state.tag === "Abandoned" + ); +} + +/** + * revisionCount — how many revision cycles a work-item has been + * through. High values are operator-substrate-honest signals that the + * work-item may need substrate-engineering attention. + */ +export function revisionCount(state: WorkLifecycleState): number { + if (state.tag === "RevisionRequested" || state.tag === "RevisionPushed") { + return state.revisionCount; + } + return 0; +} + +/** + * leadTimeSeconds — time from Claim → Merged (DORA lead-time metric). + * Requires the state's history (not encoded in the state itself for + * stateless storage simplicity; caller passes the claimAt timestamp). + */ +export function leadTimeSeconds( + claimAtIso: string, + mergedAtIso: string, +): number { + return ( + (new Date(mergedAtIso).getTime() - new Date(claimAtIso).getTime()) / + 1000 + ); +}