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
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ node_modules
**/node_modules
.next
.next-e2e
.next-*

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop ignoring next-env.d.ts from Docker contexts

In the release worker build path I checked (RELEASE.md builds forge-worker and the Dockerfile's worker stage does COPY ... next-env.d.ts ...), this pattern also matches the generated next-env.d.ts file at the repo root, not just .next-* build directories. Because ignored files are removed from the Docker context, docker compose build forge-worker will fail at that COPY once this line is present; add an exception such as !next-env.d.ts or use a narrower directory-only pattern.

Useful? React with 👍 / 👎.

**/.next
**/.next-e2e
**/.next-*
!next-env.d.ts
docs/.vitepress/dist
public/docs
.git
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ 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`.

## [2026-07-14] — v0.15.1 · Manual GitHub retry safety

### Fixed

- **Manual GitHub refresh failures now honor provider retry timing.** Rate limits, permission failures, and timeouts persist the real diagnostic and mapping-wide retry circuit instead of falling back to a short collision lease.
- **Release builds exclude every generated Next output directory.** Lifecycle and other named `.next-*` caches no longer inflate Docker contexts or exhaust builder storage.

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

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

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

## 2026-07-14 — Manual GitHub retry persistence and release context hygiene

Addressed the post-merge Codex review on PR #29 before production deployment.
Manual and MCP-triggered GitHub refreshes now catch provider failures before
returning them, replace the temporary collision lease with the later of the
workspace exponential backoff or GitHub retry/reset header, persist the failure
count and diagnostic, and open the same mapping-wide circuit used by scheduled
reconciliation for rate limits, permissions, and timeouts. Scheduled sweeps
retain their existing single-owner retry accounting. Resource existence is
also checked before the manual lease so missing links still return NOT_FOUND.
The follow-up Codex review on PR #30 identified that a long provider request
could consume a short backoff before persistence; manual retry timing is now
anchored after the provider call finishes.
The second review pass found that a shorter new mapping-wide delay could
replace a longer existing provider reset on sibling resources. Mapping circuits
are now monotonic: only missing or earlier retry gates are extended.
The final review pass identified the non-throwing partial-checks path: PR reads
can succeed while Checks API calls return a settled partial snapshot. Manual
and MCP syncs now promote that metadata into the same persisted mapping-wide
failure circuit and return the updated diagnostic/backoff state immediately.
Generic partial snapshots (pagination limits or transient 5xx responses) are
now distinguished from permission failures and back off only the affected PR;
only auth, rate-limit, and timeout partials open a mapping-wide circuit in both
manual and scheduled reconciliation.
The last concurrency review found that overlapping refreshes could still
shorten the failing resource's own gate. Primary-row retry updates now use the
same atomic extend-only predicate as sibling rows, and failure counts increment
atomically so concurrent errors cannot lose accounting.
Partial-check diagnostics are already counted by the snapshot upsert, so the
subsequent mapping-circuit extension now preserves that count instead of
incrementing it a second time and skipping an exponential-backoff step.
Failure persistence now recognizes the exact collision lease acquired by the
current manual refresh, allowing it to be replaced by a shorter configured
backoff while still preserving any different, later provider gate written by
an overlapping refresh. The Docker ignore file also explicitly re-includes
`next-env.d.ts` for the worker stage's `COPY` contract.

The first release image attempt also exposed that `.dockerignore` covered
`.next` and `.next-e2e` but not `.next-lifecycle`. All named `.next-*` outputs
are now excluded so local verification caches cannot inflate release contexts
or exhaust Docker storage.

Verification: focused GitHub reconciliation and completion policy suite (23/23),
lint (existing warnings only), TypeScript typecheck, and `git diff --check`.

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

Added a webhook-first repair loop for native GitHub `IMPLEMENTS` links. The
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.15.0",
"version": "0.15.1",
"private": true,
"description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.",
"license": "MIT",
Expand Down
156 changes: 156 additions & 0 deletions src/server/services/__tests__/github-reconciliation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { afterAll, afterEach, describe, expect, it } from "vitest";
import { GitHubRequestError } from "@/server/services/github/client";
import {
claimGitHubManualSync,
gitHubPartialChecksError,
persistGitHubManualSyncFailure,
upsertExternalResource,
} from "@/server/services/github/resource-sync";
import {
Expand Down Expand Up @@ -89,6 +91,39 @@ async function setupResource() {
}

describe("GitHub status reconciliation", () => {
it("promotes partial checks metadata into a mapping-wide manual failure", () => {
const retryAt = "2026-07-14T13:00:00.000Z";
expect(
gitHubPartialChecksError({
checks: {
partial: true,
rateLimited: true,
timedOut: false,
diagnostic: "Checks API rate limit exceeded",
retryAt,
},
}),
).toMatchObject({
message: "Checks API rate limit exceeded",
status: 429,
retryAt: new Date(retryAt),
rateLimited: true,
timedOut: false,
});
expect(gitHubPartialChecksError({ checks: { partial: false } })).toBeNull();
expect(
gitHubPartialChecksError({
checks: {
partial: true,
rateLimited: false,
timedOut: false,
permissionDenied: false,
diagnostic: "Check suites require another page",
},
}),
).toMatchObject({ status: 503, rateLimited: false, timedOut: false });
});

it("polls only stale native implementation links and is a no-op once fresh", async () => {
const { fixture, prisma, resource } = await setupResource();
const now = new Date("2026-07-14T12:00:00.000Z");
Expand Down Expand Up @@ -433,6 +468,127 @@ describe("GitHub status reconciliation", () => {
).resolves.toBe(true);
});

it("persists provider retry timing and diagnostics after a manual refresh failure", async () => {
const { fixture, prisma, resource, mapping } = await setupResource();
const now = new Date("2026-07-14T12:00:00.000Z");
const reset = new Date("2026-07-14T13:00:00.000Z");
const laterReset = new Date("2026-07-14T14:00:00.000Z");
const sibling = await prisma.externalResource.create({
data: {
workspaceId: fixture.workspace.id,
provider: "GITHUB",
resourceType: "PULL_REQUEST",
repoFullName: "acme/forge",
number: 99,
url: "https://github.com/acme/forge/pull/99",
title: "Same mapping",
state: "open",
connectionMappingId: mapping.id,
},
});
const siblingWithLaterGate = await prisma.externalResource.create({
data: {
workspaceId: fixture.workspace.id,
provider: "GITHUB",
resourceType: "PULL_REQUEST",
repoFullName: "acme/forge",
number: 100,
url: "https://github.com/acme/forge/pull/100",
title: "Same mapping with a later gate",
state: "open",
connectionMappingId: mapping.id,
syncRetryAt: laterReset,
syncLastError: "Existing longer provider reset",
},
});
await prisma.externalResource.update({
where: { id: resource.id },
data: { syncRetryAt: laterReset },
});

await expect(
persistGitHubManualSyncFailure({
db: prisma,
workspaceId: fixture.workspace.id,
externalResourceId: resource.id,
connectionMappingId: mapping.id,
currentFailureCount: 0,
now,
baseMinutes: 5,
maxMinutes: 1440,
error: new GitHubRequestError("API rate limit exceeded", 429, reset, true),
}),
).resolves.toEqual({ retryAt: laterReset, failureCount: 1, mappingWide: true });

const [failed, mappedSibling, mappedSiblingWithLaterGate] = await Promise.all([
prisma.externalResource.findUniqueOrThrow({ where: { id: resource.id } }),
prisma.externalResource.findUniqueOrThrow({ where: { id: sibling.id } }),
prisma.externalResource.findUniqueOrThrow({ where: { id: siblingWithLaterGate.id } }),
]);
expect(failed).toMatchObject({
syncRetryAt: laterReset,
syncFailureCount: 1,
syncLastError: "API rate limit exceeded",
});
expect(mappedSibling).toMatchObject({
syncRetryAt: reset,
syncFailureCount: 0,
syncLastError: "API rate limit exceeded",
});
expect(mappedSiblingWithLaterGate).toMatchObject({
syncRetryAt: laterReset,
syncFailureCount: 0,
syncLastError: "Existing longer provider reset",
});

await expect(
persistGitHubManualSyncFailure({
db: prisma,
workspaceId: fixture.workspace.id,
externalResourceId: resource.id,
connectionMappingId: mapping.id,
currentFailureCount: 1,
now: new Date("2026-07-14T12:05:00.000Z"),
baseMinutes: 5,
maxMinutes: 1440,
error: new GitHubRequestError("Partial checks already counted", 403, null),
incrementFailureCount: false,
}),
).resolves.toMatchObject({ failureCount: 1, mappingWide: true });
await expect(
prisma.externalResource.findUniqueOrThrow({ where: { id: resource.id } }),
).resolves.toMatchObject({ syncFailureCount: 1 });
});

it("replaces the current manual lease without shortening an older provider gate", async () => {
const { fixture, prisma, resource, mapping } = await setupResource();
const now = new Date("2026-07-14T12:00:00.000Z");
const retryAt = new Date("2026-07-14T12:05:00.000Z");
const claimedLeaseUntil = new Date("2026-07-14T12:10:00.000Z");
await prisma.externalResource.update({
where: { id: resource.id },
data: { syncRetryAt: claimedLeaseUntil },
});

await expect(
persistGitHubManualSyncFailure({
db: prisma,
workspaceId: fixture.workspace.id,
externalResourceId: resource.id,
connectionMappingId: mapping.id,
currentFailureCount: 0,
now,
baseMinutes: 5,
maxMinutes: 1440,
error: new GitHubRequestError("Timed out", 408, retryAt, false, true),
claimedLeaseUntil,
}),
).resolves.toMatchObject({ retryAt, failureCount: 1, mappingWide: true });
await expect(
prisma.externalResource.findUniqueOrThrow({ where: { id: resource.id } }),
).resolves.toMatchObject({ syncRetryAt: retryAt });
});

it("stops starting resources after the workspace sweep budget is exhausted", async () => {
const { fixture, prisma, resource } = await setupResource();
const issue = await createIssue(fixture, { statusCategory: "IN_PROGRESS" });
Expand Down
8 changes: 8 additions & 0 deletions src/server/services/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export type GitHubChecksSnapshot = {
partial: boolean;
rateLimited: boolean;
timedOut: boolean;
permissionDenied: boolean;
diagnostic: string | null;
retryAt: string | null;
headSha: string;
Expand Down Expand Up @@ -331,6 +332,12 @@ export async function getGitHubPullRequestChecks(args: {
const timedOut = failures.some(
(failure) => failure.reason instanceof GitHubRequestError && failure.reason.timedOut,
);
const permissionDenied = failures.some(
(failure) =>
failure.reason instanceof GitHubRequestError &&
!failure.reason.rateLimited &&
[401, 403].includes(failure.reason.status),
);
const retryAt =
failures
.map((failure) =>
Expand Down Expand Up @@ -363,6 +370,7 @@ export async function getGitHubPullRequestChecks(args: {
partial,
rateLimited,
timedOut,
permissionDenied,
diagnostic:
partial || nullConclusion || statusUnknown
? [
Expand Down
6 changes: 5 additions & 1 deletion src/server/services/github/reconciliation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,11 @@ export async function sweepGitHubStatusReconciliation(
if (checks.rateLimited === true) {
result.rateLimited += 1;
}
if (checks.partial === true) {
const mappingWidePartial =
checks.rateLimited === true ||
checks.timedOut === true ||
checks.permissionDenied === true;
if (mappingWidePartial) {
// Permission, timeout, or rate-limit failures generally affect the
// installation/mapping, not one PR. Open a persisted circuit for
// every resource on that mapping before ending this sweep.
Expand Down
Loading
Loading