mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
Add issue review policy and comment retry
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
4b39b0cc14
commit
b3e0c31239
18 changed files with 1409 additions and 5 deletions
|
|
@ -1835,6 +1835,210 @@ export function heartbeatService(db: Db) {
|
|||
return updated;
|
||||
}
|
||||
|
||||
async function patchRunIssueCommentStatus(
|
||||
runId: string,
|
||||
patch: Partial<Pick<typeof heartbeatRuns.$inferInsert, "issueCommentStatus" | "issueCommentSatisfiedByCommentId" | "issueCommentRetryQueuedAt">>,
|
||||
) {
|
||||
return db
|
||||
.update(heartbeatRuns)
|
||||
.set({ ...patch, updatedAt: new Date() })
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function findRunIssueComment(runId: string, companyId: string, issueId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: issueComments.id,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, companyId),
|
||||
eq(issueComments.issueId, issueId),
|
||||
eq(issueComments.createdByRunId, runId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function enqueueMissingIssueCommentRetry(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
agent: typeof agents.$inferSelect,
|
||||
issueId: string,
|
||||
) {
|
||||
const contextSnapshot = parseObject(run.contextSnapshot);
|
||||
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
|
||||
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
|
||||
const retryContextSnapshot = {
|
||||
...contextSnapshot,
|
||||
retryOfRunId: run.id,
|
||||
wakeReason: "missing_issue_comment",
|
||||
retryReason: "missing_issue_comment",
|
||||
missingIssueCommentForRunId: run.id,
|
||||
};
|
||||
const now = new Date();
|
||||
|
||||
const retryRun = await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`,
|
||||
);
|
||||
|
||||
const issue = await tx
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, run.companyId), eq(issues.executionRunId, run.id)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issue) return null;
|
||||
|
||||
const wakeupRequest = await tx
|
||||
.insert(agentWakeupRequests)
|
||||
.values({
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "missing_issue_comment",
|
||||
payload: {
|
||||
issueId,
|
||||
retryOfRunId: run.id,
|
||||
retryReason: "missing_issue_comment",
|
||||
},
|
||||
status: "queued",
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
const queuedRun = await tx
|
||||
.insert(heartbeatRuns)
|
||||
.values({
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "queued",
|
||||
wakeupRequestId: wakeupRequest.id,
|
||||
contextSnapshot: retryContextSnapshot,
|
||||
sessionIdBefore: sessionBefore,
|
||||
retryOfRunId: run.id,
|
||||
issueCommentStatus: "not_applicable",
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
await tx
|
||||
.update(agentWakeupRequests)
|
||||
.set({
|
||||
runId: queuedRun.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequest.id));
|
||||
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
executionRunId: queuedRun.id,
|
||||
executionAgentNameKey: normalizeAgentNameKey(agent.name),
|
||||
executionLockedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(issues.id, issue.id));
|
||||
|
||||
await tx
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
issueCommentStatus: "retry_queued",
|
||||
issueCommentRetryQueuedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
|
||||
return queuedRun;
|
||||
});
|
||||
|
||||
if (!retryRun) return null;
|
||||
|
||||
publishLiveEvent({
|
||||
companyId: retryRun.companyId,
|
||||
type: "heartbeat.run.queued",
|
||||
payload: {
|
||||
runId: retryRun.id,
|
||||
agentId: retryRun.agentId,
|
||||
invocationSource: retryRun.invocationSource,
|
||||
triggerDetail: retryRun.triggerDetail,
|
||||
wakeupRequestId: retryRun.wakeupRequestId,
|
||||
},
|
||||
});
|
||||
|
||||
return retryRun;
|
||||
}
|
||||
|
||||
async function finalizeIssueCommentPolicy(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
agent: typeof agents.$inferSelect,
|
||||
) {
|
||||
const contextSnapshot = parseObject(run.contextSnapshot);
|
||||
const issueId = readNonEmptyString(contextSnapshot.issueId);
|
||||
if (!issueId) {
|
||||
if (run.issueCommentStatus !== "not_applicable") {
|
||||
await patchRunIssueCommentStatus(run.id, {
|
||||
issueCommentStatus: "not_applicable",
|
||||
issueCommentSatisfiedByCommentId: null,
|
||||
issueCommentRetryQueuedAt: null,
|
||||
});
|
||||
}
|
||||
return { outcome: "not_applicable" as const, queuedRun: null };
|
||||
}
|
||||
|
||||
const postedComment = await findRunIssueComment(run.id, run.companyId, issueId);
|
||||
if (postedComment) {
|
||||
await patchRunIssueCommentStatus(run.id, {
|
||||
issueCommentStatus: "satisfied",
|
||||
issueCommentSatisfiedByCommentId: postedComment.id,
|
||||
issueCommentRetryQueuedAt: null,
|
||||
});
|
||||
return { outcome: "satisfied" as const, queuedRun: null };
|
||||
}
|
||||
|
||||
if (readNonEmptyString(contextSnapshot.retryReason) === "missing_issue_comment") {
|
||||
await patchRunIssueCommentStatus(run.id, {
|
||||
issueCommentStatus: "retry_exhausted",
|
||||
issueCommentSatisfiedByCommentId: null,
|
||||
});
|
||||
await appendRunEvent(run, await nextRunEventSeq(run.id), {
|
||||
eventType: "lifecycle",
|
||||
stream: "system",
|
||||
level: "warn",
|
||||
message: "Run ended without an issue comment after one retry; no further comment wake will be queued",
|
||||
});
|
||||
return { outcome: "retry_exhausted" as const, queuedRun: null };
|
||||
}
|
||||
|
||||
const queuedRun = await enqueueMissingIssueCommentRetry(run, agent, issueId);
|
||||
if (queuedRun) {
|
||||
await appendRunEvent(run, await nextRunEventSeq(run.id), {
|
||||
eventType: "lifecycle",
|
||||
stream: "system",
|
||||
level: "warn",
|
||||
message: "Run ended without an issue comment; queued one follow-up wake to require a comment",
|
||||
});
|
||||
return { outcome: "retry_queued" as const, queuedRun };
|
||||
}
|
||||
|
||||
await patchRunIssueCommentStatus(run.id, {
|
||||
issueCommentStatus: "retry_exhausted",
|
||||
issueCommentSatisfiedByCommentId: null,
|
||||
});
|
||||
return { outcome: "retry_exhausted" as const, queuedRun: null };
|
||||
}
|
||||
|
||||
async function enqueueProcessLossRetry(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
agent: typeof agents.$inferSelect,
|
||||
|
|
@ -3085,7 +3289,7 @@ export function heartbeatService(db: Db) {
|
|||
try {
|
||||
const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null);
|
||||
if (issueComment) {
|
||||
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id });
|
||||
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id });
|
||||
}
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
|
|
@ -3094,6 +3298,7 @@ export function heartbeatService(db: Db) {
|
|||
);
|
||||
}
|
||||
}
|
||||
await finalizeIssueCommentPolicy(finalizedRun, agent);
|
||||
await releaseIssueExecutionAndPromote(finalizedRun);
|
||||
}
|
||||
|
||||
|
|
@ -3160,6 +3365,7 @@ export function heartbeatService(db: Db) {
|
|||
level: "error",
|
||||
message,
|
||||
});
|
||||
await finalizeIssueCommentPolicy(failedRun, agent);
|
||||
await releaseIssueExecutionAndPromote(failedRun);
|
||||
|
||||
await updateRuntimeState(agent, failedRun, {
|
||||
|
|
@ -3211,6 +3417,10 @@ export function heartbeatService(db: Db) {
|
|||
level: "error",
|
||||
message,
|
||||
}).catch(() => undefined);
|
||||
const failedAgent = await getAgent(run.agentId).catch(() => null);
|
||||
if (failedAgent) {
|
||||
await finalizeIssueCommentPolicy(failedRun, failedAgent).catch(() => undefined);
|
||||
}
|
||||
await releaseIssueExecutionAndPromote(failedRun).catch(() => undefined);
|
||||
}
|
||||
// Ensure the agent is not left stuck in "running" if the inner catch handler's
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue