mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
fix: renew explicit continuation authorization for bounded retries (#14987)
Preserve exact user-authorized continuation receipts across bounded retries and retain the task claim through owned retry admission. Stop, reassignment, superseding input, and active cleanup continue to block unsafe continuation. Validation: exact-head Greptile 5/5, passing CI, no unresolved review threads, and a clean merge. Detailed verification and limitations are recorded in the pull request. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7498705642
commit
2e67ea8dc7
11 files changed
+639
-16
No files matched your search
@@ -9,6 +9,7 @@ import { executionProjectionsForRuns } from "./execution-projection.js";
|
||||
import type { ExecutionProjection } from "@paperclipai/shared";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { isExplicitContinuationRetryClaim } from "./explicit-continuation-retry-claim.js";
|
||||
import { markdownToPlainText, parseMarkdown } from "chat";
|
||||
import {
|
||||
and,
|
||||
@@ -7497,6 +7498,7 @@ export function issueService(db: Db) {
|
||||
const lockedIssue = await tx
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
checkoutRunId: issues.checkoutRunId,
|
||||
@@ -7528,7 +7530,7 @@ export function issueService(db: Db) {
|
||||
]);
|
||||
const [existingRun, actorRun] = await Promise.all([
|
||||
tx
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, input.expectedCheckoutRunId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
@@ -7540,6 +7542,17 @@ export function issueService(db: Db) {
|
||||
]);
|
||||
const stale =
|
||||
!existingRun || TERMINAL_HEARTBEAT_RUN_STATUSES.has(existingRun.status);
|
||||
if (isExplicitContinuationRetryClaim(lockedIssue, existingRun)) {
|
||||
return { adopted: null, latest: lockedIssue };
|
||||
}
|
||||
if (lockedIssue.executionRunId && lockedIssue.executionRunId !== input.expectedCheckoutRunId) {
|
||||
const executionRun = await tx.select().from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, lockedIssue.executionRunId)).for("update")
|
||||
.then(rows => rows[0] ?? null);
|
||||
if (isExplicitContinuationRetryClaim(lockedIssue, executionRun)) {
|
||||
return { adopted: null, latest: lockedIssue };
|
||||
}
|
||||
}
|
||||
const actorLive =
|
||||
actorRun && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(actorRun.status);
|
||||
if (!stale || !actorLive) {
|
||||
@@ -7649,7 +7662,7 @@ export function issueService(db: Db) {
|
||||
sql`select ${issues.id} from ${issues} where ${issues.id} = ${issueId} for update`,
|
||||
);
|
||||
const issue = await tx
|
||||
.select({ executionRunId: issues.executionRunId })
|
||||
.select({ id: issues.id, companyId: issues.companyId, executionRunId: issues.executionRunId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
@@ -7659,11 +7672,12 @@ export function issueService(db: Db) {
|
||||
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${issue.executionRunId} for update`,
|
||||
);
|
||||
const run = await tx
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, issue.executionRunId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (run && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status)) return false;
|
||||
if (isExplicitContinuationRetryClaim(issue, run)) return false;
|
||||
|
||||
const updated = await tx
|
||||
.update(issues)
|
||||
@@ -7689,8 +7703,7 @@ export function issueService(db: Db) {
|
||||
// Symmetric to clearExecutionRunIfTerminal. Clears checkoutRunId (and the
|
||||
// bundled execution lock cols) when the row's checkoutRunId points at a
|
||||
// heartbeat run that is terminal or no longer exists. No assignee/status
|
||||
// precondition: a terminal run holds no real claim regardless of who is
|
||||
// assigned or what status the issue is currently in.
|
||||
// precondition. Explicit retry claims remain owned by queue-first settlement.
|
||||
async function clearCheckoutRunIfTerminal(issueId: string): Promise<boolean> {
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
@@ -7698,6 +7711,8 @@ export function issueService(db: Db) {
|
||||
);
|
||||
const issue = await tx
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
checkoutRunId: issues.checkoutRunId,
|
||||
executionRunId: issues.executionRunId,
|
||||
})
|
||||
@@ -7710,11 +7725,12 @@ export function issueService(db: Db) {
|
||||
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${issue.checkoutRunId} for update`,
|
||||
);
|
||||
const run = await tx
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, issue.checkoutRunId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (run && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status)) return false;
|
||||
if (isExplicitContinuationRetryClaim(issue, run)) return false;
|
||||
|
||||
if (
|
||||
issue.executionRunId &&
|
||||
@@ -7724,7 +7740,7 @@ export function issueService(db: Db) {
|
||||
sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${issue.executionRunId} for update`,
|
||||
);
|
||||
const executionRun = await tx
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, issue.executionRunId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
@@ -7733,6 +7749,7 @@ export function issueService(db: Db) {
|
||||
!TERMINAL_HEARTBEAT_RUN_STATUSES.has(executionRun.status)
|
||||
)
|
||||
return false;
|
||||
if (isExplicitContinuationRetryClaim(issue, executionRun)) return false;
|
||||
}
|
||||
|
||||
const updated = await tx
|
||||
@@ -11600,10 +11617,10 @@ export function issueService(db: Db) {
|
||||
current.executionRunId !== checkoutRunId &&
|
||||
(current.assigneeAgentId === agentId || current.assigneeAgentId == null)
|
||||
) {
|
||||
const stale = await isTerminalOrMissingHeartbeatRun(
|
||||
current.executionRunId,
|
||||
);
|
||||
if (stale) {
|
||||
const executionRun = await db.select().from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, current.executionRunId)).then(rows => rows[0] ?? null);
|
||||
const stale = !executionRun || TERMINAL_HEARTBEAT_RUN_STATUSES.has(executionRun.status);
|
||||
if (stale && !isExplicitContinuationRetryClaim({ ...current, companyId: issueCompany.companyId }, executionRun)) {
|
||||
const now = new Date();
|
||||
const adoptionSet: Record<string, unknown> = {
|
||||
assigneeAgentId: agentId,
|
||||
|
||||
Reference in new issue
Block a user