From 392bb1ccc143b8eaa7b674ee93eb98a7e13109e2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 5 May 2026 11:53:39 -0500 Subject: [PATCH] Fix recovery handoff PR follow-ups --- cli/src/__tests__/company.test.ts | 2 + packages/plugins/sdk/src/testing.ts | 3 ++ server/src/routes/issues.ts | 46 ++++++++++--------- .../recovery/successful-run-handoff.test.ts | 9 ++++ .../recovery/successful-run-handoff.ts | 7 ++- ui/src/components/SystemNotice.tsx | 3 +- 6 files changed, 46 insertions(+), 24 deletions(-) diff --git a/cli/src/__tests__/company.test.ts b/cli/src/__tests__/company.test.ts index 2e4fcdece28..144b3147dcb 100644 --- a/cli/src/__tests__/company.test.ts +++ b/cli/src/__tests__/company.test.ts @@ -244,6 +244,7 @@ describe("renderCompanyImportPreview", () => { billingCode: null, executionWorkspaceSettings: null, assigneeAdapterOverrides: null, + comments: [], metadata: null, }, ], @@ -460,6 +461,7 @@ describe("import selection catalog", () => { billingCode: null, executionWorkspaceSettings: null, assigneeAdapterOverrides: null, + comments: [], metadata: null, }, ], diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 5ed1c1c4fa9..9cacd356be6 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1258,9 +1258,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { id: randomUUID(), companyId: parentIssue.companyId, issueId, + authorType: options?.authorAgentId ? "agent" : "system", authorAgentId: options?.authorAgentId ?? null, authorUserId: null, body, + presentation: null, + metadata: null, createdAt: now, updatedAt: now, }; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index bffb4e1b96f..51e7e0ec11a 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { Router, type Request, type Response } from "express"; import multer from "multer"; import { z } from "zod"; -import { and, desc, eq, inArray } from "drizzle-orm"; +import { and, desc, eq, inArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, issueExecutionDecisions } from "@paperclipai/db"; import { @@ -116,6 +116,14 @@ type ExecutionStageWakeContext = { lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"]; allowedActions: string[]; }; +type SuccessfulRunHandoffActivityRow = { + entityId: string; + action: string; + agentId: string | null; + runId: string | null; + details: Record | null; + createdAt: Date; +}; const SUCCESSFUL_RUN_HANDOFF_ACTIONS = [ "issue.successful_run_handoff_required", @@ -181,29 +189,25 @@ async function listSuccessfulRunHandoffStates( issueIds: string[], ): Promise> { if (issueIds.length === 0) return new Map(); - const rows = await db - .select({ - entityId: activityLog.entityId, - action: activityLog.action, - agentId: activityLog.agentId, - runId: activityLog.runId, - details: activityLog.details, - createdAt: activityLog.createdAt, - }) - .from(activityLog) - .where( - and( - eq(activityLog.companyId, companyId), - eq(activityLog.entityType, "issue"), - inArray(activityLog.entityId, issueIds), - inArray(activityLog.action, [...SUCCESSFUL_RUN_HANDOFF_ACTIONS]), - ), - ) - .orderBy(desc(activityLog.createdAt)); + const result = await db.execute(sql` + SELECT DISTINCT ON (${activityLog.entityId}) + ${activityLog.entityId} AS "entityId", + ${activityLog.action} AS "action", + ${activityLog.agentId} AS "agentId", + ${activityLog.runId} AS "runId", + ${activityLog.details} AS "details", + ${activityLog.createdAt} AS "createdAt" + FROM ${activityLog} + WHERE ${activityLog.companyId} = ${companyId} + AND ${activityLog.entityType} = 'issue' + AND ${activityLog.entityId} IN (${sql.join(issueIds.map((id) => sql`${id}`), sql`, `)}) + AND ${activityLog.action} IN (${sql.join(SUCCESSFUL_RUN_HANDOFF_ACTIONS.map((action) => sql`${action}`), sql`, `)}) + ORDER BY ${activityLog.entityId}, ${activityLog.createdAt} DESC, ${activityLog.id} DESC + `); + const rows = Array.from(result as Iterable); const states = new Map(); for (const row of rows) { - if (states.has(row.entityId)) continue; const state = successfulRunHandoffStateFromActivity(row); if (state) states.set(row.entityId, state); } diff --git a/server/src/services/recovery/successful-run-handoff.test.ts b/server/src/services/recovery/successful-run-handoff.test.ts index 3ab3da8e1cd..b5e25b74516 100644 --- a/server/src/services/recovery/successful-run-handoff.test.ts +++ b/server/src/services/recovery/successful-run-handoff.test.ts @@ -8,6 +8,7 @@ import { buildSuccessfulRunHandoffExhaustedNotice, buildSuccessfulRunHandoffRequiredNotice, decideSuccessfulRunHandoff, + isIdempotentFinishSuccessfulRunHandoffWakeStatus, isSuccessfulRunHandoffRequiredNoticeBody, } from "./successful-run-handoff.js"; @@ -183,6 +184,14 @@ describe("successful run handoff decision", () => { })).toBe("finish_successful_run_handoff:issue-1:run-1:1"); }); + it("allows failed or cancelled corrective wakes to be retried", () => { + expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("queued")).toBe(true); + expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("claimed")).toBe(true); + expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("completed")).toBe(true); + expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("failed")).toBe(false); + expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("cancelled")).toBe(false); + }); + it("builds the required system notice with hidden structured metadata", () => { const notice = buildSuccessfulRunHandoffRequiredNotice({ issue: { diff --git a/server/src/services/recovery/successful-run-handoff.ts b/server/src/services/recovery/successful-run-handoff.ts index 238d9e249ea..2d5b79ff0e3 100644 --- a/server/src/services/recovery/successful-run-handoff.ts +++ b/server/src/services/recovery/successful-run-handoff.ts @@ -35,9 +35,12 @@ const IDEMPOTENT_HANDOFF_WAKE_STATUSES = [ "deferred_issue_execution", "claimed", "completed", - "failed", - "cancelled", ]; +const IDEMPOTENT_HANDOFF_WAKE_STATUS_SET = new Set(IDEMPOTENT_HANDOFF_WAKE_STATUSES); + +export function isIdempotentFinishSuccessfulRunHandoffWakeStatus(status: string) { + return IDEMPOTENT_HANDOFF_WAKE_STATUS_SET.has(status); +} type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect; type IssueRow = Pick< diff --git a/ui/src/components/SystemNotice.tsx b/ui/src/components/SystemNotice.tsx index 5f632146d23..093c5667b7a 100644 --- a/ui/src/components/SystemNotice.tsx +++ b/ui/src/components/SystemNotice.tsx @@ -2,6 +2,7 @@ import { useId, useState, type ReactNode } from "react"; import { ChevronDown, CircleAlert, + CircleCheck, Info, OctagonAlert, TriangleAlert, @@ -72,7 +73,7 @@ const TONE_TOKENS: Record = { container: "border-emerald-300/70 bg-emerald-50/70 dark:border-emerald-500/30 dark:bg-emerald-500/10", iconWrap: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200", - icon: CircleAlert, + icon: CircleCheck, iconClass: "text-emerald-700 dark:text-emerald-300", label: "text-emerald-800 dark:text-emerald-200", divider: "border-emerald-300/50 dark:border-emerald-500/30",