mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
refactor(server): model GitHub terminal response recovery obligations
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
43900c1f94
commit
272afe9a56
12 files changed
+532
-44
No files matched your search
@@ -3554,6 +3554,166 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
expect(attempts).toBe(2);
|
||||
expect(f.responseWrites).toHaveLength(writes);
|
||||
});
|
||||
it("restores terminal GitHub responses and leaves finalized history quiet", async () => {
|
||||
const f = await toolReplyFixture();
|
||||
await db.update(heartbeatRuns).set({ status: "failed", updatedAt: new Date() }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
const errors: unknown[] = [];
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => errors.push(error) });
|
||||
const worker = githubResponseCommentService(db, f.providerFetch).registerTerminalResponseWork(coordinator, () => true);
|
||||
try {
|
||||
await worker.ready;
|
||||
await expect.poll(() => [...f.responseComments.values()][0]?.body, { timeout: 5000 }).toContain("stopped before completing");
|
||||
await expect.poll(() => coordinator.nextWakeAt(), { timeout: 5000 }).toBeNull();
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
||||
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "transaction");
|
||||
const providerWrites = f.responseWrites.length;
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled();
|
||||
expect(f.responseWrites).toHaveLength(providerWrites);
|
||||
reads.mockRestore(); writes.mockRestore();
|
||||
expect(errors).toEqual([]);
|
||||
} finally { await coordinator.stop(); vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
it("wakes terminal GitHub responses after the outer issue transaction commits", async () => {
|
||||
const f = await toolReplyFixture();
|
||||
const errors: unknown[] = [];
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => errors.push(error) });
|
||||
const worker = githubResponseCommentService(db, f.providerFetch).registerTerminalResponseWork(coordinator, () => true);
|
||||
try {
|
||||
await worker.ready;
|
||||
const baseline = f.responseWrites.length;
|
||||
const change = async (tx: Parameters<Parameters<Db["transaction"]>[0]>[0]) => {
|
||||
await tx.update(heartbeatRuns).set({ status: "failed", updatedAt: new Date() }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await issueService(tx as unknown as Db).update(f.conversation.issueId!, { status: "blocked" });
|
||||
};
|
||||
await expect(db.transaction(async tx => { await change(tx); throw new Error("rollback terminal issue"); }))
|
||||
.rejects.toThrow("rollback terminal issue");
|
||||
expect(f.responseWrites).toHaveLength(baseline);
|
||||
let release!: () => void, entered!: () => void;
|
||||
const hold = new Promise<void>(resolve => { release = resolve; });
|
||||
const ready = new Promise<void>(resolve => { entered = resolve; });
|
||||
const pending = db.transaction(async tx => { await change(tx); entered(); await hold; });
|
||||
try { await ready; expect(f.responseWrites).toHaveLength(baseline); }
|
||||
finally { release(); await pending; }
|
||||
await expect.poll(() => [...f.responseComments.values()][0]?.body, { timeout: 5000 }).toContain("stopped before completing");
|
||||
await expect.poll(() => coordinator.nextWakeAt(), { timeout: 5000 }).toBeNull();
|
||||
expect(errors).toEqual([]);
|
||||
} finally { await coordinator.stop(); }
|
||||
});
|
||||
|
||||
it("keeps an acknowledged paused GitHub request quiet until its new run finishes", async () => {
|
||||
const f = await toolReplyFixture("github:paperclipai/paperclip:418");
|
||||
await db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, f.conversation.issueId!));
|
||||
const thread = makeThread({ channelId: "paperclipai/paperclip", id: f.conversation.externalThreadId }).thread;
|
||||
await deliverMessage({ callbacks: f.callbacks, endpointId: f.endpoint.id, provider: "github", thread,
|
||||
trigger: "mention", message: makeMessage({ id: "41802", text: "New request", userId: "42", userName: "octocat", mentioned: true }) });
|
||||
const responses = githubResponseCommentService(db, f.providerFetch);
|
||||
await responses.processPending();
|
||||
expect([...f.responseComments.values()][0].body).toContain("paused");
|
||||
const errors: unknown[] = [];
|
||||
let coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => errors.push(error) });
|
||||
try {
|
||||
await responses.registerTerminalResponseWork(coordinator, () => true).ready;
|
||||
await expect.poll(() => coordinator.nextWakeAt(), { timeout: 5000 }).toBeNull();
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
||||
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "transaction");
|
||||
const providerWrites = f.responseWrites.length;
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled();
|
||||
expect(f.responseWrites).toHaveLength(providerWrites);
|
||||
reads.mockRestore(); writes.mockRestore(); vi.useRealTimers();
|
||||
await coordinator.stop();
|
||||
await db.update(issues).set({ status: "in_progress" }).where(eq(issues.id, f.conversation.issueId!));
|
||||
await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.assignedAgentId,
|
||||
status: "failed", runtimeMode: "native", contextSnapshot: await chatWakeContext({
|
||||
endpointId: f.endpoint.id, issueId: f.conversation.issueId, provider: "github", providerMessageId: "41802",
|
||||
}) });
|
||||
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => errors.push(error) });
|
||||
await responses.registerTerminalResponseWork(coordinator, () => true).ready;
|
||||
await expect.poll(() => [...f.responseComments.values()][0]?.body, { timeout: 5000 }).toContain("stopped before completing");
|
||||
await expect.poll(() => coordinator.nextWakeAt(), { timeout: 5000 }).toBeNull(); expect(errors).toEqual([]);
|
||||
} finally { await coordinator.stop(); vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
it.each(["restart", "drain", "standby"] as const)("retains terminal GitHub response work across %s", async mode => {
|
||||
const f = await toolReplyFixture();
|
||||
let canRun = true;
|
||||
const errors: unknown[] = [];
|
||||
let coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => canRun,
|
||||
canReconcile: () => mode !== "standby" || canRun, onError: error => errors.push(error) });
|
||||
const responses = githubResponseCommentService(db, f.providerFetch);
|
||||
let worker = responses.registerTerminalResponseWork(coordinator, () => canRun);
|
||||
try {
|
||||
await worker.ready;
|
||||
const baseline = f.responseWrites.length;
|
||||
if (mode === "restart") await coordinator.stop(); else canRun = false;
|
||||
await db.update(heartbeatRuns).set({ status: "failed", updatedAt: new Date() }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await issueService(db).update(f.conversation.issueId!, { status: "blocked" });
|
||||
expect(f.responseWrites).toHaveLength(baseline);
|
||||
if (mode === "restart") {
|
||||
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => errors.push(error) });
|
||||
worker = responses.registerTerminalResponseWork(coordinator, () => true);
|
||||
await worker.ready;
|
||||
} else { canRun = true; worker.wake(); }
|
||||
await expect.poll(() => [...f.responseComments.values()][0]?.body, { timeout: 5000 }).toContain("stopped before completing");
|
||||
await expect.poll(() => coordinator.nextWakeAt(), { timeout: 5000 }).toBeNull();
|
||||
expect(errors).toEqual([]);
|
||||
} finally { await coordinator.stop(); }
|
||||
});
|
||||
|
||||
it("reactivates terminal GitHub response recovery from committed native identity", async () => {
|
||||
const f = await toolReplyFixture();
|
||||
const messageId = String(f.run.contextSnapshot?.wakeCommentId);
|
||||
await db.update(heartbeatRuns).set({ status: "failed", nativeIssueId: f.conversation.issueId,
|
||||
contextSnapshot: {}, updatedAt: new Date() }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
const errors: unknown[] = [];
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => errors.push(error) });
|
||||
try {
|
||||
await githubResponseCommentService(db, f.providerFetch).registerTerminalResponseWork(coordinator, () => true).ready;
|
||||
await expect.poll(() => coordinator.nextWakeAt(), { timeout: 5000 }).toBeNull();
|
||||
const baseline = f.responseWrites.length;
|
||||
await expect(db.transaction(async tx => {
|
||||
await initializeRunIdentity(tx as unknown as Db, { companyId: f.companyId, runId: f.run.id, issueId: f.conversation.issueId!,
|
||||
responsibleUserId: "owner-user", messageIds: [messageId], cause: "instruction" });
|
||||
throw new Error("rollback identity");
|
||||
})).rejects.toThrow("rollback identity");
|
||||
expect(f.responseWrites).toHaveLength(baseline);
|
||||
await initializeRunIdentity(db, { companyId: f.companyId, runId: f.run.id, issueId: f.conversation.issueId!,
|
||||
responsibleUserId: "owner-user", messageIds: [messageId], cause: "instruction" });
|
||||
await expect.poll(() => [...f.responseComments.values()][0]?.body, { timeout: 5000 }).toContain("stopped before completing");
|
||||
await expect.poll(() => coordinator.nextWakeAt(), { timeout: 5000 }).toBeNull();
|
||||
expect(errors).toEqual([]);
|
||||
} finally { await coordinator.stop(); }
|
||||
});
|
||||
|
||||
it("waits for the existing GitHub publication lease before a terminal response", async () => {
|
||||
const f = await toolReplyFixture();
|
||||
const [resource] = await db.select().from(chatEndpointResources).where(eq(chatEndpointResources.endpointId, f.endpoint.id));
|
||||
const errors: unknown[] = [];
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => errors.push(error) });
|
||||
let release!: () => void, entered!: () => void;
|
||||
const hold = new Promise<void>(resolve => { release = resolve; });
|
||||
const ready = new Promise<void>(resolve => { entered = resolve; });
|
||||
let lease: Promise<unknown> | undefined;
|
||||
try {
|
||||
await githubResponseCommentService(db, f.providerFetch).registerTerminalResponseWork(coordinator, () => true).ready;
|
||||
lease = withGitHubPublicationLease(db, { companyId: f.companyId, endpointId: f.endpoint.id,
|
||||
repositoryId: String(resource.metadata.providerRepositoryId), number: 418 }, f.providerFetch, async () => { entered(); await hold; });
|
||||
await ready;
|
||||
const baseline = f.responseWrites.length;
|
||||
await db.update(heartbeatRuns).set({ status: "failed", updatedAt: new Date() }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await issueService(db).update(f.conversation.issueId!, { status: "blocked" });
|
||||
await expect.poll(() => coordinator.nextWakeAt(), { timeout: 5000 }).not.toBeNull();
|
||||
expect(f.responseWrites).toHaveLength(baseline);
|
||||
release(); await lease;
|
||||
await expect.poll(() => [...f.responseComments.values()][0]?.body, { timeout: 5000 }).toContain("stopped before completing");
|
||||
await expect.poll(() => coordinator.nextWakeAt(), { timeout: 5000 }).toBeNull();
|
||||
expect(errors).toEqual([]);
|
||||
} finally { release(); await lease; await coordinator.stop(); }
|
||||
});
|
||||
|
||||
it.each(["release", "restart", "drain", "standby"] as const)("wakes GitHub acknowledgements from a real publication lease %s", async mode => {
|
||||
const f = await reviewBotFixture();
|
||||
const [resource] = await db.select().from(chatEndpointResources).where(eq(chatEndpointResources.endpointId, f.endpoint.id));
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { and, desc, eq, or, sql } from "drizzle-orm";
|
||||
import { chatMessageLinks, heartbeatRuns, type Db } from "@paperclipai/db";
|
||||
import { githubRunReplyState } from "../../chat-run-publications.js";
|
||||
import { chatRunSourceMessageId } from "../run-origin.js";
|
||||
|
||||
const runColumns = {
|
||||
id: heartbeatRuns.id,
|
||||
companyId: heartbeatRuns.companyId,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
responsibleUserId: heartbeatRuns.responsibleUserId,
|
||||
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
|
||||
nativeIssueId: heartbeatRuns.nativeIssueId,
|
||||
issueId: heartbeatRuns.issueId,
|
||||
status: heartbeatRuns.status,
|
||||
updatedAt: heartbeatRuns.updatedAt,
|
||||
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object(
|
||||
'issueId', ${heartbeatRuns.contextSnapshot}->'issueId',
|
||||
'taskId', ${heartbeatRuns.contextSnapshot}->'taskId',
|
||||
'wakeCommentId', ${heartbeatRuns.contextSnapshot}->'wakeCommentId',
|
||||
'wakeCommentIds', ${heartbeatRuns.contextSnapshot}->'wakeCommentIds'
|
||||
)`,
|
||||
};
|
||||
|
||||
export type GitHubResponseExecutionInput = {
|
||||
companyId: string;
|
||||
endpointId: string;
|
||||
agentId: string;
|
||||
issueId: string;
|
||||
deliveryId: string;
|
||||
sourceMessageId: string | null;
|
||||
issueStatus: string;
|
||||
deliveryCreatedAt: Date;
|
||||
};
|
||||
|
||||
export async function githubResponseExecution(db: Db, input: GitHubResponseExecutionInput) {
|
||||
const [requestRun] = await db.select({ runId: heartbeatRuns.id }).from(heartbeatRuns).innerJoin(chatMessageLinks, and(
|
||||
eq(chatMessageLinks.companyId, input.companyId), eq(chatMessageLinks.endpointId, input.endpointId),
|
||||
eq(chatMessageLinks.deliveryId, input.deliveryId), eq(chatMessageLinks.direction, "inbound"),
|
||||
or(sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot}->>'wakeCommentId'`,
|
||||
sql`coalesce(${heartbeatRuns.contextSnapshot}->'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`),
|
||||
)).where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.agentId, input.agentId),
|
||||
sql`coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId') = ${input.issueId}`))
|
||||
.orderBy(desc(heartbeatRuns.createdAt)).limit(1);
|
||||
const [latestRun] = await db.select(runColumns).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.agentId, input.agentId),
|
||||
sql`coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId') = ${input.issueId}`,
|
||||
)).orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)).limit(1);
|
||||
const originMessageId = latestRun?.activeIdentityContextId ? await chatRunSourceMessageId(db, latestRun,
|
||||
{ allowLegacyWakeComment: true, provider: "github" }) : null;
|
||||
const ownsLatestRequest = !!latestRun && (requestRun?.runId === latestRun.id ||
|
||||
(!!input.sourceMessageId && originMessageId === input.sourceMessageId));
|
||||
const requestRunning = ownsLatestRequest && latestRun?.status === "running";
|
||||
const terminal = latestRun && ["succeeded", "failed", "interrupted", "timed_out", "cancelled"].includes(latestRun.status);
|
||||
const run = terminal && (ownsLatestRequest || input.issueStatus === "blocked") ? latestRun : null;
|
||||
|
||||
const reply = run && ownsLatestRequest ? await githubRunReplyState(db, {
|
||||
companyId: input.companyId, endpointId: input.endpointId, issueId: input.issueId, runId: run.id,
|
||||
}) : "none";
|
||||
const paused = !!run ? input.issueStatus === "blocked" && !ownsLatestRequest
|
||||
: input.issueStatus === "blocked" && latestRun?.status !== "running";
|
||||
const body = run ? reply === "confirmed" ? "This request has been handled. See the response in this conversation."
|
||||
: paused ? "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."
|
||||
: paused ? "Work is paused. Your request is saved. See the Paperclip task for details." : undefined;
|
||||
return {
|
||||
body, paused, reply, running: requestRunning,
|
||||
versionAt: run ? new Date(Math.max(run.updatedAt.getTime(), input.deliveryCreatedAt.getTime()))
|
||||
: paused ? input.deliveryCreatedAt : undefined,
|
||||
version: JSON.stringify([latestRun?.id ?? null, latestRun?.status ?? null,
|
||||
latestRun?.updatedAt.toISOString() ?? null, ownsLatestRequest, input.issueStatus === "blocked", reply]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { and, asc, eq, gt, isNotNull, sql } from "drizzle-orm";
|
||||
import { chatActions, chatConversations, chatDeliveries, chatEndpointLeases, chatEndpointResources,
|
||||
chatEndpoints, chatMessageLinks, heartbeatRuns, issues, type Db } from "@paperclipai/db";
|
||||
import type { createDeliveryWorkCoordinator } from "../../delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES } from "../../delivery-work-notifications.js";
|
||||
import type { ChatResponseScope } from "../../chat-response-availability.js";
|
||||
import { registerScopedDeadlineWork } from "../../scoped-deadline-work.js";
|
||||
import { githubResponseExecution } from "./response-execution.js";
|
||||
|
||||
type Scope = ChatResponseScope & { endpointId?: string; repositoryId?: string; number?: number };
|
||||
type Entry = { id: string; companyId: string; endpointId: string; deliveryId: string;
|
||||
issueId: string; conversationId: string; repositoryId: string | null; number: number | null; dueAt: number };
|
||||
|
||||
export function registerGitHubTerminalResponseWork(
|
||||
db: Db,
|
||||
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
|
||||
acknowledge: (deliveryId: string, canContinue: () => boolean) => Promise<unknown>,
|
||||
canRun: () => boolean,
|
||||
) {
|
||||
const repositoryId = sql<string | null>`${chatEndpointResources.metadata}->>'providerRepositoryId'`;
|
||||
const number = sql<string | null>`substring(${chatConversations.externalThreadId}
|
||||
from '^github:[^:]+:(?:issue:)?([1-9][0-9]*)(?::rc:[1-9][0-9]*)?$')`;
|
||||
async function page(scope?: Scope, cursor?: string, actionId?: string) {
|
||||
return db.select({
|
||||
id: chatActions.id, companyId: chatActions.companyId, endpointId: chatActions.endpointId,
|
||||
deliveryId: chatActions.deliveryId, conversationId: chatConversations.id, issueId: issues.id,
|
||||
agentId: chatEndpoints.assignedAgentId, issueStatus: issues.status,
|
||||
deliveryCreatedAt: chatDeliveries.createdAt, repositoryId, number, result: chatActions.result,
|
||||
sourceMessageId: sql<string | null>`(select l.comment_id 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' limit 1)`,
|
||||
leaseExpiresAt: sql<string | null>`(select max(l.expires_at) from ${chatEndpointLeases} l
|
||||
where l.company_id = ${chatActions.companyId} and l.endpoint_id = ${chatActions.endpointId}
|
||||
and l.lease_key = 'github-publication:' || (${repositoryId}) || ':' || ${number})`,
|
||||
}).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(chatDeliveries, and(eq(chatDeliveries.id, chatActions.deliveryId), eq(chatDeliveries.companyId, chatActions.companyId)))
|
||||
.innerJoin(issues, and(eq(issues.id, chatConversations.issueId), eq(issues.companyId, chatActions.companyId)))
|
||||
.leftJoin(chatEndpointResources, and(eq(chatEndpointResources.id, chatConversations.resourceId), eq(chatEndpointResources.companyId, chatActions.companyId)))
|
||||
.where(and(eq(chatActions.kind, "github_response_comment"), eq(chatActions.status, "processed"),
|
||||
isNotNull(chatActions.deliveryId), isNotNull(chatEndpoints.assignedAgentId),
|
||||
sql`coalesce(${chatActions.result}->>'final', 'false') <> 'true'`,
|
||||
sql`(${issues.status} = 'blocked' or (select r.status 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
|
||||
order by r.created_at desc, r.id desc limit 1) in ('succeeded', 'failed', 'interrupted', 'timed_out', 'cancelled'))`,
|
||||
scope ? eq(chatActions.companyId, scope.companyId) : undefined,
|
||||
scope?.issueId ? eq(issues.id, scope.issueId) : undefined,
|
||||
scope?.conversationId ? eq(chatActions.conversationId, scope.conversationId) : undefined,
|
||||
scope?.deliveryId ? eq(chatActions.deliveryId, scope.deliveryId) : undefined,
|
||||
scope?.endpointId ? eq(chatActions.endpointId, scope.endpointId) : undefined,
|
||||
scope?.repositoryId ? eq(repositoryId, scope.repositoryId) : undefined,
|
||||
scope?.number ? eq(number, String(scope.number)) : undefined,
|
||||
cursor ? gt(chatActions.id, cursor) : undefined, actionId ? eq(chatActions.id, actionId) : undefined,
|
||||
)).orderBy(asc(chatActions.id)).limit(100);
|
||||
}
|
||||
async function entry(row: Awaited<ReturnType<typeof page>>[number]): Promise<Entry | null> {
|
||||
const execution = await githubResponseExecution(db, { ...row, agentId: row.agentId!, deliveryId: row.deliveryId! });
|
||||
if (execution.body === undefined || execution.reply === "unsettled" ||
|
||||
(execution.paused && row.result?.pausedVersion === execution.version)) return null;
|
||||
return { id: row.id, companyId: row.companyId, endpointId: row.endpointId, deliveryId: row.deliveryId!,
|
||||
issueId: row.issueId, conversationId: row.conversationId, repositoryId: row.repositoryId,
|
||||
number: row.number ? Number(row.number) : null, dueAt: row.leaseExpiresAt ? Date.parse(row.leaseExpiresAt) : 0 };
|
||||
}
|
||||
return registerScopedDeadlineWork(coordinator, {
|
||||
queue: DELIVERY_QUEUES.chatGitHubTerminalResponses,
|
||||
decodeScope: (hint: string): Scope | undefined => hint === "*" ? undefined : JSON.parse(hint),
|
||||
matches: (value: Entry, scope?: Scope) => !scope || (value.companyId === scope.companyId &&
|
||||
(!scope.issueId || value.issueId === scope.issueId) && (!scope.deliveryId || value.deliveryId === scope.deliveryId) &&
|
||||
(!scope.conversationId || value.conversationId === scope.conversationId) &&
|
||||
(!scope.endpointId || value.endpointId === scope.endpointId) && (!scope.repositoryId || value.repositoryId === scope.repositoryId) &&
|
||||
(!scope.number || value.number === scope.number)),
|
||||
load: async (scope?: Scope, signal?: AbortSignal) => {
|
||||
const entries: Entry[] = [];
|
||||
let cursor: string | undefined;
|
||||
while (canRun() && !signal?.aborted) {
|
||||
const rows = await page(scope, cursor);
|
||||
for (const row of rows) {
|
||||
if (!canRun() || signal?.aborted) throw signal?.reason ?? new Error("GitHub terminal-response recovery stopped");
|
||||
const value = await entry(row); if (value) entries.push(value);
|
||||
}
|
||||
if (rows.length < 100) return entries;
|
||||
cursor = rows[rows.length - 1].id;
|
||||
}
|
||||
throw signal?.reason ?? new Error("GitHub terminal-response recovery stopped");
|
||||
},
|
||||
check: async (value: Entry, signal?: AbortSignal) => {
|
||||
const [row] = await page(undefined, undefined, value.id);
|
||||
const current = row ? await entry(row) : null;
|
||||
if (!current || current.dueAt > Date.now() || !canRun() || signal?.aborted) return current;
|
||||
await acknowledge(current.deliveryId, () => canRun() && !signal?.aborted);
|
||||
const [remaining] = await page(undefined, undefined, value.id);
|
||||
return remaining ? entry(remaining) : null;
|
||||
},
|
||||
}, canRun);
|
||||
}
|
||||
@@ -116,6 +116,7 @@ export async function withGitHubPublicationLease<T>(
|
||||
await renewal;
|
||||
await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatGitHubPublications);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatGitHubTerminalResponses, JSON.stringify(scope));
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatGitHubAcknowledgements, JSON.stringify(scope));
|
||||
await tx.delete(chatEndpointLeases).where(and(eq(chatEndpointLeases.id, claimed.id), eq(chatEndpointLeases.token, token)));
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
fastResponseRequests, chatPublications,
|
||||
type Db,
|
||||
} from "@paperclipai/db";
|
||||
import { registerGitHubTerminalResponseWork } from "./chat-channels/github/terminal-response-work.js";
|
||||
import { githubResponseExecution } 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";
|
||||
@@ -93,7 +95,7 @@ async function stageResponse(db: Db, source: Source) {
|
||||
|
||||
export async function writeGitHubResponseComment(
|
||||
db: Db, source: Source, api: Api, lease: Lease,
|
||||
input: { body: string; final: boolean; versionAt?: Date; acknowledgement?: boolean; preserveFinal?: boolean },
|
||||
input: { body: string; final: boolean; versionAt?: Date; acknowledgement?: boolean; preserveFinal?: boolean; pausedVersion?: string },
|
||||
assertCurrent: () => Promise<void>,
|
||||
) {
|
||||
await assertCurrent();
|
||||
@@ -116,8 +118,10 @@ export async function writeGitHubResponseComment(
|
||||
(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() })
|
||||
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) };
|
||||
@@ -161,6 +165,7 @@ export async function writeGitHubResponseComment(
|
||||
}, 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",
|
||||
@@ -222,49 +227,28 @@ export function githubResponseCommentService(db: Db, fetchImpl = fetch) {
|
||||
let requestRunning = false;
|
||||
let requestPaused = false;
|
||||
let preserveFinal = false;
|
||||
let pausedVersion: string | undefined;
|
||||
if (finalBody === undefined) {
|
||||
const existing = await stageResponse(db, source);
|
||||
const [requestRun] = await db.select({ runId: heartbeatRuns.id }).from(heartbeatRuns).innerJoin(chatMessageLinks, and(
|
||||
eq(chatMessageLinks.companyId, source.endpoint.companyId), eq(chatMessageLinks.endpointId, source.endpoint.id),
|
||||
eq(chatMessageLinks.deliveryId, deliveryId), eq(chatMessageLinks.direction, "inbound"),
|
||||
or(sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot}->>'wakeCommentId'`,
|
||||
sql`coalesce(${heartbeatRuns.contextSnapshot}->'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`),
|
||||
)).where(and(eq(heartbeatRuns.companyId, source.endpoint.companyId), eq(heartbeatRuns.agentId, source.endpoint.assignedAgentId),
|
||||
sql`coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId') = ${source.conversation.issueId}`))
|
||||
.orderBy(desc(heartbeatRuns.createdAt)).limit(1);
|
||||
const [latestRun] = await db.select(responseRunColumns).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.companyId, source.endpoint.companyId), eq(heartbeatRuns.agentId, source.endpoint.assignedAgentId),
|
||||
sql`coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId') = ${source.conversation.issueId}`,
|
||||
)).orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)).limit(1);
|
||||
const originMessageId = latestRun?.activeIdentityContextId ? await chatRunSourceMessageId(db, latestRun,
|
||||
{ allowLegacyWakeComment: true, provider: "github" }) : null;
|
||||
const ownsLatestRequest = !!latestRun && (requestRun?.runId === latestRun.id ||
|
||||
(!!source.sourceMessageId && originMessageId === 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");
|
||||
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) ||
|
||||
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 (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)) {
|
||||
// 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(),
|
||||
@@ -305,7 +289,7 @@ export function githubResponseCommentService(db: Db, fetchImpl = fetch) {
|
||||
}
|
||||
return writeGitHubResponseComment(db, source, api, lease,
|
||||
{ body: finalBody ?? (isMainPullResponse(source) && !requestRunning ? "Review queued…" : "Working on this…"), final: finalBody !== undefined && !requestPaused,
|
||||
acknowledgement: finalBody === undefined, preserveFinal,
|
||||
acknowledgement: finalBody === undefined, preserveFinal, pausedVersion,
|
||||
...(finalBody !== undefined ? { versionAt: finalVersionAt ?? new Date() } : {}) }, assertCurrent);
|
||||
});
|
||||
}
|
||||
@@ -464,6 +448,8 @@ export function githubResponseCommentService(db: Db, fetchImpl = fetch) {
|
||||
}
|
||||
}
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyChatResponseAvailability } from "./chat-response-availability.js";
|
||||
import { githubRunReplyState } from "./chat-run-publications.js";
|
||||
import { stageReceiptReactionRemovals } from "./chat-receipt-reactions.js";
|
||||
import { chatRunSourceMessageId } from "./chat-channels/run-origin.js";
|
||||
@@ -461,6 +462,7 @@ export function githubChatReviewService(db: Db, fetchImpl = fetch) {
|
||||
): Promise<string> {
|
||||
if (writer === db) return db.transaction(tx => stage(source, operation, payload, key, invocationId, toolName, parameters, tx));
|
||||
const actionKey = `github_publication:${source.issue.id}:${operation}:${hash(key)}`;
|
||||
await notifyChatResponseAvailability(writer, { companyId: source.endpoint.companyId, issueId: source.issue.id });
|
||||
await notifyDeliveryWork(writer, DELIVERY_QUEUES.chatGitHubPublications);
|
||||
const values = {
|
||||
companyId: source.endpoint.companyId,
|
||||
@@ -1046,6 +1048,7 @@ export function githubChatReviewService(db: Db, fetchImpl = fetch) {
|
||||
)?.[1];
|
||||
if (!number || !binding?.repository.providerRepositoryId) {
|
||||
await db.transaction(async tx => {
|
||||
await notifyChatResponseAvailability(tx, { companyId: action.companyId, conversationId: action.conversationId ?? undefined });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatGitHubPublications);
|
||||
await tx.update(chatActions).set({ status: "cancelled", result: { code: "publication_binding_unavailable", retryable: false }, updatedAt: new Date() })
|
||||
.where(and(eq(chatActions.id, actionId), eq(chatActions.companyId, action.companyId)));
|
||||
|
||||
@@ -3,5 +3,6 @@ import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notificatio
|
||||
export type ChatResponseScope = { companyId: string; issueId?: string; deliveryId?: string; conversationId?: string };
|
||||
|
||||
export async function notifyChatResponseAvailability(tx: object, scope: ChatResponseScope) {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatGitHubTerminalResponses, JSON.stringify({ companyId: scope.companyId, issueId: scope.issueId, deliveryId: scope.deliveryId, conversationId: scope.conversationId }));
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatGitHubAcknowledgements, JSON.stringify({ companyId: scope.companyId, issueId: scope.issueId, deliveryId: scope.deliveryId, conversationId: scope.conversationId }));
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notificatio
|
||||
export type ChatRunMilestoneScope = { companyId: string; issueId?: string };
|
||||
|
||||
export async function notifyChatRunMilestones(tx: object, scope: ChatRunMilestoneScope) {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatGitHubTerminalResponses, JSON.stringify({ companyId: scope.companyId, issueId: scope.issueId }));
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatRunMilestones, JSON.stringify({ companyId: scope.companyId, issueId: scope.issueId }));
|
||||
// Lifecycle and coarse native progress share lineage and authority. Extend
|
||||
// this eligibility boundary once rather than adding wrappers to its callers.
|
||||
|
||||
@@ -38,6 +38,7 @@ export const DELIVERY_QUEUES = {
|
||||
chatProviderEffects: "chat-provider-effects",
|
||||
chatGitHubIngress: "chat-github-ingress",
|
||||
chatGitHubPublications: "chat-github-publications",
|
||||
chatGitHubTerminalResponses: "chat-github-terminal-responses",
|
||||
chatGitHubAcknowledgements: "chat-github-acknowledgements",
|
||||
chatFailedRunRetries: "chat-failed-run-retries",
|
||||
chatTelegramMaintenance: "chat-telegram-maintenance",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyChatResponseAvailability } from "./chat-response-availability.js";
|
||||
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
|
||||
import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js";
|
||||
import { notifyApprovalWork } from "./approval-work-notifications.js";
|
||||
@@ -11307,6 +11308,10 @@ export function issueService(db: Db) {
|
||||
if (patch.status === "done" || patch.status === "cancelled") {
|
||||
await notifyDecisionTargetWork(tx, `issue:${id}`);
|
||||
}
|
||||
if ((patch.status !== undefined && patch.status !== receiptExisting.status) ||
|
||||
(patch.assigneeAgentId !== undefined && patch.assigneeAgentId !== receiptExisting.assigneeAgentId)) {
|
||||
await notifyChatResponseAvailability(tx, { companyId: receiptExisting.companyId, issueId: id });
|
||||
}
|
||||
if (patch.status !== undefined && patch.status !== receiptExisting.status) {
|
||||
await notifyChatInboundWakeNotices(tx, { companyId: receiptExisting.companyId, issueId: id });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user