Files
PaperClipAI/server/src/services/chat-github-response-comments.ts
T
DottaandPaperclip f8226b0515 fix(server): wake GitHub terminal responses from committed changes (#17661)
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/210/changes/paperclip/210
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip coordinates agent work and presents results in chat.
> - GitHub acknowledgements already persist the response action and
provider receipt.
> - A terminal run can still owe a final response after acknowledgement.
> - Periodic maintenance searches those rows even when no relevant state
changed.
> - Committed run, issue, presentation, lease and publication changes
identify when recovery can advance.
> - The owner should recover from those events and real deadlines, then
become quiet.

## Linked Issues or Issue Description

Refs #17656, which is merged. This existing terminal-response slice
targets master.

**Problem:** A saved GitHub response can remain nonfinal while its run,
native presentation, publication or source is unresolved. Removing
maintenance without covering those producers loses recovery. Polling
settled history keeps the tenant active.

**Expected behavior:** Committed supported changes wake the terminal
owner. It reconstructs existing actions, preserves provider authority
and pending publication ownership, waits at saved deadlines where
needed, and becomes quiet after settlement.

## What Changed

- Register terminal GitHub response work with the shared scoped deadline
coordinator, using existing action/delivery/publication/run rows.
- Notify response availability from supported run milestones, native
identity, recovery resolution, issue updates and tree/workspace
restoration writes. Preserve outer transaction commit/rollback.
- Keep GitHub response interpretation in its platform owner. Use the
bounded run projection for both direct authorization and scheduling,
including native presentation guards and deliberate passive outcomes.
- Retain the existing publication lease, markers, source/principal
checks and final receipt precedence. A publication owner or uncertain
outcome keeps its existing authority; the worker does not replay it.
- Remove only recurring terminal-response recovery, preserving the
manual path and neighbouring duties. Add no table or scheduler service.
- Regenerate the original sparse terminal-work index as
`0401_yummy_ultimates.sql`, concurrently and idempotently through the
existing top-level migration runner. Preserve all prior SQL/journal
entries and the five newest snapshots.

## Verification

Published head: `88a209e28b969b5229e9a621aeddfd3cbae9b0cd`, integrated
with landed #17656 (`ba5bba86dcdde32b14d4d7dec1b51898e5f6f22d`) and
master `52b86c2032a189b0ea259c5b0cb7158b07ef788c`.

- **74 distinct current cases pass:** 17 terminal-response
lifecycle/producer cases (1,721 other chat cases skipped), all six
queued-comment consumption regressions for the new upstream query
optimization, all 30 heartbeat completion cases, all eight real/unit
issue-tree control cases and all 13 startup cases. Database build,
server typecheck/build, module boundaries and four fresh migrations
through 0401 pass.
- The earlier **304-case** prepared-parent matrix passes at
`aa72f32641b2e2c680342cd49477272349aca9db`: all 253 GitHub workflow
cases, 30 completion cases, eight issue-tree cases and 13 startup cases.
It retains that revision scope. Repeated cases are not added to the
current 74.
- The terminal matrix covers finalized-history quiet, deleted Board
sources, pending presentation/passive outcomes, outer rollback/commit,
paused/new runs, restart/drain/standby, committed native identity, lease
waits, full-page unrelated history, uncertain publication ownership and
tree/workspace/exhausted-recovery producers.
- A populated 0400 database upgrades to 0401 with all 399 prior
migration receipts identical, exactly one added, and none added on
replay. The terminal index is valid/ready on fresh and upgraded
databases. Its SQL matches the original draft after whitespace
normalization; generation keeps snapshots 0397–0401.
- Normal integration `3c8339a541bf68bc9957b9d5a8df80d3a7c3452d`
preserves the complete 24-path owned binary patch and all five new
master paths. The landed parent tree also equals the previously
published parent tree byte for byte. Both normal merge parents and the
exact composition tree are checked. No force push or rebase occurs.
- [The owner
audit](https://github.com/paperclipai/paperclip/blob/88a209e28b969b5229e9a621aeddfd3cbae9b0cd/doc/plans/2026-10-25-github-terminal-response-sleep-audit.md#acceptance-on-the-landed-github-parent-and-current-master)
preserves the original producer, quietness, failure-isolation,
skipped-suite and presentation-wait results with their scopes. Current
passes do not erase earlier failures or turn skips into passes.
- The audit-only follow-up leaves all owned source/test/migration hashes
unchanged. **Fresh exact-head CI and Greptile 5/5 with no actionable
findings remain required.** No full local repository suite, live GitHub
tenant or deployed sleep result is claimed.

## Risks

- Process-local notifications cover supported application writers and
shared pools. Independent SQL writers and other replicas gain no new
wake guarantee.
- Native terminal status alone does not authorize fallback publication.
Pending presentation and existing publication ownership remain separate
obligations.
- Stop can leave an in-flight provider request settling; admission
fences stop a subsequent request. Unknown outcomes retain their existing
recovery rules.
- Index creation can take time on large histories. Concurrent/idempotent
migration and existing replay recovery remain in charge.
- Worker quiet does not establish host/database suspension. Cloud alarm
coverage and sleep admission remain unchanged.

## Model Used

OpenAI Codex, using reasoning, repository inspection, code execution and
real PostgreSQL tests. The exact model identifier and context-window
size are not exposed in this session.

## 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 and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] 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-28 16:51:57 -05:00

456 lines
33 KiB
TypeScript

import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm";
import {
chatActions, chatConversations, chatDeliveries, chatEndpoints,
chatEndpointResources, chatGitHubConfigurations, chatMessageLinks, heartbeatRuns, issues,
fastResponseRequests, chatPublications,
type Db,
} from "@paperclipai/db";
import { registerGitHubTerminalResponseWork } from "./chat-channels/github/terminal-response-work.js";
import { githubResponseExecution, githubResponseRunColumns as responseRunColumns } from "./chat-channels/github/response-execution.js";
import { notifyChatResponseAvailability } from "./chat-response-availability.js";
import { registerGitHubAcknowledgementWork } from "./chat-channels/github/acknowledgement-work.js";
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { conflict, forbidden, HttpError } from "../errors.js";
import { githubChatPrincipalAccess } from "./chat-github-access.js";
import { githubAutomaticAdmission } from "./chat-github-events.js";
import { githubBotRepositoryToken, githubBotRequest } from "./chat-github-client.js";
import { withGitHubPublicationLease } from "./chat-github-publication-lease.js";
import { projectSafeChatPublicationText } from "./chat-publication-projection.js";
import type { GitHubAutomaticPolicyContext } from "./chat-github-review-policy.js";
import { githubResponseAuthorityFields } from "./chat-channels/github/response-authority-read.js";
import { githubRunReplyState } from "./chat-run-publications.js";
import { logActivity } from "./activity-log.js";
import { boundRunIssueId } from "./run-task-binding.js";
import { chatRunSourceMessageId } from "./chat-channels/run-origin.js";
import { isMainPullResponse, legacyPullResponseMarkers, pullResponseMarker, requestResponseMarker,
retireLegacyResponsePlaceholder, stagePullResponse, type GitHubResponseSource } from "./chat-channels/github/response-target.js";
type Transaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
type Api = { request<T>(path: string, options?: { method?: string; body?: unknown }): Promise<T> };
type Lease = { commit<T>(write: (tx: Transaction) => Promise<T>): Promise<T> };
type Source = GitHubResponseSource;
type Comment = { id: number; body?: string; html_url: string; user?: { login?: string } };
const generation = (endpoint: Source["endpoint"]) => (endpoint.setup as { runtimeGeneration?: number }).runtimeGeneration ?? 0;
const stoppedAcknowledgement = Symbol("stopped GitHub acknowledgement");
const actionKey = (deliveryId: string) => `github_response:${deliveryId}`;
const responseMarker = (source: Source) => isMainPullResponse(source) ? pullResponseMarker(source) : requestResponseMarker(source);
async function findResponseComment(source: Source, api: Api, legacyMarkers: string[] = []): Promise<Comment | null> {
const list = source.replyId ? `/pulls/${source.number}/comments` : `/issues/${source.number}/comments`;
let legacy: Comment | null = null;
for (let page = 1; page <= 100; page++) {
const rows = await api.request<Comment[]>(`${list}?per_page=100&page=${page}`);
const found = rows.find(row => row.body?.includes(responseMarker(source)) && row.user?.login === source.endpoint.botUsername);
if (found) return found;
legacy ??= rows.find(row => legacyMarkers.some(marker => row.body?.includes(marker)) && row.user?.login === source.endpoint.botUsername) ?? null;
if (rows.length < 100) return legacy;
}
throw conflict("GitHub history is too large to safely recover the response");
}
/** One durable, App-owned comment per accepted request. Callers hold the same
* repository publication lease used by agent replies and review summaries.
* Reconnect epochs fence publication attempts, not the durable comment identity.
* Legacy receipts are also checked against the App-owned provider comment. */
async function stageResponse(db: Db, source: Source) {
const lookup = () => db.select().from(chatActions).where(and(
eq(chatActions.companyId, source.endpoint.companyId), eq(chatActions.endpointId, source.endpoint.id),
eq(chatActions.providerActionId, actionKey(source.delivery.id)),
)).then(rows => rows[0]);
const existing = await lookup();
const response = existing ?? await db.transaction(async tx => {
await notifyChatResponseAvailability(tx, { companyId: source.endpoint.companyId, deliveryId: source.delivery.id });
const [inserted] = await tx.insert(chatActions).values({
companyId: source.endpoint.companyId, endpointId: source.endpoint.id,
conversationId: source.conversation.id, deliveryId: source.delivery.id,
principalId: source.delivery.principalId, kind: "github_response_comment",
providerActionId: actionKey(source.delivery.id),
payload: { botExternalId: source.endpoint.botExternalId, agentId: source.endpoint.assignedAgentId },
status: "received",
}).onConflictDoNothing().returning();
return inserted;
}) ?? await lookup();
if (!response || response.conversationId !== source.conversation.id ||
(response.payload.botExternalId !== undefined && response.payload.botExternalId !== source.endpoint.botExternalId) ||
response.payload.agentId !== source.endpoint.assignedAgentId)
throw forbidden("GitHub response binding changed");
return response;
}
export async function writeGitHubResponseComment(
db: Db, source: Source, api: Api, lease: Lease,
input: { body: string; final: boolean; versionAt?: Date; acknowledgement?: boolean; preserveFinal?: boolean; pausedVersion?: string },
assertCurrent: () => Promise<void>,
) {
await assertCurrent();
const response = await stageResponse(db, source);
const shared = isMainPullResponse(source);
const target = shared ? await stagePullResponse(db, source) : response;
const versionAt = input.versionAt ?? source.delivery.createdAt;
if (target.result?.id && Date.parse(String(target.result.versionAt)) > versionAt.getTime()) {
if (!input.acknowledgement) throw conflict("A newer GitHub response has already been published");
if (shared) await retireLegacyResponsePlaceholder(source, response.result,
{ id: String(target.result.id), url: String(target.result.url) }, api, assertCurrent);
await lease.commit(async tx => { await tx.update(chatActions).set({ status: "processed", result: {
...response.result, id: target.result!.id, url: target.result!.url,
final: response.result?.final === true, code: "acknowledgement_superseded", versionAt: versionAt.toISOString(),
}, updatedAt: new Date() }).where(eq(chatActions.id, response.id)); });
return { id: String(target.result.id), url: String(target.result.url) };
}
// A delayed acknowledgement or progress retry must never replace a result.
if (response.result?.id && (!shared || response.result.id === target.result?.id) && (input.acknowledgement || (response.result.final === true && !input.final) ||
(input.versionAt && Date.parse(String(response.result.versionAt)) > input.versionAt.getTime()))) {
if (!input.acknowledgement && response.result.final === true && !input.final)
throw conflict("This GitHub response is already final");
if (["received", "failed"].includes(response.status) || (response.status === "processed" &&
input.pausedVersion && response.result.pausedVersion !== input.pausedVersion)) await lease.commit(async tx => {
await tx.update(chatActions).set({ status: "processed",
result: { ...response.result, ...(input.pausedVersion ? { pausedVersion: input.pausedVersion } : {}) }, updatedAt: new Date() })
.where(and(eq(chatActions.id, response.id), eq(chatActions.status, response.status)));
});
return { id: String(response.result.id), url: String(response.result.url) };
}
const marker = responseMarker(source);
const list = source.replyId ? `/pulls/${source.number}/comments` : `/issues/${source.number}/comments`;
const item = source.replyId ? "/pulls/comments" : "/issues/comments";
await assertCurrent();
let previous: Comment | null = null;
if (target.result?.id) {
// Never recreate a deleted comment or edit an arbitrary caller-selected ID.
if (!/^[1-9][0-9]*$/.test(String(target.result.id))) throw forbidden("Invalid GitHub response receipt");
previous = await api.request<Comment>(`${item}/${target.result.id}`);
if (!previous.body?.includes(marker) || previous.user?.login !== source.endpoint.botUsername)
throw forbidden("GitHub response is no longer owned by this App");
} else {
previous = await findResponseComment(source, api, shared ? await legacyPullResponseMarkers(db, source) : []);
}
await assertCurrent();
const providerVersion = previous?.body?.match(/<!-- paperclip-response-version:([^ ]+) -->/)?.[1];
if (providerVersion && Date.parse(providerVersion) > versionAt.getTime()) throw conflict("A newer GitHub response has already been published");
const sameRequest = !shared || previous?.body?.includes(requestResponseMarker(source)) === true;
const alreadyFinal = sameRequest && previous?.body?.includes("<!-- paperclip-response-state:final -->") === true;
const body = `${projectSafeChatPublicationText(input.body)}\n\n${marker}${shared ? `\n${requestResponseMarker(source)}` : ""}\n<!-- paperclip-response-version:${versionAt.toISOString()} -->${input.final ? "\n<!-- paperclip-response-state:final -->" : ""}`;
const preserved = previous && (previous.body === body || (input.acknowledgement && (sameRequest || (shared && previous.body?.includes("<!-- paperclip-response-state:final -->")))) || (alreadyFinal && !input.final) ||
(input.preserveFinal && previous.body?.includes("<!-- paperclip-response-state:final -->"))) ? previous : null;
const posted = preserved && (!shared || preserved.body?.includes(marker)) ? preserved : await api.request<Comment>(
previous ? `${item}/${previous.id}` : source.replyId
? `/pulls/${source.number}/comments/${source.replyId}/replies` : list,
{ method: previous ? "PATCH" : "POST", body: { body: preserved ? `${preserved.body}\n${marker}` : body } },
);
if (!posted || !Number.isSafeInteger(posted.id) || posted.id <= 0 || !posted.html_url)
throw conflict("GitHub response delivery was not confirmed");
if (shared) await retireLegacyResponsePlaceholder(source, response.result,
{ id: String(posted.id), url: posted.html_url }, api, assertCurrent);
await assertCurrent();
const receipt = { id: String(posted.id), url: posted.html_url };
await lease.commit(async tx => {
if (shared) await tx.update(chatActions).set({ result: {
...receipt, final: input.final || alreadyFinal || Boolean(preserved?.body?.includes("<!-- paperclip-response-state:final -->")),
versionAt: versionAt.toISOString(), deliveryId: source.delivery.id,
}, updatedAt: new Date() }).where(eq(chatActions.id, target.id));
await tx.update(chatActions).set({ status: "processed", result: {
...receipt, final: input.final || alreadyFinal, versionAt: versionAt.toISOString(),
...(input.pausedVersion ? { pausedVersion: input.pausedVersion } : {}),
}, updatedAt: new Date() }).where(eq(chatActions.id, response.id));
await logActivity(tx as unknown as Db, { companyId: source.endpoint.companyId,
actorType: "system", actorId: "github-response", action: "chat_github.response_updated",
entityType: "chat_endpoint", entityId: source.endpoint.id,
details: { deliveryId: source.delivery.id, final: input.final, ...receipt } });
});
return receipt;
}
export function githubResponseCommentService(db: Db, fetchImpl = fetch) {
async function context(deliveryId: string) {
const [row] = await db.select({ ...githubResponseAuthorityFields,
assignee: issues.assigneeAgentId, issueStatus: issues.status, sourceMessageId: chatMessageLinks.commentId }).from(chatMessageLinks)
.innerJoin(chatDeliveries, and(eq(chatDeliveries.id, chatMessageLinks.deliveryId), eq(chatDeliveries.companyId, chatMessageLinks.companyId)))
.innerJoin(chatConversations, and(eq(chatConversations.id, chatMessageLinks.conversationId), eq(chatConversations.companyId, chatMessageLinks.companyId)))
.innerJoin(chatEndpoints, and(eq(chatEndpoints.id, chatConversations.endpointId), eq(chatEndpoints.companyId, chatConversations.companyId)))
.innerJoin(chatEndpointResources, eq(chatEndpointResources.id, chatConversations.resourceId))
.innerJoin(chatGitHubConfigurations, eq(chatGitHubConfigurations.endpointId, chatEndpoints.id))
.innerJoin(issues, and(eq(issues.id, chatConversations.issueId), eq(issues.companyId, chatEndpoints.companyId)))
.where(and(eq(chatMessageLinks.deliveryId, deliveryId), eq(chatMessageLinks.direction, "inbound"), eq(chatEndpoints.provider, "github")));
if (!row || !row.delivery.principalId || !["processing", "processed"].includes(row.delivery.state) ||
!["active", "verifying"].includes(row.endpoint.status) || !row.toolsEnabled ||
!row.resource.enabled || row.resource.availability !== "available" || row.assignee !== row.endpoint.assignedAgentId)
throw forbidden("GitHub request is no longer authorized");
const access = await githubChatPrincipalAccess(db, row.endpoint, row.delivery.principalId);
if (!access?.allowed) throw forbidden("The initiating GitHub person is no longer authorized");
const automatic = (row.delivery.normalizedEvent.githubAutomatic ?? row.delivery.normalizedEvent.githubIssue) as
{ context: GitHubAutomaticPolicyContext } | undefined;
if (automatic && !(await githubAutomaticAdmission(db, row.endpoint, automatic.context))?.allowed)
throw forbidden("Automatic GitHub task authority changed");
const match = /^github:([^:]+):(?:issue:)?([1-9][0-9]*)(?::rc:([1-9][0-9]*))?$/.exec(row.conversation.externalThreadId);
const repositoryId = String(row.resource.metadata.providerRepositoryId ?? "");
if (!match || match[1]!.toLowerCase() !== row.resource.providerResourceId.toLowerCase() || !/^[1-9][0-9]*$/.test(repositoryId))
throw forbidden("Invalid GitHub request repository");
return { ...row, repositoryId, repository: match[1]!, number: Number(match[2]), replyId: match[3] ?? null };
}
async function acknowledge(deliveryId: string, finalBody?: string, assertRun?: () => Promise<void>, canContinue = () => true) {
if (!canContinue()) return;
const source = await context(deliveryId);
// Persist before competing for the publication lane. A busy lane leaves
// durable acknowledgement work for maintenance instead of losing it.
await stageResponse(db, source);
if (!canContinue()) return;
return withGitHubPublicationLease(db, { companyId: source.endpoint.companyId, endpointId: source.endpoint.id,
repositoryId: source.repositoryId, number: source.number }, (input, init) => {
if (!canContinue()) throw stoppedAcknowledgement;
return fetchImpl(input, init);
}, async lease => {
const assertCurrent = async () => {
await assertRun?.();
const current = await context(deliveryId);
if (generation(current.endpoint) !== generation(source.endpoint) || current.endpoint.assignedAgentId !== source.endpoint.assignedAgentId)
throw forbidden("GitHub response binding changed");
};
await assertCurrent();
let onlyExisting = false;
let finalVersionAt: Date | undefined;
let requestRunning = false;
let requestPaused = false;
let preserveFinal = false;
let pausedVersion: string | undefined;
if (finalBody === undefined) {
const existing = await stageResponse(db, source);
const execution = await githubResponseExecution(db, {
companyId: source.endpoint.companyId, endpointId: source.endpoint.id,
agentId: source.endpoint.assignedAgentId!, issueId: source.conversation.issueId!, deliveryId,
sourceMessageId: source.sourceMessageId, issueStatus: source.issueStatus,
deliveryCreatedAt: source.delivery.createdAt,
});
if (execution.reply === "unsettled") throw conflict("GitHub reply is still being resolved");
if (execution.presentationPending) return;
if (isMainPullResponse(source) && execution.passiveResponseWait) {
// Preserve the shared assessment when this exact run intentionally waits
// without publishing. Retire only its request acknowledgement.
await lease.commit(async tx => {
await notifyChatResponseAvailability(tx, { companyId: source.endpoint.companyId, deliveryId });
await tx.update(chatActions).set({ status: "cancelled",
result: { ...existing.result, final: true, code: "passive_response_wait" }, updatedAt: new Date(),
}).where(and(eq(chatActions.id, existing.id), eq(chatActions.companyId, source.endpoint.companyId),
sql`coalesce(${chatActions.result}->>'final', 'false') <> 'true'`));
});
return;
}
if (existing.result?.final === true) return existing.result.id ? { id: String(existing.result.id), url: String(existing.result.url) } : undefined;
if (execution.paused && existing.status === "processed" && existing.result?.id && existing.result?.pausedVersion === execution.version)
return existing.result.id ? { id: String(existing.result.id), url: String(existing.result.url) } : undefined;
pausedVersion = execution.paused ? execution.version : undefined;
requestRunning = execution.running;
requestPaused = execution.paused;
preserveFinal = execution.reply === "confirmed";
onlyExisting = preserveFinal && !existing.result?.id;
finalBody = execution.body;
finalVersionAt = execution.versionAt;
if (finalBody === undefined && existing.result?.id && (!isMainPullResponse(source) ||
(await stagePullResponse(db, source)).result?.id === existing.result.id)) {
await lease.commit(async tx => { await tx.update(chatActions).set({
status: ["received", "failed"].includes(existing.status) ? "processed" : existing.status,
updatedAt: new Date(),
}).where(and(eq(chatActions.id, existing.id), eq(chatActions.status, existing.status))); });
return { id: String(existing.result.id), url: String(existing.result.url) };
}
if (finalBody === undefined) {
const [fast] = await db.select({ request: fastResponseRequests, publicationState: chatPublications.state })
.from(fastResponseRequests).leftJoin(chatPublications, and(
eq(chatPublications.companyId, fastResponseRequests.companyId),
eq(chatPublications.commentId, fastResponseRequests.commentId),
eq(chatPublications.endpointId, fastResponseRequests.endpointId),
eq(chatPublications.conversationId, fastResponseRequests.conversationId),
)).where(and(eq(fastResponseRequests.companyId, source.endpoint.companyId),
eq(fastResponseRequests.endpointId, source.endpoint.id),
eq(fastResponseRequests.deliveryId, deliveryId))).limit(1);
if (fast && ["published", "delivery_unknown"].includes(fast.publicationState ?? "")) {
await lease.commit(async tx => { await tx.update(chatActions).set({
status: "processed", result: { code: "fast_response_superseded" }, updatedAt: new Date(),
}).where(eq(chatActions.id, existing.id)); });
return;
}
// Keep durable fallback work pending while the contextual receipt can
// still arrive. Never race an in-flight or ambiguous external send.
if (fast && (fast.publicationState === "streaming" ||
(fast.request.expiresAt.getTime() > Date.now() &&
(["pending", "running"].includes(fast.request.status) ||
["pending", "retry"].includes(fast.publicationState ?? ""))))) return;
}
}
const token = await githubBotRepositoryToken(db, source.endpoint.companyId, source.endpoint.id, source.repositoryId, lease.fetch);
const prefix = `/repos/${source.repository.split("/").map(encodeURIComponent).join("/")}`;
const api: Api = { request: (path, options) => githubBotRequest(lease.fetch, token, `${prefix}${path}`, options) };
if (onlyExisting && !(await findResponseComment(source, api))) {
const existing = await stageResponse(db, source);
await lease.commit(async tx => { await tx.update(chatActions).set({ status: "cancelled", result: { final: true, code: "run_already_answered" }, updatedAt: new Date() }).where(eq(chatActions.id, existing.id)); });
return;
}
return writeGitHubResponseComment(db, source, api, lease,
{ body: finalBody ?? (isMainPullResponse(source) && !requestRunning ? "Review queued…" : "Working on this…"), final: finalBody !== undefined && !requestPaused,
acknowledgement: finalBody === undefined, preserveFinal, pausedVersion,
...(finalBody !== undefined ? { versionAt: finalVersionAt ?? new Date() } : {}) }, assertCurrent);
});
}
async function requestsForRun(input: { companyId: string; endpointId: string; issueId: string; runId: string }) {
const [run] = await db.select(responseRunColumns).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
if (!run || boundRunIssueId(run) !== input.issueId) throw forbidden("GitHub run has no request binding");
const originMessageId = await chatRunSourceMessageId(db, run, { allowLegacyWakeComment: true, provider: "github" });
const ids = [...new Set([originMessageId, run.contextSnapshot?.wakeCommentId, ...(Array.isArray(run.contextSnapshot?.wakeCommentIds) ? run.contextSnapshot.wakeCommentIds : [])].filter((id): id is string => typeof id === "string"))];
if (!ids.length) throw forbidden("GitHub run has no request binding");
const links = await db.select({ deliveryId: chatMessageLinks.deliveryId }).from(chatMessageLinks)
.innerJoin(chatConversations, and(eq(chatConversations.id, chatMessageLinks.conversationId), eq(chatConversations.companyId, input.companyId),
eq(chatConversations.endpointId, input.endpointId), eq(chatConversations.issueId, input.issueId)))
.where(and(eq(chatMessageLinks.companyId, input.companyId), eq(chatMessageLinks.endpointId, input.endpointId),
inArray(chatMessageLinks.commentId, ids), eq(chatMessageLinks.direction, "inbound")))
.orderBy(asc(chatMessageLinks.createdAt));
return { run, links };
}
async function finishRun(input: { companyId: string; endpointId: string; issueId: string; runId: string; closePrimary?: boolean },
primary: Source, receipt: { id: string; url: string }, api: Api, lease: Lease, assertCurrent: () => Promise<void>) {
const { run, links } = await requestsForRun(input);
if (run.agentId !== primary.endpoint.assignedAgentId) throw forbidden("GitHub response authority changed");
for (const link of links) {
if (!link.deliveryId || (link.deliveryId === primary.delivery.id && !input.closePrimary)) continue;
const sibling = await context(link.deliveryId);
if (sibling.endpoint.id !== primary.endpoint.id || sibling.number !== primary.number || sibling.replyId !== primary.replyId || sibling.conversation.id !== primary.conversation.id)
throw forbidden("Coalesced GitHub response belongs to another conversation");
const [response] = await db.select().from(chatActions).where(and(eq(chatActions.deliveryId, link.deliveryId), eq(chatActions.kind, "github_response_comment")));
if (!response || response.result?.final === true) continue;
if (isMainPullResponse(primary) && !input.closePrimary) {
// Every coalesced request shares the published main comment. Settle
// receipts without replacing the assessment with a link to itself.
await retireLegacyResponsePlaceholder(sibling, response.result, receipt, api,
async () => { await assertCurrent(); await context(link.deliveryId!); });
await lease.commit(async tx => { await tx.update(chatActions).set({ status: "processed",
result: { ...response.result, ...receipt, final: true }, updatedAt: new Date(),
}).where(eq(chatActions.id, response.id)); });
continue;
}
// Never create a sibling placeholder just to close it. Mark a failed
// acknowledgement settled so its maintenance retry cannot post later.
if (!response.result?.id && !(await findResponseComment(sibling, api))) {
await lease.commit(async tx => { await tx.update(chatActions).set({ status: "cancelled", result: { final: true, code: "run_already_answered" }, updatedAt: new Date() }).where(eq(chatActions.id, response.id)); });
continue;
}
try {
await writeGitHubResponseComment(db, sibling, api, lease, {
body: link.deliveryId === primary.delivery.id
? `Review complete. See the [review finding](${receipt.url}).`
: `Handled with the [response to this request](${receipt.url}).`, final: true, versionAt: new Date(),
}, async () => { await assertCurrent(); await context(link.deliveryId!); });
} catch (error) {
const details = error instanceof HttpError ? error.details as { code?: string; providerStatus?: number } | undefined : undefined;
if (!response.result?.id || details?.code !== "github_bot_operation_failed" || details.providerStatus !== 404) throw error;
// A human can delete an earlier working comment while the coalesced
// review runs. Settle that exact receipt without recreating the comment
// or blocking the current response and check publication.
await assertCurrent();
await context(link.deliveryId);
await lease.commit(async tx => { await tx.update(chatActions).set({ status: "cancelled",
result: { ...response.result, final: true, code: "response_deleted" }, updatedAt: new Date(),
}).where(and(eq(chatActions.id, response.id), eq(chatActions.companyId, input.companyId),
eq(chatActions.endpointId, input.endpointId))); });
}
}
}
async function failRun(input: { companyId: string; endpointId: string; issueId: string; runId: string; body: string }) {
const { run, links } = await requestsForRun(input);
let receipt: { id: string; url: string } | undefined;
for (const link of links) {
if (!link.deliveryId) continue;
const responseForRequest = async () => (await db.select({ result: chatActions.result }).from(chatActions).where(and(
eq(chatActions.companyId, input.companyId), eq(chatActions.endpointId, input.endpointId),
eq(chatActions.deliveryId, link.deliveryId!), eq(chatActions.kind, "github_response_comment"),
)).limit(1))[0];
if ((await responseForRequest())?.result?.final === true) continue;
const assertRun = async () => {
const source = await context(link.deliveryId!);
const [current] = await db.select(responseRunColumns).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, input.companyId)));
if (!current || current.agentId !== source.endpoint.assignedAgentId ||
!["succeeded", "failed", "interrupted", "timed_out", "cancelled"].includes(current.status) ||
source.endpoint.id !== input.endpointId || source.conversation.issueId !== input.issueId ||
boundRunIssueId(current) !== input.issueId)
throw forbidden("GitHub failure authority changed");
const [latest] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.agentId, source.endpoint.assignedAgentId),
sql`coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId') = ${input.issueId}`,
)).orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)).limit(1);
if (latest?.id !== run.id) throw forbidden("A newer GitHub run owns the response");
if ((await responseForRequest())?.result?.final === true) throw forbidden("GitHub request already has a final response");
if (current.nativePresentationPending) throw conflict("GitHub native response presentation is still being resolved");
if (isMainPullResponse(source) && current.passiveResponseWait) throw forbidden("GitHub run intentionally yielded without a new response");
if (await githubRunReplyState(db, input) !== "none") throw conflict("GitHub reply is still being resolved");
};
receipt = await acknowledge(link.deliveryId, input.body, assertRun);
}
if (!receipt) throw forbidden("GitHub failure has no request binding");
return receipt;
}
async function hasWorkingResponseForRun(input: { companyId: string; endpointId: string; issueId: string; runId: string }) {
const { links } = await requestsForRun(input);
const ids = links.map(link => link.deliveryId).filter((id): id is string => !!id);
if (!ids.length) return false;
const responses = await db.select({ result: chatActions.result }).from(chatActions).where(and(
eq(chatActions.companyId, input.companyId), eq(chatActions.endpointId, input.endpointId),
inArray(chatActions.deliveryId, ids), eq(chatActions.kind, "github_response_comment")));
return responses.some(response => response.result?.final !== true);
}
async function processPending(limit = 10) {
const rows = await db.select().from(chatActions).where(and(eq(chatActions.kind, "github_response_comment"),
or(eq(chatActions.status, "received"), and(eq(chatActions.status, "failed"), sql`${chatActions.result}->>'retryable' = 'true'`,
sql`(${chatActions.result}->>'retryAt')::timestamptz <= now()`)))).orderBy(asc(chatActions.createdAt)).limit(limit);
for (const row of rows) if (row.deliveryId) await tryAcknowledge(row.deliveryId);
return rows.length + await processTerminalResponses(limit);
}
async function processTerminalResponses(limit = 10) {
// Recovery can stop execution before a milestone publication is staged.
// Revisit acknowledged requests with terminal execution, even after a
// restart; successful acknowledgement is not successful task completion.
const stopped = await db.select({ deliveryId: chatActions.deliveryId }).from(chatActions)
.innerJoin(chatConversations, and(eq(chatConversations.id, chatActions.conversationId), eq(chatConversations.companyId, chatActions.companyId)))
.innerJoin(chatEndpoints, and(eq(chatEndpoints.id, chatActions.endpointId), eq(chatEndpoints.companyId, chatActions.companyId)))
.innerJoin(issues, and(eq(issues.id, chatConversations.issueId), eq(issues.companyId, chatActions.companyId)))
.where(and(eq(chatActions.kind, "github_response_comment"), eq(chatActions.status, "processed"),
sql`coalesce(${chatActions.result}->>'final', 'false') <> 'true'`,
sql`exists (select 1 from ${heartbeatRuns} r where r.company_id = ${chatActions.companyId}
and r.agent_id = ${chatEndpoints.assignedAgentId}
and coalesce(r.native_issue_id::text, r.context_snapshot->>'issueId') = ${chatConversations.issueId}::text
and r.status in ('succeeded', 'failed', 'interrupted', 'timed_out', 'cancelled')
and not exists (select 1 from ${heartbeatRuns} newer where newer.company_id = r.company_id
and newer.agent_id = r.agent_id
and coalesce(newer.native_issue_id::text, newer.context_snapshot->>'issueId') = ${chatConversations.issueId}::text
and (newer.created_at, newer.id) > (r.created_at, r.id))
and (${issues.status} = 'blocked' or r.active_identity_context_id is not null or exists (select 1 from ${chatMessageLinks} l
where l.company_id = ${chatActions.companyId} and l.endpoint_id = ${chatActions.endpointId}
and l.delivery_id = ${chatActions.deliveryId} and l.direction = 'inbound'
and (l.comment_id::text = r.context_snapshot->>'wakeCommentId'
or coalesce(r.context_snapshot->'wakeCommentIds', '[]'::jsonb) ? l.comment_id::text))))`,
)).orderBy(asc(chatActions.updatedAt)).limit(limit);
for (const row of stopped) if (row.deliveryId) await tryAcknowledge(row.deliveryId);
return stopped.length;
}
async function tryAcknowledge(deliveryId: string, canContinue = () => true) {
try { return await acknowledge(deliveryId, undefined, undefined, canContinue); }
catch (error) {
if (error === stoppedAcknowledgement || !canContinue()) return;
const [row] = await db.select().from(chatActions).where(and(eq(chatActions.kind, "github_response_comment"), eq(chatActions.deliveryId, deliveryId)));
if (!row || row.result?.final === true) return;
const attempts = Number(row.result?.attempts ?? 0) + 1;
const denied = error instanceof Error && "status" in error && error.status === 403;
await db.transaction(async tx => {
await notifyChatResponseAvailability(tx, { companyId: row.companyId, deliveryId });
await tx.update(chatActions).set({ status: denied ? "cancelled" : "failed", result: {
...row.result, attempts, retryable: !denied && attempts < 8,
retryAt: new Date(Date.now() + Math.min(300000, 1000 * 2 ** attempts)).toISOString(),
code: denied ? "authorization_changed" : "acknowledgement_unconfirmed",
}, updatedAt: new Date() }).where(and(eq(chatActions.id, row.id), sql`coalesce(${chatActions.result}->>'final', 'false') <> 'true'`));
});
}
}
return { tryAcknowledge, processPending, processTerminalResponses, failRun, finishRun, hasWorkingResponseForRun,
registerTerminalResponseWork: (coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, canRun: () => boolean) =>
registerGitHubTerminalResponseWork(db, coordinator, tryAcknowledge, canRun),
registerAcknowledgementWork: (coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, canRun: () => boolean) =>
registerGitHubAcknowledgementWork(db, coordinator, tryAcknowledge, canRun),
};
}