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

## Thinking Path

> - Paperclip coordinates agent work and presents its progress in chat.
> - GitHub deliveries save acknowledgement actions and provider
receipts.
> - Periodic maintenance currently searches for unfinished
acknowledgements even when idle.
> - Committed delivery, lease, response and presentation changes
identify when work can advance.
> - The existing owner should wake on those changes and recover only at
saved deadlines.
> - Completed history should become quiet without losing unfinished
provider effects.

## Linked Issues or Issue Description

Refs #17653, which is merged. This existing GitHub acknowledgement slice
targets master.

**Problem:** A saved GitHub acknowledgement can wait for a publication
lease, fast-response result or native presentation decision. Polling
empty or unchanged work keeps the tenant active. Removing maintenance
without covering these producers can strand the acknowledgement.

**Expected behavior:** Committed changes wake the scoped owner. It
reconstructs existing actions on startup, retries at saved deadlines,
parks unchanged presentation waits and becomes quiet when settled.

## What Changed

- Register GitHub acknowledgement work with the shared scoped deadline
coordinator. Reuse existing action, delivery, lease and fast-response
rows.
- Add transaction-aware response-availability notifications at supported
writes. Keep GitHub protocol details in its platform owner and shared
notifications limited to IDs.
- Share the bounded latest-request/run projection between scheduling and
direct response authorization. An absent native presentation decision
parks the action until its real writer commits.
- Retain publication leases, source/principal authority, version
fencing, response markers and final receipt precedence. Check admission
before each new provider request; let in-flight work settle. Stop does
not consume the provider retry budget.
- Remove only recurring acknowledgement recovery and keep its manual
path. Add no work table or scheduler service.
- Regenerate the three original indexes as
`0400_absent_phantom_reporter.sql`: pending acknowledgement IDs,
fast-response delivery lookup and publication comment/destination
lookup. Each uses concurrent/idempotent creation and the existing
top-level migration runner. Preserve all landed SQL/journal entries and
the five newest snapshots.

## Verification

Published head: `09ca00b9f029838434ec042cda691345d67b493e`, on landed
continuation parent `063cfd43813cabf18417605fb9eefb9aacf95c8d`.

- **87 distinct current cases pass:** 18 real PostgreSQL acknowledgement
lifecycle/presentation cases, all 26 fast-response service cases, all 30
heartbeat completion cases and all 13 startup cases. Database build,
server typechecking/build, module boundaries and three fresh migrations
through 0400 pass. The current Runner bundle rebuild also passes after
upstream provider-event changes.
- The lifecycle matrix covers empty/history quiet, lease release,
restart, drain/standby, fast-response failure/expiry/publication/unknown
outcome, streaming quarantine, outer rollback/commit, saved provider
retry, stop between requests and receipt state. The real presentation
writer proves quiet while pending, rollback, committed reactivation and
final quiet.
- The earlier **305-case** matrix (236 full GitHub workflow cases, 26
fast-response service cases, 30 completion cases and 13 startup cases)
passes at `337f272853d4f8a51b5b9300900a46eb4e13f698`. It retains its
prepared-parent scope. Repeated cases are not added to current 87.
- A populated 0399 database upgrades to 0400 with all 398 prior
migration receipts unchanged, one new receipt and no additional receipt
on replay. All three indexes are valid/ready on fresh and upgraded
databases. Their SQL definitions match the original draft after
whitespace normalization; migration history and retained snapshot bytes
are preserved.
- Normal integration `9b3607e159153e00ccdfe538d52b591bd38d6499`
preserves the full 28-path owned binary patch and all 20 unrelated
master paths, including native retry, provider-event and evaluation
workflow changes. Both normal merge parents and the exact tree are
checked. No force push or rebase occurs.
- [The owner
audit](https://github.com/paperclipai/paperclip/blob/09ca00b9f029838434ec042cda691345d67b493e/doc/plans/2026-10-25-github-chat-maintenance-sleep-audit.md#acceptance-on-the-actual-landed-continuation-parent)
retains the original missing-notification, JSON precedence, receipt
lifecycle, external-database fixture and migration-registry failures.
Earlier 69/228/broad counts keep their original scope. Skips and failed
revisions are not relabelled as current passes.
- The final audit-only edit 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, live
GitHub tenant or deployed sleep result is claimed.

## Risks

- Process-local transaction hints cover supported application writers
and shared pools. Independent SQL writers and other replicas do not gain
a new wake guarantee.
- Provider effects remain guarded by the existing publication lease and
current source/principal authority. Unknown outcomes retain their
existing recovery semantics.
- Presentation waits depend on their supported milestone/transaction
producers. A missing producer can defer recovery until another supported
change or restart.
- Index creation can take time on large histories and runs concurrently
outside a transaction. Existing migration recovery handles replay; prior
SQL is unchanged.
- Worker quiet is distinct from host/database suspension. Cloud wake
coverage and sleep admission remain unchanged; later maintenance duties
are separate slices.

## 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:06:42 -05:00

461 lines
34 KiB
TypeScript

import { readGitHubResponseRun, responseRunColumns } from "./chat-channels/github/response-run-state.js";
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 { 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 },
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)) await lease.commit(async tx => {
await tx.update(chatActions).set({ status: "processed", 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(),
}, 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;
if (finalBody === undefined) {
const existing = await stageResponse(db, source);
const { latestRun, ownsLatestRequest } = await readGitHubResponseRun(db, {
companyId: source.endpoint.companyId, endpointId: source.endpoint.id,
agentId: source.endpoint.assignedAgentId, issueId: source.conversation.issueId,
deliveryId, sourceMessageId: source.sourceMessageId,
});
requestRunning = ownsLatestRequest && latestRun?.status === "running";
const terminal = latestRun && ["succeeded", "failed", "interrupted", "timed_out", "cancelled"].includes(latestRun.status);
const run = terminal && (ownsLatestRequest || source.issueStatus === "blocked") ? latestRun : null;
if (run) {
const reply = ownsLatestRequest ? await githubRunReplyState(db, {
companyId: source.endpoint.companyId, endpointId: source.endpoint.id,
issueId: source.conversation.issueId!, runId: run.id }) : "none";
if (reply === "unsettled") throw conflict("GitHub reply is still being resolved");
// A confirmed reply for this exact run can retire its placeholder.
// Otherwise terminal execution is not yet a missing-response decision.
if (ownsLatestRequest && run.nativePresentationPending && reply === "none") return;
if (isMainPullResponse(source) && ownsLatestRequest && run.passiveResponseWait && reply === "none") {
// A committed passive turn intentionally has no new response.
// Settle this request's acknowledgement without editing the shared
// PR assessment or inventing a current-run publication receipt.
await lease.commit(async tx => { 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;
preserveFinal = reply === "confirmed";
onlyExisting = reply === "confirmed" && !existing.result?.id;
requestPaused = source.issueStatus === "blocked" && !ownsLatestRequest;
finalBody = reply === "confirmed" ? "This request has been handled. See the response in this conversation."
: requestPaused ? "Work is paused. Your request is saved. See the Paperclip task for details."
: run.status === "succeeded" ? "This turn ended without publishing a final response. See the Paperclip task for details."
: "This turn stopped before completing. See the Paperclip task for details.";
finalVersionAt = new Date(Math.max(run.updatedAt.getTime(), source.delivery.createdAt.getTime()));
} else if (source.issueStatus === "blocked" && latestRun?.status !== "running") {
requestPaused = true;
finalBody = "Work is paused. Your request is saved. See the Paperclip task for details.";
finalVersionAt = source.delivery.createdAt;
} else if (existing.result?.id && (!isMainPullResponse(source) ||
(await stagePullResponse(db, source)).result?.id === existing.result.id)) {
// Rotate no-op maintenance checks so a page of newer queued asks
// cannot hide other conversations that need terminal reconciliation.
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,
...(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,
registerAcknowledgementWork: (coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, canRun: () => boolean) =>
registerGitHubAcknowledgementWork(db, coordinator, tryAcknowledge, canRun),
};
}