Files
PaperClipAI/server/src/services/chat-github-checks.ts
T
Devin FoleyandPaperclip 35bd7f6059 Reduce encrypted event retries and GitHub run-query transfer (#17251)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Its Cloud connector delivers encrypted GitHub events to an instance.
> - One failed inner event currently rejects the whole authenticated
batch.
> - Healthy events then remain unacknowledged and are downloaded again.
> - GitHub response and check reconciliation also reads full run records
when it needs only ownership and status.
> - This pull request isolates inner failures and narrows those run
projections.
> - Valid events can finish while failed events remain available for
recovery.

## Linked Issues or Issue Description

**What happened?**

An inner App event encrypted with an unavailable key causes
`leaseEvents` to reject the complete batch. No sibling can finish or
acknowledge. Repeated retries download the same encrypted payloads.
Separately, GitHub response and check reads load prompts, runtime
profiles, results, and log excerpts that their callers do not use.

**Expected behavior**

Authenticate the outer batch and lease before handling individual
events. Process valid siblings independently. Retain failed events
without acknowledging them. Read only the current run fields needed for
response ownership and status checks.

**Steps to reproduce**

1. Seal one event for an old instance key and another for the current
key.
2. Put both events in a correctly authenticated outer lease.
3. Observe that the old client rejects the whole lease.
4. With this change, the valid event processes, the failed ID is
reported locally, and only completed events acknowledge.

No public issue exists. The change does not add a provider integration
or duplicate a roadmap feature.

## What Changed

- Keep outer authentication, environment checks, and lease binding
strict.
- Isolate inner decryption and route-binding failures. Return their
authenticated IDs only to the local poller and count them as failures.
- Leave failed events unacknowledged in the broker. Continue processing
and acknowledging healthy and duplicate siblings.
- Read only run identity, status, and required request context for
GitHub responses. Read only run ID and status for check reconciliation.
- Cover stale keys, swapped routes, invalid outer bindings, all-failed
batches, large unused run payloads, and cross-company/task refusals.

## Verification

- Connector cryptography suite: 47 tests passed.
- Final connector suite: 50 tests passed, including cryptography and
three real PostgreSQL connection-event tests. These cover acknowledgment
isolation, all-failed batches, and the exact safe warning fields.
- GitHub workflow regression suite after the master refresh: 307 tests
passed, including the large-payload/company/task test (1,084 unrelated
tests skipped by the selection).
- Server typecheck passed after the final source changes.
- `pnpm -r typecheck` and `pnpm build` passed after the latest
conflict-free master refresh.
- The broad local `pnpm test:run` was stopped when the branch was
refreshed; it has no complete passing result. All CI gates passed on the
final commit, including general and serialized server suites, runner
verification, build, typecheck, and end-to-end tests. Apex is 5/5 with
all review comments resolved. The last change removes source comments
and expands docs; both edited TypeScript files emit identical JavaScript
before and after that change.
- A read-only sample compared full run records with the new field sets:
response data decreased by about 96%, and check data by over 99%. This
is serialized query output, not a claim of realized billing savings or
CPU reduction.
- An initial database test setup failed because the local dependency
install omitted native-library symlinks. The package's standard setup
script repaired them. The unchanged test timeout then passed.
- Added lines checked for credentials, customer data, private links, and
internal issue references.

## Risks

Failed inner events remain pending and retry under the broker's existing
lease and retention policy. This change does not recover a lost original
private key or introduce a dead-letter deletion path. Outer
authentication failures still reject the complete batch.

Run projections must retain request provenance and task binding fields.
Existing predicates, ordering, identity validation, and publication
authority remain unchanged. The change has no schema migration and adds
no background timer or new provider call.

## Model Used

OpenAI GPT-6 through Codex, with reasoning and shell/GitHub CLI tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used with version and capability
details
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs
- [x] I have described the issue in-PR following the bug report template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
ticket ID
- [x] Focused local regressions and all CI gates pass
- [x] I have added and updated tests
- [x] I have updated relevant documentation
- [x] I have documented risks
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-22 17:40:29 -07:00

470 lines
17 KiB
TypeScript

import {
GitHubPublicationLeaseLost,
withGitHubPublicationLease,
} from "./chat-github-publication-lease.js";
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import {
chatActions,
chatDeliveries,
chatEndpoints,
chatEndpointResources,
chatGitHubConfigurations,
chatGitHubReviews,
chatMessageLinks,
companies,
heartbeatRuns,
type Db,
} from "@paperclipai/db";
import type { GitHubReviewEventContext } from "@paperclipai/shared";
import { runtimePublicOrigin } from "./cloud-runtime-identity.js";
import {
githubBotRepositoryToken,
githubBotRequest,
} from "./chat-github-client.js";
/** Check status follows the ordinary chat delivery/task/run. This outbox never
* schedules an agent. Assessments are published by the governed review tool. */
export function githubReviewCheckService(db: Db, fetchImpl = fetch) {
async function enqueue(
endpoint: typeof chatEndpoints.$inferSelect,
event: GitHubReviewEventContext,
admitted: boolean,
reason: string,
) {
const [config] = await db
.select()
.from(chatGitHubConfigurations)
.where(eq(chatGitHubConfigurations.endpointId, endpoint.id));
if (!config?.configuration.toolsEnabled) return;
const policy = {
...config.configuration.defaults,
...config.configuration.repositories[event.repositoryId],
};
if (policy.ratingThreshold === null && !admitted) return;
await db
.insert(chatActions)
.values({
companyId: endpoint.companyId,
endpointId: endpoint.id,
kind: "github_review_check",
providerActionId: `github-check:${event.deliveryId}`,
payload: {
version: 1,
event,
admitted,
reason,
runtimeGeneration:
(endpoint.setup as { runtimeGeneration?: number })
.runtimeGeneration ?? 0,
},
status: "received",
})
.onConflictDoNothing();
}
async function dispatch(id: string) {
const [action] = await db
.select()
.from(chatActions)
.where(
and(
eq(chatActions.id, id),
eq(chatActions.kind, "github_review_check"),
),
)
.limit(1);
if (!action || ["processed", "cancelled"].includes(action.status)) return;
const event = action.payload.event as GitHubReviewEventContext;
await withGitHubPublicationLease(
db,
{
companyId: action.companyId,
endpointId: action.endpointId,
repositoryId: event.repositoryId,
number: event.pullNumber,
},
fetchImpl,
async (lease) => {
const [fresh] = await db
.select()
.from(chatActions)
.where(eq(chatActions.id, id));
if (!fresh || ["processed", "cancelled"].includes(fresh.status)) return;
let attempts = Number(fresh.result?.attempts ?? 0);
try {
const [source] = await db
.select({
endpoint: chatEndpoints,
resource: chatEndpointResources,
configuration: chatGitHubConfigurations.configuration,
companyPrefix: companies.issuePrefix,
})
.from(chatEndpoints)
.innerJoin(companies, eq(companies.id, chatEndpoints.companyId))
.innerJoin(
chatGitHubConfigurations,
eq(chatGitHubConfigurations.endpointId, chatEndpoints.id),
)
.innerJoin(
chatEndpointResources,
eq(chatEndpointResources.endpointId, chatEndpoints.id),
)
.where(
and(
eq(chatEndpoints.id, action.endpointId),
eq(chatEndpoints.companyId, action.companyId),
eq(chatEndpointResources.providerResourceId, event.repository),
),
);
if (
!source ||
!source.configuration.toolsEnabled ||
!["active", "verifying"].includes(source.endpoint.status) ||
!source.resource.enabled ||
source.resource.availability !== "available" ||
String(source.resource.metadata?.providerRepositoryId) !==
event.repositoryId ||
((source.endpoint.setup as { runtimeGeneration?: number })
.runtimeGeneration ?? 0) !== action.payload.runtimeGeneration
) {
await lease.commit(async (tx) => {
await tx
.update(chatActions)
.set({
status: "cancelled",
result: { code: "connection_changed" },
updatedAt: new Date(),
})
.where(eq(chatActions.id, id));
});
return;
}
const [review] = await db
.select()
.from(chatGitHubReviews)
.where(
and(
eq(chatGitHubReviews.endpointId, action.endpointId),
eq(chatGitHubReviews.repositoryId, event.repositoryId),
eq(chatGitHubReviews.pullNumber, event.pullNumber),
eq(chatGitHubReviews.headSha, event.headSha),
),
)
.orderBy(desc(chatGitHubReviews.createdAt))
.limit(1);
if (review?.assessment) {
// The assessment outbox owns the terminal score and its retries.
await lease.commit(async (tx) => {
await tx
.update(chatActions)
.set({
status: "processed",
result: {
code: "assessment_owns_check",
reviewId: review.id,
},
updatedAt: new Date(),
})
.where(eq(chatActions.id, id));
});
return;
}
let run = review?.runId
? (
await db
.select({ id: heartbeatRuns.id, status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, review.runId))
)[0]
: undefined;
if (review && !run) {
const [sourceRun] = await db
.select({ run: { id: heartbeatRuns.id, status: heartbeatRuns.status } })
.from(chatDeliveries)
.innerJoin(
chatMessageLinks,
eq(chatMessageLinks.deliveryId, chatDeliveries.id),
)
.innerJoin(
heartbeatRuns,
and(
eq(heartbeatRuns.companyId, action.companyId),
eq(heartbeatRuns.agentId, source.endpoint.assignedAgentId),
sql`${heartbeatRuns.contextSnapshot}->>'wakeCommentId' = ${chatMessageLinks.commentId}::text`,
),
)
.where(
and(
eq(chatDeliveries.endpointId, action.endpointId),
sql`${chatDeliveries.normalizedEvent}->'githubAutomatic'->'context'->>'deliveryId' = ${event.deliveryId}`,
),
)
.orderBy(desc(heartbeatRuns.createdAt))
.limit(1);
run = sourceRun?.run;
}
const [delivery] = await db
.select({ state: chatDeliveries.state })
.from(chatDeliveries)
.where(
and(
eq(chatDeliveries.endpointId, action.endpointId),
sql`${chatDeliveries.normalizedEvent}->'githubAutomatic'->'context'->>'deliveryId' = ${event.deliveryId}`,
),
)
.limit(1);
const admissionEnded =
!review &&
(delivery?.state === "filtered" || delivery?.state === "failed");
const terminal =
!!run &&
[
"succeeded",
"failed",
"cancelled",
"timed_out",
"interrupted",
].includes(run.status);
const state =
(!action.payload.admitted && !review) || admissionEnded
? "manual_required"
: terminal
? run!.status === "succeeded"
? "incomplete"
: "error"
: run?.status === "running"
? "running"
: "queued";
const status =
state === "running"
? "in_progress"
: state === "queued"
? "queued"
: "completed";
const policy = review?.policySnapshot ?? {
...source.configuration.defaults,
...source.configuration.repositories[event.repositoryId],
};
const conclusion = policy.ratingThreshold === null ? "neutral" : "action_required";
const title =
state === "manual_required"
? "Authorized manual review required"
: state === "incomplete"
? "Agent finished without a complete assessment"
: state === "error"
? "Review execution did not complete"
: state === "running"
? "Agent is reviewing this commit"
: "Waiting for the assigned agent";
const token = await githubBotRepositoryToken(
db,
action.companyId,
action.endpointId,
event.repositoryId,
lease.fetch,
);
const prefix = `/repos/${event.repository.split("/").map(encodeURIComponent).join("/")}`;
const request = <T>(
path: string,
options?: Parameters<typeof githubBotRequest>[3],
) =>
githubBotRequest<T>(
lease.fetch,
token,
`${prefix}${path}`,
options,
);
const current = await request<{ head: { sha: string } }>(
`/pulls/${event.pullNumber}`,
);
if (current.head.sha !== event.headSha) {
if (review)
await lease.commit(async (tx) => {
await tx
.update(chatGitHubReviews)
.set({ state: "superseded", updatedAt: new Date() })
.where(
and(
eq(chatGitHubReviews.id, review.id),
isNull(chatGitHubReviews.assessment),
),
);
});
await lease.commit(async (tx) => {
await tx
.update(chatActions)
.set({
status: "cancelled",
result: { code: "stale_head" },
updatedAt: new Date(),
})
.where(eq(chatActions.id, id));
});
return;
}
if (fresh.result?.state !== state) {
const origin = runtimePublicOrigin();
const detailsPath = review
? `issues/${review.issueId}`
: `apps/chat/${source.endpoint.id}/reviews`;
const detailsUrl = origin
? `${origin}/${encodeURIComponent(source.companyPrefix)}/${detailsPath}`
: null;
const externalId = `${action.endpointId}:${event.pullNumber}:${event.headSha}`;
const checks = await request<{
check_runs: Array<{
id: number;
status?: string;
external_id?: string;
app?: { id?: number };
}>;
}>(
`/commits/${event.headSha}/check-runs?check_name=Paperclip%20Review&per_page=100`,
);
const check = checks.check_runs.find(
(check) =>
check.external_id === externalId &&
String(check.app?.id) === source.endpoint.botExternalId,
);
// GitHub retains a completed check's conclusion when PATCHed back to
// queued/in_progress. A fresh attempt needs a new check run with the
// same stable name; otherwise a prior success still looks passing.
const updateCheck =
check &&
!(check.status === "completed" && status !== "completed");
const posted = await request<{ id: number; html_url: string }>(
updateCheck ? `/check-runs/${check.id}` : "/check-runs",
{
method: updateCheck ? "PATCH" : "POST",
body: {
name: "Paperclip Review",
head_sha: event.headSha,
external_id: externalId,
...(detailsUrl ? { details_url: detailsUrl } : {}),
status,
...(status === "completed"
? { conclusion }
: {}),
output: {
title,
summary: `${title}. ${state === "manual_required" ? "An authorized person can mention the bot to request a review of this head. Automatic review was not authorized by the bot configuration." : "This check follows the assigned Paperclip agent's task execution. Only a validated assessment can produce a passing score."}\n\nCommit: ${event.headSha}`,
},
},
},
);
if (review)
await lease.commit(async (tx) => {
await tx
.update(chatGitHubReviews)
.set({
state,
runId: run?.id ?? review.runId,
checkId: String(posted.id),
checkUrl: posted.html_url,
...(terminal
? { conclusion }
: {}),
updatedAt: new Date(),
})
.where(
and(
eq(chatGitHubReviews.id, review.id),
isNull(chatGitHubReviews.assessment),
),
);
});
}
await lease.commit(async (tx) => {
await tx
.update(chatActions)
.set({
status: status === "completed" ? "processed" : "received",
result: {
state,
attempts: 0,
retryable: true,
retryAt: new Date(Date.now() + 5000).toISOString(),
},
updatedAt: new Date(),
})
.where(eq(chatActions.id, id));
});
} catch (error) {
if (error instanceof GitHubPublicationLeaseLost) return;
attempts++;
await lease.commit(async (tx) => {
await tx
.update(chatActions)
.set({
status: "failed",
result: {
attempts,
retryable: attempts < 8,
retryAt: new Date(
Date.now() + Math.min(300000, 1000 * 2 ** attempts),
).toISOString(),
code: "check_publication_failed",
},
updatedAt: new Date(),
})
.where(eq(chatActions.id, id));
});
}
},
);
}
async function processPending(limit = 10) {
// Check delivery and task admission run independently. A rapid push can
// cancel an obsolete check before admission inserts its review projection.
// Reconcile that late row from the durable cancellation receipt, without
// starting work or borrowing another delivery's authority.
await db
.update(chatGitHubReviews)
.set({
state: "superseded",
updatedAt: new Date(),
})
.where(
and(
inArray(chatGitHubReviews.state, ["queued", "running"]),
isNull(chatGitHubReviews.assessment),
sql`exists (
select 1 from ${chatActions} action
join ${chatDeliveries} delivery
on delivery.id::text = ${chatGitHubReviews.deliveryId}
where action.company_id = ${chatGitHubReviews.companyId}
and action.endpoint_id = ${chatGitHubReviews.endpointId}
and delivery.endpoint_id = action.endpoint_id
and action.kind = 'github_review_check'
and action.status = 'cancelled'
and action.result->>'code' = 'stale_head'
and action.payload->'event'->>'deliveryId' =
delivery.normalized_event->'githubAutomatic'->'context'->>'deliveryId'
)`,
),
);
const rows = await db
.select({ id: chatActions.id })
.from(chatActions)
.where(
and(
eq(chatActions.kind, "github_review_check"),
or(
and(
eq(chatActions.status, "received"),
sql`(${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= now())`,
),
and(
eq(chatActions.status, "failed"),
sql`${chatActions.result}->>'retryable' = 'true'`,
sql`(${chatActions.result}->>'retryAt')::timestamptz <= now()`,
),
),
),
)
.orderBy(chatActions.updatedAt)
.limit(limit);
for (const row of rows) await dispatch(row.id);
}
return { enqueue, processPending };
}