diff --git a/.env.example b/.env.example index 61ddb194..6b38f34b 100644 --- a/.env.example +++ b/.env.example @@ -324,6 +324,10 @@ DISPATCH_AGENT_TOKEN="your_agent_token_here" # again (#1211). An operator requeue resets the count. Default 3. # PR_FIX_MAX_RECLAIMS=3 +# Cooldown (ms) before an external-prerequisite wait is redispatched for a fresh +# worker-side readiness check. Bounded to 60 000–604 800 000; default 21 600 000 (6h). +# PR_FIX_EXTERNAL_WAIT_COOLDOWN_MS=21600000 + # --- Legacy aliases (do not set) --- # MISSION_CONTROL_URL and MISSION_CONTROL_AGENT_TOKEN were renamed to diff --git a/docs/pr-fix-outcome-policy.md b/docs/pr-fix-outcome-policy.md index 8d9950f4..1b9651b9 100644 --- a/docs/pr-fix-outcome-policy.md +++ b/docs/pr-fix-outcome-policy.md @@ -12,6 +12,7 @@ | `no_progress` | Bounded retry or no-progress exhaustion | `PARKED` | `NORMAL` | Diagnostic `` comment; no `needs-human` label | | `infra_failure` | Infrastructure or authentication failure | `PARKED` | `NORMAL` | Diagnostic parked comment; fail closed, not blindly retried | | `security_failure` | Security failure | `PARKED` | `NORMAL` | Diagnostic parked comment; fail closed, not blindly retried | +| external wait | Worker is waiting on a prerequisite outside Dispatch | `WAITING_EXTERNAL` | Existing lane | No human escalation or attempt charge; retry after cooldown | The structured outcome is stored on `PrFixQueueItem` and `PrFixHistory`; human decisions may also include `decisionDetail`. A `tasks/report` request may provide `blockReason` and `decision` (`question`, `options`, `evidence`). A bare `outcome: "blocked"` without `blockReason` means `no_progress`, not `human_decision`. An `outcome` supplied on a brand-new `NORMAL`-lane enqueue is advisory and only takes effect if/when the item parks (a fresh item starts `QUEUED`). @@ -19,6 +20,8 @@ The structured outcome is stored on `PrFixQueueItem` and `PrFixHistory`; human d `fixAttempts` counts dispatchable worker executions. Hand-out reclamation (`handoutReclaims`) and `PR_FIX_HANDOUT_TIMEOUT_MS` wall time are separate budgets: a stale hand-out that never reached a worker does not consume an execution attempt. Stale or already-known feedback also consumes no execution budget. No new numeric thresholds are introduced. +An `outcome: "waiting-external"` report (or `WAITING_EXTERNAL` mark with the issued generation) parks work temporarily without implying prerequisite readiness. Queue listings exclude it by default; `include_blocked=true` includes pending external waits as read-only entries with the latest wait note and retry deadline, and does not make them claimable or signal readiness. The bounded `PR_FIX_EXTERNAL_WAIT_COOLDOWN_MS` setting defaults to six hours (accepted range: one minute to seven days). When due, queue discovery conditionally reissues it as a new generation; the next worker rechecks the prerequisite. This does not increment `fixAttempts` or enforce its cap. Repeated report/mark for the same waiting generation is idempotent and does not extend the cooldown. Late marks cannot change the generation. + Automatic enqueue/evidence does not release `BLOCKED` or `PARKED`. An operator explicitly requeues either status, or a maintainer marks the item back to `QUEUED`; requeue releases the parked/blocked state. `FIXED` retains its existing #940 and new-evidence reopen paths. ## Safety and reporting diff --git a/docs/pr-review-fix-queue.md b/docs/pr-review-fix-queue.md index 95bf82a9..0beb125c 100644 --- a/docs/pr-review-fix-queue.md +++ b/docs/pr-review-fix-queue.md @@ -11,8 +11,8 @@ Each item is deduped by `(repo, pr)` and stores: - `repo`, `pr`, `branch`, `url`, `title` - optional source `issue` (extracted from PR title/body) - `lane`: `NORMAL`, `ESCALATED`, or `NEEDS_HUMAN` -- `status`: `QUEUED`, `FIXED`, `BLOCKED`, `PARKED`, `STALE`, or `IGNORED` -- `outcome`: `human_decision`, `no_progress`, `infra_failure`, or `security_failure`; human decisions may also store `decisionDetail` +- `status`: `QUEUED`, `FIXED`, `BLOCKED`, `PARKED`, `STALE`, `IGNORED`, or `WAITING_EXTERNAL` +- `outcome`: `human_decision`, `no_progress`, `infra_failure`, or `security_failure`; human decisions may also store `decisionDetail`. External waits use report outcome `waiting-external` and status `WAITING_EXTERNAL`, with a bounded cooldown before a fresh generation checks readiness. - `reason`, `feedback[]`, `evidenceKeys[]` - `headSha` and `author` metadata - `generation` — Dispatch-owned work generation (see below) @@ -93,7 +93,7 @@ Items in the `NEEDS_HUMAN` lane receive `BLOCKED` status and are excluded from t ### Mark item status -`POST /api/pr-fix-queue/mark` — Marks an item `FIXED`, `BLOCKED`, `PARKED`, `STALE`, or `IGNORED` and records a history event. Requires auth. The body may include an `outcome` (`human_decision`, `no_progress`, `infra_failure`, or `security_failure`) and a `decision` object (`question`, `options`, and `evidence`) when recording a human decision. +`POST /api/pr-fix-queue/mark` — Marks an item `FIXED`, `BLOCKED`, `PARKED`, `STALE`, `IGNORED`, or `WAITING_EXTERNAL` and records a history event. Requires auth. The body may include an `outcome` (`human_decision`, `no_progress`, `infra_failure`, or `security_failure`) and a `decision` object (`question`, `options`, and `evidence`) when recording a human decision. **Request body:** ```json diff --git a/docs/worker-execution-contract.md b/docs/worker-execution-contract.md index dcc3ca56..bd75adfa 100644 --- a/docs/worker-execution-contract.md +++ b/docs/worker-execution-contract.md @@ -38,7 +38,7 @@ Dispatch bearer tokens have two tiers. A **worker** token may call exactly: - `POST /api/issues/claim` (without `force`) and `POST /api/issues/unclaim` (for the caller's own agent) - `GET /api/issues/state`, `POST /api/issues/status` (status only for issues assigned to the caller's own agent) - `GET /api/issues`, `GET /api/pr-fix-queue/queued`, `GET /api/pr-fix-queue/history` -- `POST /api/pr-fix-queue/mark` with `FIXED`, `BLOCKED`, or `STALE` (generation required, as today) +- `POST /api/pr-fix-queue/mark` with `FIXED`, `BLOCKED`, `PARKED`, `STALE`, or `WAITING_EXTERNAL` (generation required) - `POST /api/pr-fix-queue/ack` (the durable hand-out acknowledgement, see "Hand-out acknowledgement" below) The **maintainer** token (`DISPATCH_AGENT_TOKEN`, or the `DISPATCH_MAINTAINER_TOKEN` alias) keeps full rights and acts on any agent's behalf. A worker token calling a maintainer-only route gets an HTTP 403 naming the required tier: force claims, `QUEUED`/`IGNORED` marks, `POST /api/pr-fix-queue/requeue`, and the `POST /api/pr-fix-queue/sweep` reclaim job are maintainer-only. Unclaim is not tier-gated — the target agent is bound to the caller and is only bounded by the assignment check; the legacy `DISPATCH_WORKER_TOKEN` falls back to the request body, but `DISPATCH_WORKER_TOKENS` overrides that with a token-derived identity. A `DISPATCH_WORKER_TOKEN` value that duplicates a maintainer token resolves to the lower worker tier (fail-closed) and logs a one-time boot warning — set it to a distinct value. @@ -224,7 +224,7 @@ curl -s -X POST "DISPATCH_URL/api/pr-fix-queue/mark" \ -d '{"repo":"org/repo","pr":42,"status":"fixed"}' ``` -Valid statuses: `FIXED`, `BLOCKED`, `STALE`, `IGNORED`. +Valid statuses: `FIXED`, `BLOCKED`, `PARKED`, `STALE`, `IGNORED`, `WAITING_EXTERNAL`. `WAITING_EXTERNAL` defers the consumed generation for the configured cooldown; it neither charges an attempt nor signals prerequisite readiness. ### Normal Lane diff --git a/prisma/migrations/20261013000000_pr_fix_external_wait/migration.sql b/prisma/migrations/20261013000000_pr_fix_external_wait/migration.sql new file mode 100644 index 00000000..107a562d --- /dev/null +++ b/prisma/migrations/20261013000000_pr_fix_external_wait/migration.sql @@ -0,0 +1,2 @@ +ALTER TYPE "PrFixStatus" ADD VALUE IF NOT EXISTS 'WAITING_EXTERNAL'; +ALTER TABLE "PrFixQueueItem" ADD COLUMN "retryAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7a421f15..721d9b8d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -134,6 +134,7 @@ enum PrFixStatus { PARKED STALE IGNORED + WAITING_EXTERNAL } enum PrFixType { @@ -219,6 +220,8 @@ model PrFixQueueItem { // `needs-human` label is never applied. Release requires an explicit // operator requeue or a maintainer mark to QUEUED. handoutReclaims Int @default(0) + // Cooldown deadline for external dependency waits; due items are reissued lazily. + retryAt DateTime? author String? history PrFixHistory[] diff --git a/src/app/api/agents/[agentName]/next-task/route.test.ts b/src/app/api/agents/[agentName]/next-task/route.test.ts index 060a24a8..5939c9ff 100644 --- a/src/app/api/agents/[agentName]/next-task/route.test.ts +++ b/src/app/api/agents/[agentName]/next-task/route.test.ts @@ -42,6 +42,7 @@ vi.mock("@/lib/prisma", () => ({ prFixQueueItem: { create: mocks.prFixCreate, findUnique: mocks.prFixFindUniqueByRepo, + updateMany: mocks.prFixUpdateMany, }, prFixHistory: { create: mocks.prFixHistoryCreate }, }), @@ -645,6 +646,71 @@ describe("GET /api/agents/[agentName]/next-task", () => { expect(secondBody.prFixItem.id).toBe(firstBody.prFixItem.id); }); + it("withholds future external waits and lazily hands out due retries at a new generation (#1231)", async () => { + const client = makeQueueStore(); + mocks.prFixFindMany.mockImplementation(async ({ where }: any = {}) => + client.prFixQueueItem.findMany({ where }), + ); + mocks.prFixFindUnique.mockImplementation(async ({ where }: any) => + client.items.find((row: any) => row.id === where.id) ?? null, + ); + mocks.prFixUpdateMany.mockImplementation(async ({ where, data }: any) => { + const row = client.items.find((candidate: any) => + candidate.id === where.id && candidate.generation === where.generation && + (where.status === undefined || candidate.status === where.status) && + (where.retryAt === undefined || candidate.retryAt?.getTime() === where.retryAt.getTime()) && + (!where.OR || where.OR.some((condition: any) => + condition.dispatchedGeneration === null + ? candidate.dispatchedGeneration == null + : candidate.dispatchedGeneration !== condition.dispatchedGeneration.not, + )), + ); + if (!row) return { count: 0 }; + const patch: Record = { ...data }; + for (const [key, value] of Object.entries(patch)) { + if (value && typeof value === "object" && Array.isArray((value as any).push)) { + patch[key] = [...(row[key] ?? []), ...(value as any).push]; + } else if (value && typeof value === "object" && typeof (value as any).increment === "number") { + patch[key] = (row[key] ?? 0) + (value as any).increment; + } + } + Object.assign(row, patch); + return { count: 1 }; + }); + const item = await enqueuePrFixItem(client, { + repo: "org/repo", pr: 123, lane: "NORMAL", reason: "review changes requested", + feedback: "please update tests", evidenceKey: "review:123", + }); + await markPrFixItem(client, { + repo: "org/repo", pr: 123, status: "WAITING_EXTERNAL", + expectedGeneration: item.generation, note: "waiting on prerequisite", + }); + const waiting = client.items[0]; + const issuedGeneration = waiting.generation; + expect(waiting.retryAt.getTime()).toBeGreaterThan(Date.now()); + + const poll = async () => GET( + request("/api/agents/example-agent/next-task"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + const early = await poll(); + expect((await early.json()).type).toBe("idle"); + expect(waiting.status).toBe("WAITING_EXTERNAL"); + + waiting.retryAt = new Date(Date.now() - 1_000); + const due = await poll(); + const body = await due.json(); + expect(body.type).toBe("followup-pr"); + expect(body.prFixItem).toEqual({ id: waiting.id, generation: issuedGeneration + 1 }); + expect(waiting.status).toBe("QUEUED"); + expect(waiting.generation).toBeGreaterThan(issuedGeneration); + expect(waiting.fixAttempts).toBeUndefined(); + expect(waiting.dispatchedGeneration).toBe(waiting.generation); + + const repeat = await poll(); + expect((await repeat.json()).type).toBe("idle"); + }); + it("serves the bumped generation through the production requeue path", async () => { // Drive the real queue state machine (enqueue → BLOCKED → requeue) against // an in-memory store, and verify next-task surfaces the persisted identity. diff --git a/src/app/api/agents/[agentName]/tasks/report/route.test.ts b/src/app/api/agents/[agentName]/tasks/report/route.test.ts index da858464..e424e81e 100644 --- a/src/app/api/agents/[agentName]/tasks/report/route.test.ts +++ b/src/app/api/agents/[agentName]/tasks/report/route.test.ts @@ -321,6 +321,7 @@ describe("POST /api/agents/[agentName]/tasks/report — validation", () => { "no_changes_needed", "already_addressed", "handed-off", + "waiting-external", ]; for (const outcome of validOutcomes) { @@ -1616,6 +1617,14 @@ describe("POST /api/agents/[agentName]/tasks/report — prFixItem attempt token expect(body.report.prFixItem).toEqual({ id: "cktz-abc", generation: 2 }); }); + it.each(["implement", "followup-pr"] as const)("accepts waiting-external for %s and records a neutral run", async (taskType) => { + const body = { ...validReport, taskType, outcome: "waiting-external", error: "missing prerequisite: X", ...(taskType === "followup-pr" ? { prFixItem: { id: "cktz-abc", generation: 2 } } : {}) }; + const res = await postRequest(body); + expect(res.status).toBe(200); + expect(prFixResolveMock).toHaveBeenCalledWith(expect.objectContaining({ outcome: "waiting-external", error: "missing prerequisite: X", attempt: taskType === "followup-pr" ? { itemId: "cktz-abc", generation: 2 } : null })); + expect(mockAgentRun.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: "waiting-external" }) })); + }); + it("returns 400 when prFixItem is not an object", async () => { const res = await postRequest({ ...validReport, prFixItem: "cktz-abc" }); expect(res.status).toBe(400); diff --git a/src/app/api/agents/[agentName]/tasks/report/route.ts b/src/app/api/agents/[agentName]/tasks/report/route.ts index 8d0d5737..ee4d8fc2 100644 --- a/src/app/api/agents/[agentName]/tasks/report/route.ts +++ b/src/app/api/agents/[agentName]/tasks/report/route.ts @@ -31,6 +31,7 @@ const VALID_OUTCOMES = [ "already_addressed", "already_done", "handed-off", + "waiting-external", ] as const; type ValidOutcome = (typeof VALID_OUTCOMES)[number]; @@ -73,6 +74,7 @@ function deriveStatus(outcome: ValidOutcome): string { if (outcome === "handed-off") return "handed-off"; if (outcome === "failed") return "failed"; if (outcome === "blocked") return "blocked"; + if (outcome === "waiting-external") return "waiting-external"; return "completed"; } @@ -590,6 +592,7 @@ export async function POST( pullRequestUrl: report.pullRequestUrl, outcome: report.outcome, summary: report.summary, + error: report.error, evidence: report.evidence, // #1218: a `blocked` report's structured reason / decision payload; // the resolver ignores both for any other outcome. diff --git a/src/app/api/pr-fix-queue/mark/route.test.ts b/src/app/api/pr-fix-queue/mark/route.test.ts index bdba5228..012832eb 100644 --- a/src/app/api/pr-fix-queue/mark/route.test.ts +++ b/src/app/api/pr-fix-queue/mark/route.test.ts @@ -264,6 +264,22 @@ describe("POST /api/pr-fix-queue/mark — worker tier (#1111)", () => { ); } + it("allows a worker to mark an item WAITING_EXTERNAL with the exact generation", async () => { + mocks.parseMarkPrFixInput.mockReturnValue({ repo: "org/repo", pr: 42, status: "WAITING_EXTERNAL", expectedGeneration: 2 }); + mocks.markPrFixItem.mockResolvedValue({ mutated: true, item: { id: "fix-1", status: "WAITING_EXTERNAL" } }); + const res = await workerPost({ repo: "org/repo", pr: 42, status: "WAITING_EXTERNAL", generation: 2 }); + expect(res.status).toBe(200); + expect(mocks.markPrFixItem).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ status: "WAITING_EXTERNAL", expectedGeneration: 2 })); + }); + + it("rejects a worker external wait without a generation", async () => { + mocks.parseMarkPrFixInput.mockReturnValue({ repo: "org/repo", pr: 42, status: "WAITING_EXTERNAL" }); + const res = await workerPost({ repo: "org/repo", pr: 42, status: "WAITING_EXTERNAL" }); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain("generation is required for WAITING_EXTERNAL"); + expect(mocks.markPrFixItem).not.toHaveBeenCalled(); + }); + it("allows a worker to mark an item FIXED with a generation", async () => { mocks.parseMarkPrFixInput.mockReturnValue({ repo: "org/repo", pr: 42, status: "FIXED", expectedGeneration: 2 }); mocks.markPrFixItem.mockResolvedValue({ mutated: true, item: { id: "fix-1", status: "FIXED" } }); diff --git a/src/app/api/pr-fix-queue/mark/route.ts b/src/app/api/pr-fix-queue/mark/route.ts index f4489a3f..e0ca7c9e 100644 --- a/src/app/api/pr-fix-queue/mark/route.ts +++ b/src/app/api/pr-fix-queue/mark/route.ts @@ -30,11 +30,12 @@ export async function POST(request: Request) { const input = parseMarkPrFixInput(body); if ("error" in input) return errorResponse(input.error, 400); - // Worker tokens may only settle attempts (FIXED/BLOCKED/PARKED/STALE): + // Worker tokens may settle attempts (FIXED/BLOCKED/PARKED/STALE) or defer a + // consumed generation while waiting on an external prerequisite (WAITING_EXTERNAL): // PARKED lets a worker report bounded exhaustion or an operational // failure (#1218). Moving an item back to QUEUED or IGNORED changes // routing and requires a maintainer token (#1111). - const WORKER_SETTLE_STATUSES = ["FIXED", "BLOCKED", "PARKED", "STALE"]; + const WORKER_SETTLE_STATUSES = ["FIXED", "BLOCKED", "PARKED", "STALE", "WAITING_EXTERNAL"]; if ( auth.type === "bearer" && auth.tier === "worker" && @@ -74,6 +75,12 @@ export async function POST(request: Request) { ? (body as Record).allowStickyReclassify === true : false; + // Every external wait consumes a handed-out generation, including operator + // marks, so it must always be fenced. Other operator marks retain legacy behavior. + if (input.status === "WAITING_EXTERNAL" && (input.expectedGeneration === undefined || input.expectedGeneration === null)) { + return errorResponse("generation is required for WAITING_EXTERNAL", 400); + } + // #1074: bearer (agent/bridge) marks must settle a specific attempt, so // the generation token is required. Operator paths (oidc session, basic, // disabled) keep the optional behavior for compatibility. diff --git a/src/app/automation/pr-fix-queue/page.test.tsx b/src/app/automation/pr-fix-queue/page.test.tsx index ea8450ed..66258b28 100644 --- a/src/app/automation/pr-fix-queue/page.test.tsx +++ b/src/app/automation/pr-fix-queue/page.test.tsx @@ -36,6 +36,21 @@ describe("PR Fix Queue page", () => { expect(screen.getByText(/ci failure/)).toBeInTheDocument(); }); + it("shows external waits as read-only with their note and retry deadline", async () => { + const retryAt = "2026-01-03T12:00:00Z"; + mockAuthedFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [ + { id: "wait-1", repo: "org/repo", pr: 44, status: "WAITING_EXTERNAL", lane: "NORMAL", reason: "CI prerequisite", latestNote: "missing prerequisite: X", retryAt, title: "Wait for CI", url: null, queuedAt: "2026-01-02T00:00:00Z" }, + ], + }); + render(); + await waitFor(() => expect(screen.getByText(/Waiting on external prerequisite/i)).toBeInTheDocument()); + expect(screen.getByText("missing prerequisite: X")).toBeInTheDocument(); + expect(screen.getByText(new Date(retryAt).toLocaleString())).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /requeue/i })).not.toBeInTheDocument(); + }); + it("shows empty state when no items", async () => { mockAuthedFetch.mockResolvedValueOnce({ ok: true, diff --git a/src/app/automation/pr-fix-queue/page.tsx b/src/app/automation/pr-fix-queue/page.tsx index 0b151c40..8706c244 100644 --- a/src/app/automation/pr-fix-queue/page.tsx +++ b/src/app/automation/pr-fix-queue/page.tsx @@ -18,6 +18,8 @@ interface PrFixItem { title: string | null; url: string | null; queuedAt: string; + retryAt?: string | null; + latestNote?: string | null; outcome?: string | null; fixAttempts?: number | null; } @@ -116,6 +118,7 @@ export default function PrFixQueuePage() { const blocked = items.filter((i) => i.status === "BLOCKED"); const parked = items.filter((i) => i.status === "PARKED"); const queued = items.filter((i) => i.status === "QUEUED"); + const waiting = items.filter((i) => i.status === "WAITING_EXTERNAL"); if (loading) { return
Loading...
; @@ -291,6 +294,27 @@ export default function PrFixQueuePage() { + + + + Waiting on external prerequisite + {waiting.length > 0 && {waiting.length}} + + + + {waiting.length === 0 ?

No external waits.

: ( + + Repo / PRLast-known explanationRetry At + {waiting.map((item) => + {prTitle(item)}
{item.repo}
+ {item.latestNote || item.reason} + {item.retryAt ? formatTime(item.retryAt) : "Not scheduled"} +
)}
+
+ )} +
+
+ diff --git a/src/lib/pr-fix-external-wait.test.ts b/src/lib/pr-fix-external-wait.test.ts new file mode 100644 index 00000000..81e45456 --- /dev/null +++ b/src/lib/pr-fix-external-wait.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from "vitest"; +import { listQueuedPrFixItems, markPrFixItem, resolvePrFixFromAgentReport, type PrFixQueueClient } from "./pr-fix-queue"; + +function clientFor(item: any, hooks: { beforeUpdate?: (where: any, data: any) => void } = {}) { + const history: any[] = []; + const client: any = { + prFixQueueItem: { + findUnique: async ({ where }: any) => where.id ? (where.id === item.id ? item : null) : item.repo === where.repo_pr.repo && item.pr === where.repo_pr.pr ? item : null, + findMany: async ({ where }: any = {}) => { + const statuses = where?.status?.in ?? [where?.status]; + if (where?.status && !statuses.includes(item.status)) return []; + if (where?.retryAt && (!item.retryAt || new Date(item.retryAt).getTime() > new Date(where.retryAt.lte).getTime())) return []; + return [item]; + }, + update: async ({ where, data }: any) => { if (where.id !== item.id) throw new Error("not found"); Object.assign(item, data); return item; }, + updateMany: async ({ where, data }: any) => { + hooks.beforeUpdate?.(where, data); + if (where.id !== item.id || (where.status && item.status !== where.status) || item.generation !== where.generation || (where.retryAt && item.retryAt !== where.retryAt)) return { count: 0 }; + for (const [key, value] of Object.entries(data)) { + if (key === "generation" && typeof value === "object" && value !== null && "increment" in value) item.generation += (value as any).increment; + else item[key] = value; + } + return { count: 1 }; + }, + }, + prFixHistory: { + create: async ({ data }: any) => { history.push(data); }, + findMany: async ({ where, orderBy }: any) => history.filter((entry) => where.itemId.in.includes(item.id) && entry.status === where.status && entry.action === where.action).reverse(), + }, + $transaction: async (fn: any) => fn(client), + }; + return { client: client as PrFixQueueClient, history }; +} + +const item = () => ({ id: "pf1", repo: "org/repo", pr: 7, status: "QUEUED", lane: "NORMAL", generation: 4, fixAttempts: 5, attemptHeadSha: null as string | null, retryAt: null as Date | null, dispatchedGeneration: 4, dispatchedAt: new Date(), agentHandouts: ["worker@4"], handoutAcks: ["worker@4"], postDispatchEvidenceKeys: [] }); + +describe("external prerequisite waits (#1231)", () => { + it("marks a generation once without charging attempts, and accepts repeats idempotently", async () => { + const row = item(); + const { client, history } = clientFor(row); + const first = await markPrFixItem(client, { repo: row.repo, pr: row.pr, status: "WAITING_EXTERNAL", expectedGeneration: 4, note: "CI dependency" }); + const deadline = row.retryAt; + const repeated = await markPrFixItem(client, { repo: row.repo, pr: row.pr, status: "WAITING_EXTERNAL", expectedGeneration: 4, note: "duplicate" }); + expect(first.mutated).toBe(true); + expect(repeated.mutated).toBe(true); + expect(row.status).toBe("WAITING_EXTERNAL"); + expect(row.fixAttempts).toBe(5); + expect(row.retryAt).toBe(deadline); + expect(history).toHaveLength(1); + }); + + it("reissues a due wait as a clean new generation without changing attempt budget", async () => { + const row = { ...item(), status: "WAITING_EXTERNAL", retryAt: new Date(Date.now() - 1), dispatchedAt: new Date(), agentHandouts: ["worker@4"], handoutAcks: ["worker@4"] }; + const { client, history } = clientFor(row); + const queued = await listQueuedPrFixItems(client); + expect(queued.map((x: any) => x.status)).toEqual(["QUEUED"]); + expect(row.generation).toBe(5); + expect(row.fixAttempts).toBe(5); + expect(row.retryAt).toBeNull(); + expect(row.dispatchedAt).toBeNull(); + expect(row.dispatchedGeneration).toBeNull(); + expect(row.postDispatchEvidenceKeys).toEqual([]); + expect(row.agentHandouts).toEqual([]); + expect(row.handoutAcks).toEqual([]); + expect(row.attemptHeadSha).toBeNull(); + expect(history).toHaveLength(1); + }); + + it("does not let stale wait marks overwrite an updated generation or sticky decisions", async () => { + const stale = item(); + stale.generation = 5; + const staleResult = await markPrFixItem(clientFor(stale).client, { repo: stale.repo, pr: stale.pr, status: "WAITING_EXTERNAL", expectedGeneration: 4 }); + expect(staleResult).toEqual({ mutated: false, reason: "generation-mismatch" }); + for (const status of ["BLOCKED", "PARKED"]) { + const sticky = { ...item(), status }; + const result = await markPrFixItem(clientFor(sticky).client, { repo: sticky.repo, pr: sticky.pr, status: "WAITING_EXTERNAL", expectedGeneration: 4 }); + expect(result).toEqual({ mutated: false, reason: "generation-mismatch" }); + expect(sticky.status).toBe(status); + } + }); + + it("routes waiting-external reports through the same generation-fenced transition", async () => { + const row = item(); + const { client } = clientFor(row); + const result = await resolvePrFixFromAgentReport({ repoFullName: row.repo, pullRequestNumber: row.pr, outcome: "waiting-external", attempt: { itemId: row.id, generation: 4 }, client }); + expect(result.action).toBe("deferred"); + expect(row.status).toBe("WAITING_EXTERNAL"); + expect(row.fixAttempts).toBe(5); + expect(row.lane).toBe("NORMAL"); + }); + + it("allows repeated external waits across the attempt cap without charging attempts", async () => { + const row = { ...item(), fixAttempts: 5 }; + const { client, history } = clientFor(row); + for (let cycle = 0; cycle < 3; cycle++) { + await markPrFixItem(client, { repo: row.repo, pr: row.pr, status: "WAITING_EXTERNAL", expectedGeneration: row.generation }); + expect(row.fixAttempts).toBe(5); + row.status = "QUEUED"; + row.generation += 1; + row.retryAt = null; + } + expect(history).toHaveLength(3); + }); + + it("does not resume a future cooldown", async () => { + const row = { ...item(), status: "WAITING_EXTERNAL", retryAt: new Date(Date.now() + 60_000) }; + const { client, history } = clientFor(row); + const historyBefore = history.length; + const queued = await listQueuedPrFixItems(client); + expect(queued).toEqual([]); + expect(row.status).toBe("WAITING_EXTERNAL"); + expect(row.generation).toBe(4); + expect(history).toHaveLength(historyBefore); + }); + + it.each(["report-first", "mark-first"]) ("deduplicates report/mark in %s order", async (order) => { + const row = item(); + const { client, history } = clientFor(row); + if (order === "report-first") { + await resolvePrFixFromAgentReport({ repoFullName: row.repo, pullRequestNumber: row.pr, outcome: "waiting-external", error: "missing prerequisite: X", attempt: { itemId: row.id, generation: 4 }, client }); + expect(history).toHaveLength(1); + expect(history[0].note).toBe("missing prerequisite: X"); + await markPrFixItem(client, { repo: row.repo, pr: row.pr, status: "WAITING_EXTERNAL", expectedGeneration: 4, note: "actual mark note" }); + } else { + await markPrFixItem(client, { repo: row.repo, pr: row.pr, status: "WAITING_EXTERNAL", expectedGeneration: 4, note: "actual mark note" }); + await resolvePrFixFromAgentReport({ repoFullName: row.repo, pullRequestNumber: row.pr, outcome: "waiting-external", error: "missing prerequisite: X", attempt: { itemId: row.id, generation: 4 }, client }); + } + const deadline = row.retryAt; + await resolvePrFixFromAgentReport({ repoFullName: row.repo, pullRequestNumber: row.pr, outcome: "waiting-external", attempt: { itemId: row.id, generation: 4 }, client }); + expect(row.retryAt).toBe(deadline); + expect(history).toHaveLength(1); + expect(history[0].note).toBe(order === "report-first" ? "missing prerequisite: X" : "actual mark note"); + }); + + it("sanitizes and bounds the stored external-wait note", async () => { + const row = item(); + const { client, history } = clientFor(row); + const secretError = `token=${"s".repeat(700)}`; + await resolvePrFixFromAgentReport({ repoFullName: row.repo, pullRequestNumber: row.pr, outcome: "waiting-external", error: secretError, attempt: { itemId: row.id, generation: 4 }, client }); + expect(history[0].note).toContain("token="); + expect(history[0].note.length).toBeLessThanOrEqual(500); + expect(history[0].note).not.toContain("s".repeat(700)); + }); + + it("preserves a concurrent BLOCKED transition", async () => { + const row = item(); + let raced = false; + const { client, history } = clientFor(row, { beforeUpdate: () => { if (!raced) { raced = true; row.status = "BLOCKED"; } } }); + const result = await markPrFixItem(client, { repo: row.repo, pr: row.pr, status: "WAITING_EXTERNAL", expectedGeneration: 4 }); + expect(result).toEqual({ mutated: false, reason: "generation-mismatch" }); + expect(row.status).toBe("BLOCKED"); + expect(history).toHaveLength(0); + }); + + it("accepts a concurrent wait winner as duplicate success", async () => { + const row = item(); + let raced = false; + const { client, history } = clientFor(row, { beforeUpdate: () => { if (!raced) { raced = true; row.status = "WAITING_EXTERNAL"; row.retryAt = new Date(Date.now() + 60_000); } } }); + const result = await markPrFixItem(client, { repo: row.repo, pr: row.pr, status: "WAITING_EXTERNAL", expectedGeneration: 4 }); + expect(result.mutated).toBe(true); + expect(row.status).toBe("WAITING_EXTERNAL"); + expect(history).toHaveLength(0); + }); + + it("does not overwrite status or generation changed during due-wait reactivation", async () => { + const row = { ...item(), status: "WAITING_EXTERNAL", retryAt: new Date(Date.now() - 1000) }; + const { client, history } = clientFor(row, { beforeUpdate: () => { row.status = "BLOCKED"; row.generation = 8; } }); + await listQueuedPrFixItems(client); + expect(row.status).toBe("BLOCKED"); + expect(row.generation).toBe(8); + expect(history).toHaveLength(0); + }); + + it("keeps attempt count through due reactivation and subsequent FIXED settlement", async () => { + const row = { ...item(), status: "WAITING_EXTERNAL", retryAt: new Date(Date.now() - 1000) }; + const { client } = clientFor(row); + await listQueuedPrFixItems(client); + const generation = row.generation; + await markPrFixItem(client, { repo: row.repo, pr: row.pr, status: "FIXED", expectedGeneration: generation }); + expect(row.status).toBe("FIXED"); + expect(row.generation).toBe(generation); + expect(row.fixAttempts).toBe(5); + }); + + it("reconciles closed and archived WAITING_EXTERNAL items using the real reconciliation functions", async () => { + const { reconcileArchivedRepoPrFixItems, reconcileStalePrFixItems } = await import("./pr-fix-queue"); + const closed = { ...item(), status: "WAITING_EXTERNAL" }; + const closedFixture = clientFor(closed); + expect(await reconcileStalePrFixItems(closedFixture.client, new Map([[closed.repo, new Set([closed.pr])]]), new Map([[closed.repo, new Map([[closed.pr, "closed"]])]]))).toMatchObject({ checked: 1, markedStale: 1, errored: 0 }); + expect(closed.status).toBe("STALE"); + const archived = { ...item(), status: "WAITING_EXTERNAL" }; + const archivedFixture = clientFor(archived); + expect(await reconcileArchivedRepoPrFixItems(archivedFixture.client, async () => true)).toMatchObject({ checked: 1, markedStale: 1, errored: 0 }); + expect(archived.status).toBe("STALE"); + }); + + it("does not treat an old attempt as a repeat of a newer external wait", async () => { + const row = { ...item(), status: "WAITING_EXTERNAL", generation: 5 }; + const result = await resolvePrFixFromAgentReport({ repoFullName: row.repo, pullRequestNumber: row.pr, outcome: "waiting-external", attempt: { itemId: row.id, generation: 4 }, client: clientFor(row).client }); + expect(result.action).toBe("skipped"); + expect(result.reason).toContain("stale attempt generation"); + }); +}); diff --git a/src/lib/pr-fix-queue.test.ts b/src/lib/pr-fix-queue.test.ts index e9deef6f..f5a6a36b 100644 --- a/src/lib/pr-fix-queue.test.ts +++ b/src/lib/pr-fix-queue.test.ts @@ -128,6 +128,7 @@ function makeClient(): PrFixQueueClient & { // so a freshly created row reads back as "no ack, never reclaimed". handoutAcks: [], // mirrors the column's @default([]) handoutReclaims: 0, // mirrors the column's @default(0) + retryAt: null, // #1218 outcome classification columns: default to null so a freshly // created row reads back as "no outcome / no decision". outcome: null, @@ -171,6 +172,7 @@ function makeClient(): PrFixQueueClient & { (where.id === undefined || i.id === where.id) && (where.generation === undefined || i.generation === where.generation) && (where.status === undefined || i.status === where.status) && + (where.retryAt === undefined || (where.retryAt.lte && i.retryAt != null && new Date(i.retryAt).getTime() <= new Date(where.retryAt.lte).getTime()) || i.retryAt === where.retryAt) && (where.postDispatchEvidenceKeys === undefined || JSON.stringify(i.postDispatchEvidenceKeys ?? []) === JSON.stringify(where.postDispatchEvidenceKeys.equals ?? [])) && @@ -195,6 +197,10 @@ function makeClient(): PrFixQueueClient & { : result.filter((i) => i.status === where.status); } if (where?.lane) result = result.filter((i) => i.lane === where.lane); + if (where?.retryAt?.lte) { + const cutoff = new Date(where.retryAt.lte).getTime(); + result = result.filter((i) => i.retryAt != null && new Date(i.retryAt).getTime() <= cutoff); + } if (where?.dispatchedAt) { // Mirror Prisma's `dispatchedAt: { lt: dueBefore }` only — the // reclaimer query (#1211 review) is the only consumer today and @@ -357,6 +363,22 @@ describe("PR review-fix queue", () => { expect((await listQueuedPrFixItems(client, { lane: "needs-human", includeBlocked: true })).map((i) => i.pr)).toEqual([3]); }); + it("includes future external waits only when requested, with the latest note and deadline", async () => { + const item = await enqueuePrFixItem(client, { repo: "org/repo", pr: 45, lane: "NORMAL", reason: "CI blocked", feedback: "wait", evidenceKey: "wait:1" }); + await markPrFixItem(client, { repo: item.repo, pr: item.pr, status: "WAITING_EXTERNAL", expectedGeneration: item.generation, note: "older note" }); + const deadline = new Date(Date.now() + 60 * 60_000); + item.retryAt = deadline; + client.history.push({ itemId: item.id, action: "mark", status: "WAITING_EXTERNAL", note: "latest note", at: new Date(Date.now() + 1) }); + + const ordinary = await listQueuedPrFixItems(client); + expect(ordinary).toEqual([]); + const included = await listQueuedPrFixItems(client, { includeBlocked: true }); + expect(included).toHaveLength(1); + expect(included[0]).toMatchObject({ status: "WAITING_EXTERNAL", latestNote: "latest note" }); + expect(included[0].retryAt).toBeTruthy(); + expect(client.items.find((candidate) => candidate.id === item.id)?.status).toBe("WAITING_EXTERNAL"); + }); + it("supports status transitions with history", async () => { await enqueuePrFixItem(client, { repo: "org/repo", pr: 5, lane: "NORMAL", reason: "r", feedback: "f", evidenceKey: "e" }); const fixed = await markPrFixItem(client, { repo: "org/repo", pr: 5, status: "fixed", note: "pushed fix + validation" }); diff --git a/src/lib/pr-fix-queue.ts b/src/lib/pr-fix-queue.ts index 4783ceae..e35a0715 100644 --- a/src/lib/pr-fix-queue.ts +++ b/src/lib/pr-fix-queue.ts @@ -3,6 +3,7 @@ import { surfacePrFixBlocked, surfacePrFixParked, surfacePrFixRequeued, surfaceP import { prisma } from "@/lib/prisma"; import { fetchPullRequestMergeState, fetchPullRequestHeadSha, fetchPullRequestState } from "./github-prs"; import { fetchRepositoryMetadata } from "./github-code-search"; +import { sanitizeForStorage } from "./groomer/sanitize"; export type PrFixQueueClient = { issue: { @@ -550,6 +551,24 @@ export function maxPrFixAttempts(): number { return Number.isInteger(n) && n > 0 ? n : 5; } +export function externalWaitCooldownMs(): number { + const n = Number(process.env.PR_FIX_EXTERNAL_WAIT_COOLDOWN_MS); + return Number.isInteger(n) && n >= 60_000 && n <= 7 * 24 * 60 * 60 * 1000 ? n : 6 * 60 * 60 * 1000; +} + +async function reactivateDueExternalWaits(client: PrFixQueueClient, now: Date): Promise { + const due = await client.prFixQueueItem.findMany({ where: { status: "WAITING_EXTERNAL", retryAt: { lte: now } }, take: 100 }); + for (const row of due.filter((candidate) => candidate.status === "WAITING_EXTERNAL" && candidate.retryAt && new Date(candidate.retryAt).getTime() <= now.getTime())) { + await client.$transaction(async (tx) => { + const { count } = await tx.prFixQueueItem.updateMany({ + where: { id: row.id, status: "WAITING_EXTERNAL", generation: row.generation, retryAt: row.retryAt }, + data: { status: "QUEUED", ...freshAttemptGeneration(), retryAt: null, dispatchedGeneration: null, dispatchedAt: null, postDispatchEvidenceKeys: [], attemptHeadSha: row.headSha ?? row.attemptHeadSha ?? null, outcome: null, decisionDetail: null, agentHandouts: [], handoutAcks: [] }, + }); + if (count === 1) await tx.prFixHistory.create({ data: { itemId: row.id, action: "mark", status: "QUEUED", lane: row.lane, note: "External-wait cooldown elapsed; retrying prerequisite check." } }); + }); + } +} + /** * How long a stamped hand-out may sit unacknowledged before the reclaimer * treats it as abandoned (#1211). A worker that durably materialized its @@ -1098,7 +1117,8 @@ export async function enqueuePrFixItem(client: PrFixQueueClient, rawInput: Enque snapshot.status === "STALE" || snapshot.status === "IGNORED" || snapshot.status === "BLOCKED" || - snapshot.status === "PARKED"; + snapshot.status === "PARKED" || + snapshot.status === "WAITING_EXTERNAL"; const nextEvidenceKeys = uniqueAppend(snapshot.evidenceKeys ?? [], input.evidenceKey, 40); @@ -1446,7 +1466,9 @@ export async function createLinkedPrFixItem( export async function listQueuedPrFixItems(client: PrFixQueueClient, options: { lane?: string | null; includeBlocked?: boolean; prioritizeByType?: boolean } = {}) { const lane = options.lane ? normalizePrFixLane(options.lane) : undefined; - const status = options.includeBlocked ? { in: ["QUEUED", "BLOCKED", "PARKED"] } : "QUEUED"; + const now = new Date(); + await reactivateDueExternalWaits(client, now); + const status = options.includeBlocked ? { in: ["QUEUED", "BLOCKED", "PARKED", "WAITING_EXTERNAL"] } : "QUEUED"; const items = await client.prFixQueueItem.findMany({ where: { status, ...(lane ? { lane } : {}) }, @@ -1465,6 +1487,16 @@ export async function listQueuedPrFixItems(client: PrFixQueueClient, options: { items.sort((a, b) => new Date(a.queuedAt).getTime() - new Date(b.queuedAt).getTime()); } + if (options.includeBlocked && items.some((item) => item.status === "WAITING_EXTERNAL") && client.prFixHistory.findMany) { + const waits = await client.prFixHistory.findMany({ + where: { itemId: { in: items.filter((item) => item.status === "WAITING_EXTERNAL").map((item) => item.id) }, status: "WAITING_EXTERNAL", action: "mark" }, + orderBy: { at: "desc" }, + }); + const latestNotes = new Map(); + for (const row of waits) if (!latestNotes.has(row.itemId)) latestNotes.set(row.itemId, row.note ?? null); + for (const item of items) if (item.status === "WAITING_EXTERNAL") item.latestNote = latestNotes.get(item.id) ?? null; + } + return items; } @@ -1554,6 +1586,9 @@ export async function markPrFixItem( const input: MarkPrFixInput = { ...rawInput, repo: normalizeQueueRepo(rawInput.repo) }; const nextStatus = normalizePrFixStatus(input.status) as PrFixStatus | null; if (!nextStatus) throw new Error("Invalid status"); + if (nextStatus === "WAITING_EXTERNAL" && (input.expectedGeneration === undefined || input.expectedGeneration === null)) { + throw new Error("generation is required for WAITING_EXTERNAL"); + } const expectedGeneration = input.expectedGeneration !== undefined && input.expectedGeneration !== null @@ -1571,6 +1606,20 @@ export async function markPrFixItem( // is bounded exhaustion or an operational failure: lane NORMAL, no // needs-human label. The bridge's ACTIONABLE_LANES filter skips BLOCKED. const data: Record = { status: nextStatus }; + if (nextStatus === "WAITING_EXTERNAL") { + if (existing.status === "WAITING_EXTERNAL" && existing.generation === expectedGeneration) { + return { mutated: true, item: existing }; + } + if (existing.status !== "QUEUED") return { mutated: false, reason: "generation-mismatch" }; + data.retryAt = new Date(Date.now() + externalWaitCooldownMs()); + data.outcome = null; + data.decisionDetail = null; + data.postDispatchEvidenceKeys = []; + data.dispatchedGeneration = null; + data.dispatchedAt = null; + data.agentHandouts = []; + data.handoutAcks = []; + } // #1218 review: a recorded human decision (BLOCKED) and a recorded operator // PARKED are sticky to worker settlements — the commit-time status pin below @@ -1602,6 +1651,22 @@ export async function markPrFixItem( input.actorTier !== "maintainer" ); + if (nextStatus === "WAITING_EXTERNAL") { + const result = await client.$transaction(async (tx) => { + const { count } = await tx.prFixQueueItem.updateMany({ + where: { id: existing.id, status: "QUEUED", generation: expectedGeneration }, + data, + }); + if (count !== 1) return null; + await tx.prFixHistory.create({ data: { itemId: existing.id, action: "mark", status: "WAITING_EXTERNAL", lane: existing.lane, note: input.note ?? null } }); + return tx.prFixQueueItem.findUnique({ where: { id: existing.id } }); + }); + if (result) return { mutated: true, item: result }; + const latest = await client.prFixQueueItem.findUnique({ where: { id: existing.id } }); + if (latest?.status === "WAITING_EXTERNAL" && latest.generation === expectedGeneration) return { mutated: true, item: latest }; + return { mutated: false, reason: "generation-mismatch" }; + } + // Reopen a settlement as a fresh attempt on post-dispatch evidence (#1119): // the guarded write, the cap give-up, and the history row, all inside one // tiny transaction the caller passes in. Pinned on the row's id, its @@ -2153,7 +2218,7 @@ export async function reconcileStalePrFixItems( where: { repo, pr: { in: Array.from(prNumbers) }, - status: { in: ["QUEUED", "BLOCKED", "PARKED"] }, + status: { in: ["QUEUED", "BLOCKED", "PARKED", "WAITING_EXTERNAL"] }, }, }); checked += staleCandidates.length; @@ -2214,7 +2279,7 @@ export async function reconcileArchivedRepoPrFixItems( let markedStale = 0; let errored = 0; - const active = await client.prFixQueueItem.findMany({ where: { status: { in: ["QUEUED", "BLOCKED", "PARKED"] } } }); + const active = await client.prFixQueueItem.findMany({ where: { status: { in: ["QUEUED", "BLOCKED", "PARKED", "WAITING_EXTERNAL"] } } }); const byRepo = new Map(); for (const item of active) byRepo.set(item.repo, [...(byRepo.get(item.repo) ?? []), item]); @@ -2449,6 +2514,7 @@ export type AgentReportOutcome = | "blocked" | "failed" | "already_addressed" + | "waiting-external" // #1143: an issue close, not a PR-fix settlement; never touches the queue. | "already_done" | "handed-off"; @@ -2468,6 +2534,7 @@ export interface ResolvePrFixFromAgentReportInput { pullRequestUrl?: string | null; outcome: AgentReportOutcome; summary?: string | null; + error?: string | null; // #1121 evidence string (commit SHAs/paths) carried by an `already_addressed` report. evidence?: string | null; // #1218: for a `blocked` report, the structured reason. `human_decision` @@ -2626,6 +2693,13 @@ export async function resolvePrFixFromAgentReport( } const currentStatus = normalizePrFixStatus(existing.status) as PrFixStatus | null; + if ( + input.outcome === "waiting-external" && + currentStatus === "WAITING_EXTERNAL" && + attempt?.generation === existing.generation + ) { + return { matched: true, action: "deferred", itemId: existing.id ?? null, reason: "external wait already recorded for this generation", attemptGeneration: attempt.generation }; + } if (!currentStatus || currentStatus !== "QUEUED") { // Already settled (FIXED / BLOCKED / STALE). Idempotent — nothing to do. return { @@ -2639,6 +2713,22 @@ export async function resolvePrFixFromAgentReport( const expectedGeneration = attempt?.generation ?? undefined; + if (input.outcome === "waiting-external") { + if (expectedGeneration === undefined) { + return { matched: true, action: "skipped", itemId: existing.id ?? null, reason: "waiting-external requires a prFixItem attempt token" }; + } + const marked = await markPrFixItem(client as PrFixQueueClient, { + repo, pr, status: "WAITING_EXTERNAL", note: sanitizeForStorage(input.summary?.trim() || input.error || "", 500) || null, expectedGeneration, + }); + return { + matched: true, + action: marked.mutated ? "deferred" : "skipped", + itemId: existing.id ?? null, + reason: marked.mutated ? "worker is waiting for an external prerequisite" : `wait transition skipped: ${marked.reason}`, + attemptGeneration: attempt?.generation ?? null, + }; + } + if (input.outcome === "blocked") { // #1218: a `blocked` report is a genuine human decision only when it // carries a `human_decision` blockReason. A bare give-up (or any other diff --git a/src/types/index.ts b/src/types/index.ts index 4ad8a106..445a45a3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -249,10 +249,10 @@ export function isValidEscalatedOutcome(outcome: string): outcome is EscalatedOu // ─── PR Review-Fix Queue Constants and Helpers ─────────────────────────────── export type PrFixLane = "NORMAL" | "ESCALATED" | "NEEDS_HUMAN"; -export type PrFixStatus = "QUEUED" | "FIXED" | "BLOCKED" | "PARKED" | "STALE" | "IGNORED"; +export type PrFixStatus = "QUEUED" | "FIXED" | "BLOCKED" | "PARKED" | "STALE" | "IGNORED" | "WAITING_EXTERNAL"; export const VALID_PR_FIX_LANES: PrFixLane[] = ["NORMAL", "ESCALATED", "NEEDS_HUMAN"]; -export const VALID_PR_FIX_STATUSES: PrFixStatus[] = ["QUEUED", "FIXED", "BLOCKED", "PARKED", "STALE", "IGNORED"]; +export const VALID_PR_FIX_STATUSES: PrFixStatus[] = ["QUEUED", "FIXED", "BLOCKED", "PARKED", "STALE", "IGNORED", "WAITING_EXTERNAL"]; // ─── PR Fix Outcome Constants ──────────────────────────────────────────────── //