Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,17 @@ The dashboard's **What's New** rail reads this file at request time
entries. Keep entries terse — one line per item under each version
date, grouped by `Added` / `Changed` / `Fixed` / `Removed`.

## [Unreleased]
## [2026-07-14] — v0.15.0 · Reliable GitHub status recovery

### Added

- **Linked implementation PRs now self-heal after missed GitHub webhooks.** A settings-driven stale-only worker refreshes PR lifecycle and aggregate checks with bounded batches, restart-safe leases, provider-aware rate-limit backoff, and visible retry diagnostics.

### Fixed

- **GitHub status recovery cannot certify stale or partial evidence.** New PR heads invalidate cached checks, late check events for older heads are ignored, partial GitHub App permissions remain non-blocking, and merged PRs without confirmed checks back off instead of polling hot or closing issues.
- **Check webhooks are refresh hints, not repository-wide success.** Forge now paginates every check suite, combines legacy commit status, distrusts incomplete or malformed aggregates, and only completion evidence read directly from GitHub can close work.
- **Provider failures cannot monopolize maintenance.** Configurable request timeouts, sweep budgets, manual-refresh cooldowns, dormant closed-PR rechecks, atomic leases, and mapping-wide backoff contain timeouts, permission failures, and rate limits while allowing other workspaces to continue.

## [2026-07-14] — v0.14.0 · Proactive completion handoff

Expand Down
39 changes: 39 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,45 @@

> Append-only session log. Read at session start. Update at session end.

## 2026-07-14 — Resilient linked-PR status reconciliation

Added a webhook-first repair loop for native GitHub `IMPLEMENTS` links. The
maintenance worker now scans only stale, non-terminal PR snapshots in bounded
per-workspace batches, claims each row with an expiring database lease, and
refreshes PR lifecycle plus both GitHub check suites and legacy combined commit
statuses. Workspace admins can configure enablement, stale age, batch size, and
initial/maximum backoff from Connections settings.

Persisted provider attempt, retry, failure, diagnostic, and terminal state on
`ExternalResource`. GitHub rate-limit headers override exponential retry;
missing/paused mappings are re-resolved by repository; inaccessible resources
back off without being unlinked; closed PRs enter a slow configurable re-probe
cadence so a missed reopen event self-heals; and confirmed-green merged PRs stop
polling. Partial Checks/status permissions no longer prevent a readable PR from
linking, but partial evidence cannot certify completion. Head changes clear
cached checks and late check webhooks for an older head are ignored. Repeated
unchanged refreshes produce no issue activity or duplicate completion cards.

The safety review removed webhook conclusions as aggregate completion evidence:
check-suite and check-run deliveries now mark a resource dirty, while the repair
loop paginates every check suite and combines legacy commit status before it can
trust success. Completed suites without conclusions, malformed aggregates, and
partially readable endpoints stay unresolved. Configurable request timeouts,
shared-worker wall budgets, atomic manual-refresh cooldowns, exact stale-row
claims, and persistent mapping-wide circuits contain permission failures,
timeouts, and rate limits without blocking later workspaces.

Verification: Prisma migrations 0102 and 0103 applied locally; lint passed with
existing repository warnings only; typecheck passed; focused GitHub
reconciliation, client, check aggregation, completion, timeout, circuit-breaker,
dormant-reopen, cooldown, and lease-race coverage passed (**22/22**); the full
Vitest suite passed **1,259 tests** with one intentional live-connector skip; a
fresh production build passed; and the full serial Playwright suite passed
**38/38**. The browser gate also exposed a stale dashboard assertion that
hard-coded two rail columns despite the existing work-count threshold; the
assertion now follows the rendered layout mode and passed both alone and in the
full suite.

## 2026-07-13 — AXI-102 patient-wait spam + expandable Command Center cards

Closed the AXI-102 stall-comment loop introduced by approval-expiry recovery.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "forge",
"version": "0.14.0",
"version": "0.15.0",
"private": true,
"description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.",
"license": "MIT",
Expand Down
25 changes: 25 additions & 0 deletions prisma/migrations/0102_github_status_reconciliation/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
ALTER TABLE "Workspace"
ADD COLUMN "githubSyncEnabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "githubSyncStaleMinutes" INTEGER NOT NULL DEFAULT 15,
ADD COLUMN "githubSyncBatchSize" INTEGER NOT NULL DEFAULT 25,
ADD COLUMN "githubSyncBackoffMinutes" INTEGER NOT NULL DEFAULT 5,
ADD COLUMN "githubSyncMaxBackoffMinutes" INTEGER NOT NULL DEFAULT 1440;

ALTER TABLE "ExternalResource"
ADD COLUMN "syncAttemptedAt" TIMESTAMP(3),
ADD COLUMN "syncRetryAt" TIMESTAMP(3),
ADD COLUMN "syncFailureCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "syncLastError" TEXT,
ADD COLUMN "syncTerminalAt" TIMESTAMP(3);

CREATE INDEX "ExternalResource_workspaceId_provider_resourceType_syncTerminalAt_lastSyncedAt_idx"
ON "ExternalResource"("workspaceId", "provider", "resourceType", "syncTerminalAt", "lastSyncedAt");
CREATE INDEX "ExternalResource_syncRetryAt_idx" ON "ExternalResource"("syncRetryAt");

-- Closed unmerged PRs are final. Merged PRs remain eligible until Forge has
-- refreshed aggregate checks at least once under the new reconciliation path.
UPDATE "ExternalResource"
SET "syncTerminalAt" = COALESCE("lastSyncedAt", "updatedAt")
WHERE "provider" = 'GITHUB'
AND "resourceType" = 'PULL_REQUEST'
AND "state" = 'closed';
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ALTER TABLE "Workspace"
ADD COLUMN "githubRequestTimeoutSeconds" INTEGER NOT NULL DEFAULT 10,
ADD COLUMN "githubSweepBudgetSeconds" INTEGER NOT NULL DEFAULT 45,
ADD COLUMN "githubClosedReprobeMinutes" INTEGER NOT NULL DEFAULT 1440,
ADD COLUMN "githubManualCooldownSeconds" INTEGER NOT NULL DEFAULT 30;
30 changes: 30 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,25 @@ model Workspace {
/// DONE-category status used by completion recommendations/automation.
/// Null keeps completion candidates disabled until an admin chooses one.
completionStatusId String?
/// Self-heal missed GitHub webhooks by polling stale linked implementation
/// PRs. Webhooks remain the primary path; this only reconciles stale rows.
githubSyncEnabled Boolean @default(true)
/// Minimum age of a successful GitHub snapshot before it may be polled.
githubSyncStaleMinutes Int @default(15)
/// Maximum stale GitHub resources processed for this workspace per sweep.
githubSyncBatchSize Int @default(25)
/// Initial retry delay for provider failures; retries grow exponentially.
githubSyncBackoffMinutes Int @default(5)
/// Maximum retry delay for repeated provider failures.
githubSyncMaxBackoffMinutes Int @default(1440)
/// Per-request timeout for GitHub REST calls made by the repair loop.
githubRequestTimeoutSeconds Int @default(10)
/// Maximum wall time a GitHub repair sweep may occupy the shared worker.
githubSweepBudgetSeconds Int @default(45)
/// Slow re-probe cadence for closed-unmerged PRs so missed reopen events heal.
githubClosedReprobeMinutes Int @default(1440)
/// Minimum interval between operator/agent manual refresh requests per PR.
githubManualCooldownSeconds Int @default(30)
/// Issues with `updatedAt` older than this threshold (in days) are
/// considered "stalled" by the dashboard suggestions bucket. Settings-
/// driven so admins can tune the workspace's idle definition without
Expand Down Expand Up @@ -2185,6 +2204,15 @@ model ExternalResource {
externalCreatedAt DateTime?
externalUpdatedAt DateTime?
lastSyncedAt DateTime?
/// Last background/manual provider request attempt, successful or not.
syncAttemptedAt DateTime?
/// Retry gate after rate limiting, auth/mapping errors, or provider failure.
syncRetryAt DateTime?
syncFailureCount Int @default(0)
syncLastError String? @db.Text
/// Set once this resource no longer needs background reconciliation.
/// Open/draft PRs and merged PRs without passing aggregate checks stay null.
syncTerminalAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand All @@ -2196,6 +2224,8 @@ model ExternalResource {
@@index([workspaceId, provider, repoFullName])
@@index([workspaceId, provider, externalNodeId])
@@index([connectionMappingId])
@@index([workspaceId, provider, resourceType, syncTerminalAt, lastSyncedAt])
@@index([syncRetryAt])
}

/// Join between a Forge issue and an external resource. The link kind is
Expand Down
3 changes: 3 additions & 0 deletions src/app/(app)/w/[slug]/settings/connections/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Input } from "@/components/ui/input";
import { Confirm, QuickForm } from "@/components/ui/modal";
import { Card } from "@/components/settings/card";
import { EmptyState } from "@/components/settings/empty-state";
import { GitHubReconciliationPolicy } from "@/components/settings/github-reconciliation-policy";
import { Section } from "@/components/ui";
import { trpc } from "@/lib/trpc";

Expand Down Expand Up @@ -443,6 +444,8 @@ export default function ConnectionsMappingPage() {
</p>
</div>

<GitHubReconciliationPolicy isAdmin={isAdmin} />

{!connectionsLoading && !hasAnyConnection && (
<EmptyState
as="div"
Expand Down
23 changes: 21 additions & 2 deletions src/components/issue-detail/github-links-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"use client";

import { useState } from "react";
import { ExternalLink, GitPullRequest, Github, Plus, RefreshCw } from "lucide-react";
import { AlertTriangle, ExternalLink, GitPullRequest, Github, Plus, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
import { relativeTime } from "@/lib/utils";
import { formatDate, relativeTime } from "@/lib/utils";
import { GitHubLinkModal } from "@/components/issue-detail/github-link-modal";

function kindLabel(kind: string): string {
Expand Down Expand Up @@ -95,6 +95,25 @@ export function GitHubLinksPanel({ issueId }: { issueId: string }) {
<span>synced {relativeTime(resource.lastSyncedAt)}</span>
)}
</div>
{resource.syncLastError && (
<div
className="mt-1 flex items-start gap-1 text-[0.6875rem] text-warning"
title={resource.syncLastError}
>
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
<span className="line-clamp-2">
GitHub refresh delayed
{resource.syncRetryAt
? ` · retry ${formatDate(resource.syncRetryAt, undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}`
: ""}
</span>
</div>
)}
</div>
<Button
type="button"
Expand Down
Loading
Loading