mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
<!-- 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>
456 lines
33 KiB
TypeScript
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),
|
|
};
|
|
}
|