diff --git a/docs/assets/pr-5426/scheduled-retry-story-desktop.png b/docs/assets/pr-5426/scheduled-retry-story-desktop.png new file mode 100644 index 00000000..3f0c875d Binary files /dev/null and b/docs/assets/pr-5426/scheduled-retry-story-desktop.png differ diff --git a/docs/assets/pr-5426/scheduled-retry-story-mobile.png b/docs/assets/pr-5426/scheduled-retry-story-mobile.png new file mode 100644 index 00000000..97b02b7d Binary files /dev/null and b/docs/assets/pr-5426/scheduled-retry-story-mobile.png differ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4c06b736..7872de73 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -371,6 +371,10 @@ export type { IssueProductivityReviewTrigger, SuccessfulRunHandoffState, SuccessfulRunHandoffStateKind, + IssueScheduledRetry, + IssueScheduledRetryStatus, + IssueRetryNowOutcome, + IssueRetryNowResponse, IssueReferenceSource, IssueRelatedWorkItem, IssueRelatedWorkSummary, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index c0c0f1f4..ddc8162d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -153,6 +153,10 @@ export type { IssueProductivityReviewTrigger, SuccessfulRunHandoffState, SuccessfulRunHandoffStateKind, + IssueScheduledRetry, + IssueScheduledRetryStatus, + IssueRetryNowOutcome, + IssueRetryNowResponse, IssueReferenceSource, IssueRelatedWorkItem, IssueRelatedWorkSummary, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 2f09515a..827c259f 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -181,6 +181,34 @@ export interface SuccessfulRunHandoffState { createdAt: Date | string | null; } +export type IssueScheduledRetryStatus = "scheduled_retry" | "queued" | "running" | "cancelled"; + +export interface IssueScheduledRetry { + runId: string; + status: IssueScheduledRetryStatus; + agentId: string; + agentName: string | null; + retryOfRunId: string | null; + scheduledRetryAt: Date | string | null; + scheduledRetryAttempt: number; + scheduledRetryReason: string | null; + retryExhaustedReason?: string | null; + error?: string | null; + errorCode?: string | null; +} + +export type IssueRetryNowOutcome = + | "promoted" + | "already_promoted" + | "no_scheduled_retry" + | "gate_suppressed"; + +export interface IssueRetryNowResponse { + outcome: IssueRetryNowOutcome; + message: string; + scheduledRetry: IssueScheduledRetry | null; +} + export interface IssueRelation { id: string; companyId: string; @@ -345,6 +373,7 @@ export interface Issue { blockerAttention?: IssueBlockerAttention; productivityReview?: IssueProductivityReview | null; successfulRunHandoff?: SuccessfulRunHandoffState | null; + scheduledRetry?: IssueScheduledRetry | null; relatedWork?: IssueRelatedWorkSummary; referencedIssueIdentifiers?: string[]; planDocument?: IssueDocument | null; diff --git a/server/src/__tests__/issue-scheduled-retry-routes.test.ts b/server/src/__tests__/issue-scheduled-retry-routes.test.ts new file mode 100644 index 00000000..527500af --- /dev/null +++ b/server/src/__tests__/issue-scheduled-retry-routes.test.ts @@ -0,0 +1,518 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { and, eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + agentWakeupRequests, + companies, + createDb, + heartbeatRunEvents, + heartbeatRuns, + issueComments, + issueRelations, + issueTreeHolds, + issues, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres scheduled retry route tests on this host: ${ + embeddedPostgresSupport.reason ?? "unsupported environment" + }`, + ); +} + +describeEmbeddedPostgres("issue scheduled retry routes", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-scheduled-retry-routes-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(issueRelations); + await db.delete(issueTreeHolds); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(heartbeatRunEvents); + await db.delete(heartbeatRuns); + await db.delete(agentWakeupRequests); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createApp(actor: Express.Request["actor"]) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", issueRoutes(db, {} as any)); + app.use(errorHandler); + return app; + } + + function boardActor(companyId: string): Express.Request["actor"] { + return { + type: "board", + userId: "board-user", + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "admin", status: "active" }], + isInstanceAdmin: false, + source: "session", + }; + } + + function agentActor(companyId: string, agentId: string): Express.Request["actor"] { + return { + type: "agent", + agentId, + companyId, + runId: randomUUID(), + source: "agent_jwt", + }; + } + + async function seedIssueWithRetry(input: { + agentStatus?: "active" | "paused"; + retryStatus?: "scheduled_retry" | "queued" | "running"; + issueStatus?: "in_progress" | "todo" | "done" | "cancelled"; + } = {}) { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const sourceRunId = randomUUID(); + const retryRunId = randomUUID(); + const wakeupRequestId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const now = new Date("2026-05-06T18:00:00.000Z"); + const scheduledRetryAt = new Date("2026-05-06T19:00:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: input.agentStatus ?? "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + }); + await db.insert(heartbeatRuns).values({ + id: sourceRunId, + companyId, + agentId, + invocationSource: "assignment", + triggerDetail: "system", + status: "failed", + error: "transient upstream error", + errorCode: "adapter_failed", + finishedAt: now, + contextSnapshot: { + issueId, + wakeReason: "issue_assigned", + }, + updatedAt: now, + createdAt: now, + }); + await db.insert(agentWakeupRequests).values({ + id: wakeupRequestId, + companyId, + agentId, + source: "automation", + triggerDetail: "system", + reason: "bounded_transient_heartbeat_retry", + payload: { + issueId, + retryOfRunId: sourceRunId, + scheduledRetryAt: scheduledRetryAt.toISOString(), + }, + status: "queued", + }); + await db.insert(heartbeatRuns).values({ + id: retryRunId, + companyId, + agentId, + invocationSource: "automation", + triggerDetail: "system", + status: input.retryStatus ?? "scheduled_retry", + wakeupRequestId, + retryOfRunId: sourceRunId, + scheduledRetryAt, + scheduledRetryAttempt: 2, + scheduledRetryReason: "transient_failure", + contextSnapshot: { + issueId, + wakeReason: "bounded_transient_heartbeat_retry", + retryOfRunId: sourceRunId, + scheduledRetryAt: scheduledRetryAt.toISOString(), + scheduledRetryAttempt: 2, + retryReason: "transient_failure", + }, + updatedAt: now, + createdAt: now, + }); + await db + .update(agentWakeupRequests) + .set({ runId: retryRunId }) + .where(eq(agentWakeupRequests.id, wakeupRequestId)); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Retryable issue", + status: input.issueStatus ?? "in_progress", + priority: "medium", + assigneeAgentId: agentId, + executionRunId: retryRunId, + executionAgentNameKey: "codexcoder", + executionLockedAt: now, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + + return { companyId, agentId, issueId, sourceRunId, retryRunId, scheduledRetryAt }; + } + + it("surfaces the current scheduled retry in the issue read model", async () => { + const { companyId, issueId, agentId, sourceRunId, retryRunId, scheduledRetryAt } = await seedIssueWithRetry(); + + const res = await request(createApp(boardActor(companyId))).get(`/api/issues/${issueId}`); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.scheduledRetry).toMatchObject({ + runId: retryRunId, + status: "scheduled_retry", + agentId, + agentName: "CodexCoder", + retryOfRunId: sourceRunId, + scheduledRetryAttempt: 2, + scheduledRetryReason: "transient_failure", + }); + expect(res.body.scheduledRetry.scheduledRetryAt).toBe(scheduledRetryAt.toISOString()); + }); + + it("promotes the existing scheduled retry and treats duplicate clicks as idempotent", async () => { + const { companyId, issueId, retryRunId } = await seedIssueWithRetry(); + const app = createApp(boardActor(companyId)); + + const first = await request(app).post(`/api/issues/${issueId}/scheduled-retry/retry-now`).send({}); + + expect(first.status, JSON.stringify(first.body)).toBe(200); + expect(first.body).toMatchObject({ + outcome: "promoted", + scheduledRetry: { + runId: retryRunId, + status: "queued", + }, + }); + + const second = await request(app).post(`/api/issues/${issueId}/scheduled-retry/retry-now`).send({}); + + expect(second.status, JSON.stringify(second.body)).toBe(200); + expect(second.body).toMatchObject({ + outcome: "already_promoted", + scheduledRetry: { + runId: retryRunId, + status: "queued", + }, + }); + + const retryRuns = await db + .select({ id: heartbeatRuns.id, status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.retryOfRunId, first.body.scheduledRetry.retryOfRunId), eq(heartbeatRuns.companyId, companyId))); + expect(retryRuns).toHaveLength(1); + expect(retryRuns[0]).toMatchObject({ id: retryRunId, status: "queued" }); + }); + + it("returns a clear no-op response when there is no scheduled retry", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "NONE", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "No retry", + status: "todo", + priority: "medium", + issueNumber: 1, + identifier: "NONE-1", + }); + + const res = await request(createApp(boardActor(companyId))) + .post(`/api/issues/${issueId}/scheduled-retry/retry-now`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ + outcome: "no_scheduled_retry", + scheduledRetry: null, + }); + }); + + it("reports already-promoted retries without creating another run", async () => { + const { companyId, issueId, retryRunId } = await seedIssueWithRetry({ retryStatus: "queued" }); + + const res = await request(createApp(boardActor(companyId))) + .post(`/api/issues/${issueId}/scheduled-retry/retry-now`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ + outcome: "already_promoted", + scheduledRetry: { + runId: retryRunId, + status: "queued", + }, + }); + }); + + it("uses normal promotion gates and records gate-suppressed retries", async () => { + const { companyId, issueId, retryRunId } = await seedIssueWithRetry({ agentStatus: "paused" }); + + const res = await request(createApp(boardActor(companyId))) + .post(`/api/issues/${issueId}/scheduled-retry/retry-now`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ + outcome: "gate_suppressed", + scheduledRetry: { + runId: retryRunId, + status: "cancelled", + errorCode: "agent_not_invokable", + }, + }); + + const [run] = await db + .select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, retryRunId)); + expect(run).toEqual({ status: "cancelled", errorCode: "agent_not_invokable" }); + + const [activity] = await db + .select({ action: activityLog.action, entityId: activityLog.entityId, runId: activityLog.runId }) + .from(activityLog) + .where(eq(activityLog.entityId, issueId)); + expect(activity).toEqual({ + action: "issue.scheduled_retry_retry_now", + entityId: issueId, + runId: retryRunId, + }); + }); + + it("requires board access for retry-now", async () => { + const { companyId, agentId, issueId } = await seedIssueWithRetry(); + + const res = await request(createApp(agentActor(companyId, agentId))) + .post(`/api/issues/${issueId}/scheduled-retry/retry-now`) + .send({}); + + expect(res.status).toBe(403); + }); + + it("enforces company scoping for retry-now", async () => { + const { issueId } = await seedIssueWithRetry(); + + const res = await request(createApp(boardActor(randomUUID()))) + .post(`/api/issues/${issueId}/scheduled-retry/retry-now`) + .send({}); + + expect(res.status).toBe(403); + }); + + it("suppresses retry-now when the issue is under a budget hard-stop", async () => { + const { companyId, agentId, issueId, retryRunId } = await seedIssueWithRetry(); + await db + .update(agents) + .set({ status: "paused", pauseReason: "budget" }) + .where(eq(agents.id, agentId)); + + const res = await request(createApp(boardActor(companyId))) + .post(`/api/issues/${issueId}/scheduled-retry/retry-now`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ + outcome: "gate_suppressed", + scheduledRetry: { + runId: retryRunId, + status: "cancelled", + errorCode: "budget_blocked", + }, + }); + }); + + it("suppresses retry-now when the issue is waiting on another review participant", async () => { + const { companyId, agentId, issueId, retryRunId } = await seedIssueWithRetry({ issueStatus: "in_progress" }); + const reviewerAgentId = randomUUID(); + await db.insert(agents).values({ + id: reviewerAgentId, + companyId, + name: "ReviewerAgent", + role: "qa", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + }); + await db + .update(issues) + .set({ + status: "in_review", + executionState: { + status: "pending", + currentStageId: randomUUID(), + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: reviewerAgentId, userId: null }, + returnAssignee: { type: "agent", agentId, userId: null }, + reviewRequest: null, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, + }) + .where(eq(issues.id, issueId)); + + const res = await request(createApp(boardActor(companyId))) + .post(`/api/issues/${issueId}/scheduled-retry/retry-now`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ + outcome: "gate_suppressed", + scheduledRetry: { + runId: retryRunId, + status: "cancelled", + errorCode: "issue_review_participant_changed", + }, + }); + }); + + it("suppresses retry-now when the issue is under an active subtree pause hold", async () => { + const { companyId, issueId, retryRunId } = await seedIssueWithRetry(); + await db.insert(issueTreeHolds).values({ + companyId, + rootIssueId: issueId, + mode: "pause", + status: "active", + reason: "manual pause for review", + releasePolicy: { strategy: "manual" }, + }); + + const res = await request(createApp(boardActor(companyId))) + .post(`/api/issues/${issueId}/scheduled-retry/retry-now`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ + outcome: "gate_suppressed", + scheduledRetry: { + runId: retryRunId, + status: "cancelled", + errorCode: "issue_paused", + }, + }); + }); + + it("suppresses retry-now when unresolved blockers remain", async () => { + const { companyId, issueId, retryRunId } = await seedIssueWithRetry(); + const blockerId = randomUUID(); + await db.insert(issues).values({ + id: blockerId, + companyId, + title: "Blocking task", + status: "todo", + priority: "medium", + issueNumber: 2, + identifier: "BLOCK-2", + }); + await db.insert(issueRelations).values({ + id: randomUUID(), + companyId, + issueId: blockerId, + relatedIssueId: issueId, + type: "blocks", + }); + await db + .update(issues) + .set({ status: "blocked" }) + .where(eq(issues.id, issueId)); + + const res = await request(createApp(boardActor(companyId))) + .post(`/api/issues/${issueId}/scheduled-retry/retry-now`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ + outcome: "gate_suppressed", + scheduledRetry: { + runId: retryRunId, + status: "cancelled", + errorCode: "issue_dependencies_blocked", + }, + }); + }); + + it("suppresses retry-now when the issue already reached a terminal status", async () => { + const { companyId, issueId, retryRunId } = await seedIssueWithRetry({ issueStatus: "done" }); + + const res = await request(createApp(boardActor(companyId))) + .post(`/api/issues/${issueId}/scheduled-retry/retry-now`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ + outcome: "gate_suppressed", + scheduledRetry: { + runId: retryRunId, + status: "cancelled", + errorCode: "issue_terminal_status", + }, + }); + }); +}); diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index 4e90e0ad..504714ab 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -13,6 +13,7 @@ const mockIssueService = vi.hoisted(() => ({ getComment: vi.fn(), listBlockerAttention: vi.fn(), listProductivityReviews: vi.fn(), + getCurrentScheduledRetry: vi.fn(), listAttachments: vi.fn(), })); @@ -188,6 +189,7 @@ describe.sequential("issue goal context routes", () => { mockIssueService.getComment.mockResolvedValue(null); mockIssueService.listBlockerAttention.mockResolvedValue(new Map()); mockIssueService.listProductivityReviews.mockResolvedValue(new Map()); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null); mockIssueService.listAttachments.mockResolvedValue([]); mockDocumentsService.getIssueDocumentPayload.mockResolvedValue({}); mockDocumentsService.getIssueDocumentByKey.mockResolvedValue(null); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 4e568c15..69aada8f 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1498,6 +1498,7 @@ export function issueRoutes( relations, blockerAttention, productivityReview, + scheduledRetry, attachments, continuationSummary, currentExecutionWorkspace, @@ -1510,6 +1511,7 @@ export function issueRoutes( svc.getRelationSummaries(issue.id), svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null), svc.listProductivityReviews(issue.companyId, [issue.id]).then((map) => map.get(issue.id) ?? null), + svc.getCurrentScheduledRetry(issue.id), svc.listAttachments(issue.id), documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY), currentExecutionWorkspacePromise, @@ -1525,6 +1527,7 @@ export function issueRoutes( workMode: issue.workMode, ...(blockerAttention ? { blockerAttention } : {}), productivityReview, + scheduledRetry, priority: issue.priority, projectId: issue.projectId, goalId: goal?.id ?? issue.goalId, @@ -1606,6 +1609,7 @@ export function issueRoutes( productivityReview, referenceSummary, successfulRunHandoffStates, + scheduledRetry, ] = await Promise.all([ resolveIssueProjectAndGoal(issue), svc.getAncestors(issue.id), @@ -1616,6 +1620,7 @@ export function issueRoutes( svc.listProductivityReviews(issue.companyId, [issue.id]).then((map) => map.get(issue.id) ?? null), issueReferencesSvc.listIssueReferenceSummary(issue.id), listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id]), + svc.getCurrentScheduledRetry(issue.id), ]); const mentionedProjects = mentionedProjectIds.length > 0 ? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds) @@ -1631,6 +1636,7 @@ export function issueRoutes( ...(blockerAttention ? { blockerAttention } : {}), productivityReview, successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null, + scheduledRetry, blockedBy: relations.blockedBy, blocks: relations.blocks, relatedWork: referenceSummary, @@ -2438,6 +2444,44 @@ export function issueRoutes( res.json({ ok: true }); }); + router.post("/issues/:id/scheduled-retry/retry-now", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + + const actor = getActorInfo(req); + const result = await heartbeat.retryScheduledRetryNow({ + issueId: issue.id, + actor: { + actorType: actor.actorType, + actorId: actor.actorId, + }, + }); + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + action: "issue.scheduled_retry_retry_now", + entityType: "issue", + entityId: issue.id, + agentId: result.scheduledRetry?.agentId ?? issue.assigneeAgentId ?? null, + runId: result.scheduledRetry?.runId ?? null, + details: { + outcome: result.outcome, + message: result.message, + scheduledRetry: result.scheduledRetry, + }, + }); + + res.json(result); + }); + router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 427f91cd..dc719cb6 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4712,6 +4712,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) issueId: string | null; details: Record; }; + type BlockedScheduledRetryGate = Extract; async function evaluateScheduledRetryGate(input: { run: typeof heartbeatRuns.$inferSelect; @@ -4960,6 +4961,111 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return cancelled; } + async function promoteScheduledRetryRun( + dueRun: typeof heartbeatRuns.$inferSelect, + now: Date, + ): Promise< + | { outcome: "promoted"; run: typeof heartbeatRuns.$inferSelect } + | { + outcome: "gate_suppressed"; + run: typeof heartbeatRuns.$inferSelect; + reason: string; + errorCode: BlockedScheduledRetryGate["errorCode"]; + } + | { outcome: "not_promoted"; run: typeof heartbeatRuns.$inferSelect | null } + > { + const agent = await getAgent(dueRun.agentId); + if (!agent) { + const gate = { + allowed: false as const, + reason: "Scheduled retry suppressed because the agent no longer exists", + errorCode: "agent_not_invokable" as const, + issueId: readNonEmptyString(parseObject(dueRun.contextSnapshot).issueId), + details: { agentId: dueRun.agentId }, + }; + const cancelled = await cancelScheduledRetryForGate(dueRun, gate, now); + return cancelled + ? { + outcome: "gate_suppressed", + run: cancelled, + reason: gate.reason, + errorCode: gate.errorCode, + } + : { outcome: "not_promoted", run: null }; + } + + const contextSnapshot = parseObject(dueRun.contextSnapshot); + const gate = await evaluateScheduledRetryGate({ + run: dueRun, + agent, + contextSnapshot, + retryReason: dueRun.scheduledRetryReason, + enforceIssueExecutionLock: dueRun.scheduledRetryReason === MAX_TURN_CONTINUATION_RETRY_REASON, + }); + if (!gate.allowed) { + if ( + gate.errorCode === "issue_not_found" && + dueRun.scheduledRetryReason !== MAX_TURN_CONTINUATION_RETRY_REASON + ) { + // Preserve legacy transient retry behavior for runs that only carry a + // loose task context rather than a persisted issue row. + } else { + const cancelled = await cancelScheduledRetryForGate(dueRun, gate, now); + return cancelled + ? { + outcome: "gate_suppressed", + run: cancelled, + reason: gate.reason, + errorCode: gate.errorCode, + } + : { outcome: "not_promoted", run: null }; + } + } + + const promoted = await db + .update(heartbeatRuns) + .set({ + status: "queued", + updatedAt: now, + }) + .where( + and( + eq(heartbeatRuns.id, dueRun.id), + eq(heartbeatRuns.status, "scheduled_retry"), + lte(heartbeatRuns.scheduledRetryAt, now), + ), + ) + .returning() + .then((rows) => rows[0] ?? null); + if (!promoted) return { outcome: "not_promoted", run: null }; + + await appendRunEvent(promoted, await nextRunEventSeq(promoted.id), { + eventType: "lifecycle", + stream: "system", + level: "info", + message: "Scheduled retry became due and was promoted to the queued run pool", + payload: { + scheduledRetryAttempt: promoted.scheduledRetryAttempt, + scheduledRetryAt: promoted.scheduledRetryAt ? new Date(promoted.scheduledRetryAt).toISOString() : null, + scheduledRetryReason: promoted.scheduledRetryReason, + }, + }); + + publishLiveEvent({ + companyId: promoted.companyId, + type: "heartbeat.run.queued", + payload: { + runId: promoted.id, + agentId: promoted.agentId, + invocationSource: promoted.invocationSource, + triggerDetail: promoted.triggerDetail, + wakeupRequestId: promoted.wakeupRequestId, + }, + }); + + return { outcome: "promoted", run: promoted }; + } + async function scheduleBoundedRetryForRun( run: typeof heartbeatRuns.$inferSelect, agent: typeof agents.$inferSelect, @@ -5384,81 +5490,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const promotedRunIds: string[] = []; for (const dueRun of dueRuns) { - const agent = await getAgent(dueRun.agentId); - if (!agent) { - await cancelScheduledRetryForGate(dueRun, { - allowed: false, - reason: "Scheduled retry suppressed because the agent no longer exists", - errorCode: "agent_not_invokable", - issueId: readNonEmptyString(parseObject(dueRun.contextSnapshot).issueId), - details: { agentId: dueRun.agentId }, - }, now); - continue; + const result = await promoteScheduledRetryRun(dueRun, now); + if (result.outcome === "promoted") { + promotedRunIds.push(result.run.id); } - - const contextSnapshot = parseObject(dueRun.contextSnapshot); - const gate = await evaluateScheduledRetryGate({ - run: dueRun, - agent, - contextSnapshot, - retryReason: dueRun.scheduledRetryReason, - enforceIssueExecutionLock: dueRun.scheduledRetryReason === MAX_TURN_CONTINUATION_RETRY_REASON, - }); - if (!gate.allowed) { - if ( - gate.errorCode === "issue_not_found" && - dueRun.scheduledRetryReason !== MAX_TURN_CONTINUATION_RETRY_REASON - ) { - // Preserve legacy transient retry behavior for runs that only carry a - // loose task context rather than a persisted issue row. - } else { - await cancelScheduledRetryForGate(dueRun, gate, now); - continue; - } - } - - const promoted = await db - .update(heartbeatRuns) - .set({ - status: "queued", - updatedAt: now, - }) - .where( - and( - eq(heartbeatRuns.id, dueRun.id), - eq(heartbeatRuns.status, "scheduled_retry"), - lte(heartbeatRuns.scheduledRetryAt, now), - ), - ) - .returning() - .then((rows) => rows[0] ?? null); - if (!promoted) continue; - - promotedRunIds.push(promoted.id); - - await appendRunEvent(promoted, await nextRunEventSeq(promoted.id), { - eventType: "lifecycle", - stream: "system", - level: "info", - message: "Scheduled retry became due and was promoted to the queued run pool", - payload: { - scheduledRetryAttempt: promoted.scheduledRetryAttempt, - scheduledRetryAt: promoted.scheduledRetryAt ? new Date(promoted.scheduledRetryAt).toISOString() : null, - scheduledRetryReason: promoted.scheduledRetryReason, - }, - }); - - publishLiveEvent({ - companyId: promoted.companyId, - type: "heartbeat.run.queued", - payload: { - runId: promoted.id, - agentId: promoted.agentId, - invocationSource: promoted.invocationSource, - triggerDetail: promoted.triggerDetail, - wakeupRequestId: promoted.wakeupRequestId, - }, - }); } return { @@ -5467,6 +5502,182 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }; } + async function getIssueRetryRun( + companyId: string, + issueId: string, + statuses: Array<"scheduled_retry" | "queued" | "running" | "cancelled">, + ) { + if (statuses.length === 0) return null; + return db + .select({ + run: heartbeatRuns, + agentName: agents.name, + }) + .from(heartbeatRuns) + .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) + .where( + and( + eq(heartbeatRuns.companyId, companyId), + inArray(heartbeatRuns.status, statuses), + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, + sql`${heartbeatRuns.retryOfRunId} is not null`, + ), + ) + .orderBy(desc(heartbeatRuns.updatedAt), desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + } + + function summarizeIssueScheduledRetryRun( + row: { run: typeof heartbeatRuns.$inferSelect; agentName: string | null }, + ) { + return { + runId: row.run.id, + status: row.run.status as "scheduled_retry" | "queued" | "running" | "cancelled", + agentId: row.run.agentId, + agentName: row.agentName, + retryOfRunId: row.run.retryOfRunId, + scheduledRetryAt: row.run.scheduledRetryAt, + scheduledRetryAttempt: row.run.scheduledRetryAttempt, + scheduledRetryReason: row.run.scheduledRetryReason, + error: row.run.error, + errorCode: row.run.errorCode, + }; + } + + async function retryScheduledRetryNow(input: { + issueId: string; + actor?: { actorType?: "user" | "agent" | "system"; actorId?: string | null }; + now?: Date; + }) { + const now = input.now ?? new Date(); + const issue = await db + .select({ id: issues.id, companyId: issues.companyId }) + .from(issues) + .where(eq(issues.id, input.issueId)) + .then((rows) => rows[0] ?? null); + if (!issue) throw notFound("Issue not found"); + + const scheduled = await getIssueRetryRun(issue.companyId, issue.id, ["scheduled_retry"]); + if (!scheduled) { + const alreadyPromoted = await getIssueRetryRun(issue.companyId, issue.id, ["queued", "running"]); + if (alreadyPromoted) { + return { + outcome: "already_promoted" as const, + message: "Scheduled retry was already promoted", + scheduledRetry: summarizeIssueScheduledRetryRun(alreadyPromoted), + }; + } + return { + outcome: "no_scheduled_retry" as const, + message: "No live scheduled retry exists for this issue", + scheduledRetry: null, + }; + } + + const contextSnapshot = { + ...parseObject(scheduled.run.contextSnapshot), + scheduledRetryAt: now.toISOString(), + retryNowRequestedAt: now.toISOString(), + retryNowRequestedByActorType: input.actor?.actorType ?? null, + retryNowRequestedByActorId: input.actor?.actorId ?? null, + }; + + const updated = await db.transaction(async (tx) => { + const row = await tx + .update(heartbeatRuns) + .set({ + scheduledRetryAt: now, + contextSnapshot, + updatedAt: now, + }) + .where(and(eq(heartbeatRuns.id, scheduled.run.id), eq(heartbeatRuns.status, "scheduled_retry"))) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) return null; + + if (row.wakeupRequestId) { + const wakeupPayload = { + ...(parseObject( + await tx + .select({ payload: agentWakeupRequests.payload }) + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.id, row.wakeupRequestId)) + .then((rows) => rows[0]?.payload ?? null), + )), + scheduledRetryAt: now.toISOString(), + retryNowRequestedAt: now.toISOString(), + }; + await tx + .update(agentWakeupRequests) + .set({ + payload: wakeupPayload, + updatedAt: now, + }) + .where(eq(agentWakeupRequests.id, row.wakeupRequestId)); + } + + return row; + }); + + if (!updated) { + const alreadyPromoted = await getIssueRetryRun(issue.companyId, issue.id, ["queued", "running"]); + if (alreadyPromoted) { + return { + outcome: "already_promoted" as const, + message: "Scheduled retry was already promoted", + scheduledRetry: summarizeIssueScheduledRetryRun(alreadyPromoted), + }; + } + return { + outcome: "no_scheduled_retry" as const, + message: "No live scheduled retry exists for this issue", + scheduledRetry: null, + }; + } + + await appendRunEvent(updated, await nextRunEventSeq(updated.id), { + eventType: "lifecycle", + stream: "system", + level: "info", + message: "Scheduled retry was requested to run now", + payload: { + issueId: issue.id, + scheduledRetryAttempt: updated.scheduledRetryAttempt, + scheduledRetryAt: updated.scheduledRetryAt ? new Date(updated.scheduledRetryAt).toISOString() : null, + scheduledRetryReason: updated.scheduledRetryReason, + requestedByActorType: input.actor?.actorType ?? null, + requestedByActorId: input.actor?.actorId ?? null, + }, + }); + + const promotion = await promoteScheduledRetryRun(updated, now); + const promotedRow = await getIssueRetryRun(issue.companyId, issue.id, ["queued", "running", "cancelled"]); + const scheduledRetry = promotedRow + ? summarizeIssueScheduledRetryRun(promotedRow) + : summarizeIssueScheduledRetryRun({ run: promotion.run ?? updated, agentName: scheduled.agentName }); + + if (promotion.outcome === "promoted") { + return { + outcome: "promoted" as const, + message: "Scheduled retry was promoted to the queued run pool", + scheduledRetry, + }; + } + if (promotion.outcome === "gate_suppressed") { + return { + outcome: "gate_suppressed" as const, + message: promotion.reason, + scheduledRetry, + }; + } + return { + outcome: "already_promoted" as const, + message: "Scheduled retry was already promoted", + scheduledRetry, + }; + } + function parseHeartbeatPolicy(agent: typeof agents.$inferSelect) { const runtimeConfig = parseObject(agent.runtimeConfig); const heartbeat = parseObject(runtimeConfig.heartbeat); @@ -9383,6 +9594,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) reapOrphanedRuns, promoteDueScheduledRetries, + retryScheduledRetryNow, resumeQueuedRuns, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 332fe924..8229b1d6 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -156,6 +156,19 @@ type IssueActiveRunRow = { finishedAt: Date | null; createdAt: Date; }; +type IssueScheduledRetryRow = { + runId: string; + status: "scheduled_retry" | "queued" | "running" | "cancelled"; + agentId: string; + agentName: string | null; + retryOfRunId: string | null; + scheduledRetryAt: Date | null; + scheduledRetryAttempt: number; + scheduledRetryReason: string | null; + retryExhaustedReason?: string | null; + error?: string | null; + errorCode?: string | null; +}; type IssueWithLabels = IssueRow & { labels: IssueLabelRow[]; labelIds: string[] }; type IssueWithLabelsAndRun = IssueWithLabels & { activeRun: IssueActiveRunRow | null }; type IssueUserCommentStats = { @@ -1686,6 +1699,36 @@ export function issueService(db: Db) { return enriched; } + async function getCurrentScheduledRetryForIssue(issueId: string, companyId: string): Promise { + const row = await db + .select({ + runId: heartbeatRuns.id, + status: heartbeatRuns.status, + agentId: heartbeatRuns.agentId, + agentName: agents.name, + retryOfRunId: heartbeatRuns.retryOfRunId, + scheduledRetryAt: heartbeatRuns.scheduledRetryAt, + scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt, + scheduledRetryReason: heartbeatRuns.scheduledRetryReason, + error: heartbeatRuns.error, + errorCode: heartbeatRuns.errorCode, + }) + .from(heartbeatRuns) + .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) + .where( + and( + eq(heartbeatRuns.companyId, companyId), + eq(heartbeatRuns.status, "scheduled_retry"), + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, + ), + ) + .orderBy(asc(heartbeatRuns.scheduledRetryAt), asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + + return row ? { ...row, status: "scheduled_retry" } : null; + } + function deriveIssueCommentAuthorType(comment: { authorType?: string | null; authorAgentId?: string | null; @@ -2502,6 +2545,16 @@ export function issueService(db: Db) { return getIssueByIdentifier(identifier); }, + getCurrentScheduledRetry: async (issueId: string) => { + const issue = await db + .select({ id: issues.id, companyId: issues.companyId }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + if (!issue) throw notFound("Issue not found"); + return getCurrentScheduledRetryForIssue(issue.id, issue.companyId); + }, + getRelationSummaries: async (issueId: string) => { const issue = await db .select({ id: issues.id, companyId: issues.companyId }) diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 3ed54a88..10427c50 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -12,6 +12,7 @@ import type { IssueComment, IssueDocument, IssueLabel, + IssueRetryNowResponse, IssueThreadInteraction, IssueTreeControlPreview, IssueTreeHold, @@ -129,6 +130,8 @@ export const issuesApi = { releaseTreeHold: (id: string, holdId: string, data: ReleaseIssueTreeHold) => api.post(`/issues/${id}/tree-holds/${holdId}/release`, data), checkMonitorNow: (id: string) => api.post<{ ok: true }>(`/issues/${id}/monitor/check-now`, {}), + retryScheduledRetryNow: (id: string) => + api.post(`/issues/${id}/scheduled-retry/retry-now`, {}), remove: (id: string) => api.delete(`/issues/${id}`), checkout: (id: string, agentId: string) => api.post(`/issues/${id}/checkout`, { diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 41f103b2..936c168c 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -18,6 +18,8 @@ import { IssueProperties } from "./IssueProperties"; const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn(), + adapterModels: vi.fn(), + adapterModelProfiles: vi.fn(), })); const mockProjectsApi = vi.hoisted(() => ({ @@ -34,10 +36,6 @@ const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn(), })); -const mockInstanceSettingsApi = vi.hoisted(() => ({ - getExperimental: vi.fn(), -})); - vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", @@ -60,8 +58,8 @@ vi.mock("../api/auth", () => ({ authApi: mockAuthApi, })); -vi.mock("../api/instanceSettings", () => ({ - instanceSettingsApi: mockInstanceSettingsApi, +vi.mock("../context/ToastContext", () => ({ + useToastActions: () => ({ pushToast: vi.fn() }), })); vi.mock("../hooks/useProjectOrder", () => ({ @@ -353,6 +351,8 @@ describe("IssueProperties", () => { container = document.createElement("div"); document.body.appendChild(container); mockAgentsApi.list.mockResolvedValue([]); + mockAgentsApi.adapterModels.mockResolvedValue([]); + mockAgentsApi.adapterModelProfiles.mockResolvedValue([]); mockProjectsApi.list.mockResolvedValue([]); mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.listLabels.mockResolvedValue([]); @@ -362,7 +362,6 @@ describe("IssueProperties", () => { color: "#6366f1", })); mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } }); - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); }); afterEach(() => { @@ -578,9 +577,8 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); - it("shows a workspace tasks link for non-default workspaces when isolated workspaces are enabled", async () => { + it("shows only the workspace detail link for non-default workspaces", async () => { mockProjectsApi.list.mockResolvedValue([createProject()]); - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); const root = renderProperties(container, { issue: createIssue({ projectId: "project-1", @@ -596,14 +594,10 @@ describe("IssueProperties", () => { await flush(); await flush(); - const tasksLink = Array.from(container.querySelectorAll("a")).find( - (link) => link.textContent?.includes("View workspace tasks"), - ); const workspaceLink = Array.from(container.querySelectorAll("a")).find( (link) => link.textContent?.trim() === "View workspace", ); - expect(tasksLink).not.toBeUndefined(); - expect(tasksLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1/issues"); + expect(container.textContent).not.toContain("View workspace tasks"); expect(workspaceLink).not.toBeUndefined(); expect(workspaceLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1"); @@ -806,6 +800,132 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("hides model options when the issue uses the assignee default", async () => { + mockAgentsApi.list.mockResolvedValue([ + { + id: "agent-1", + name: "Senior Product Engineer", + role: "engineer", + title: null, + status: "active", + adapterType: "codex_local", + icon: null, + }, + ]); + + const root = renderProperties(container, { + issue: createIssue({ + assigneeAgentId: "agent-1", + assigneeAdapterOverrides: null, + }), + childIssues: [], + onUpdate: vi.fn(), + }); + await flush(); + + expect(container.textContent).not.toContain("Model lane"); + expect(container.textContent).not.toContain("Codex options"); + + act(() => root.unmount()); + }); + + it("edits existing custom assignee model options from the properties pane", async () => { + const onUpdate = vi.fn(); + mockAgentsApi.list.mockResolvedValue([ + { + id: "agent-1", + name: "Senior Product Engineer", + role: "engineer", + title: null, + status: "active", + adapterType: "codex_local", + icon: null, + }, + ]); + mockAgentsApi.adapterModels.mockResolvedValue([ + { id: "gpt-5.5", label: "GPT-5.5" }, + { id: "gpt-5.4", label: "GPT-5.4" }, + ]); + + const root = renderProperties(container, { + issue: createIssue({ + assigneeAgentId: "agent-1", + assigneeAdapterOverrides: { + adapterConfig: { + model: "gpt-5.4", + modelReasoningEffort: "high", + }, + }, + }), + childIssues: [], + onUpdate, + }); + await flush(); + await flush(); + + expect(container.textContent).toContain("Custom · gpt-5.4 · high"); + expect(container.textContent).toContain("Model lane"); + + const modelButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("GPT-5.5")); + expect(modelButton).not.toBeUndefined(); + + await act(async () => { + modelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ + assigneeAdapterOverrides: { + adapterConfig: { + model: "gpt-5.5", + modelReasoningEffort: "high", + }, + }, + }); + + act(() => root.unmount()); + }); + + it("clears existing assignee adapter overrides from the properties pane", async () => { + const onUpdate = vi.fn(); + mockAgentsApi.list.mockResolvedValue([ + { + id: "agent-1", + name: "Senior Product Engineer", + role: "engineer", + title: null, + status: "active", + adapterType: "codex_local", + icon: null, + }, + ]); + + const root = renderProperties(container, { + issue: createIssue({ + assigneeAgentId: "agent-1", + assigneeAdapterOverrides: { + adapterConfig: { + model: "gpt-5.4", + }, + }, + }), + childIssues: [], + onUpdate, + }); + await flush(); + + const clearButton = container.querySelector('button[aria-label="Clear adapter options"]'); + expect(clearButton).not.toBeNull(); + + await act(async () => { + clearButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ assigneeAdapterOverrides: null }); + + act(() => root.unmount()); + }); + it("shows a checkmark on selected labels in the picker", async () => { mockIssuesApi.listLabels.mockResolvedValue([ createLabel(), diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 968bbc2b..50a64bd8 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -3,15 +3,16 @@ import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link } from "@/lib/router"; import type { Issue, IssueLabel, Project, WorkspaceRuntimeService } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AdapterModel } from "../api/agents"; import { accessApi } from "../api/access"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; -import { instanceSettingsApi } from "../api/instanceSettings"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members"; +import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../lib/issue-assignee-overrides"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, @@ -25,6 +26,10 @@ import { orderItemsBySelectedAndRecent } from "../lib/recent-selections"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy"; import { formatMonitorOffset } from "../lib/issue-monitor"; +import { formatRetryReason } from "../lib/runRetryState"; +import { useRetryNowMutation } from "../hooks/useRetryNowMutation"; +import { RetryErrorBand } from "./IssueScheduledRetryCard"; +import { extractProviderIdWithFallback } from "../lib/model-utils"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { Identity } from "./Identity"; @@ -32,6 +37,7 @@ import { IssueReferencePill } from "./IssueReferencePill"; import { formatDate, formatDateTime, cn, projectUrl } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; import { Button } from "@/components/ui/button"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { Dialog, DialogClose, @@ -43,8 +49,9 @@ import { } from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock } from "lucide-react"; +import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock, RotateCcw, Loader2, CheckCircle2 } from "lucide-react"; import { AgentIcon } from "./AgentIconPicker"; +import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) { const [copied, setCopied] = useState(false); @@ -122,10 +129,6 @@ function runningRuntimeServiceWithUrl( return runtimeServices?.find((service) => service.status === "running" && service.url?.trim()) ?? null; } -function executionWorkspaceIssuesHref(workspaceId: string) { - return `/execution-workspaces/${workspaceId}/issues`; -} - function toDateTimeLocalValue(value: string | null | undefined) { if (!value) return ""; const date = new Date(value); @@ -151,6 +154,82 @@ function PropertyRow({ label, children }: { label: string; children: React.React ); } +const ISSUE_THINKING_EFFORT_OPTIONS = { + claude_local: [ + { value: "", label: "Default" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + ], + codex_local: [ + { value: "", label: "Default" }, + { value: "minimal", label: "Minimal" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "X-High" }, + ], + opencode_local: [ + { value: "", label: "Default" }, + { value: "minimal", label: "Minimal" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "X-High" }, + { value: "max", label: "Max" }, + ], +} as const; + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : {}; +} + +function compactRecord(record: Record) { + return Object.fromEntries( + Object.entries(record).filter(([, value]) => value !== undefined), + ); +} + +function thinkingEffortOptionsFor(adapterType: string | null | undefined) { + if (adapterType === "codex_local") return ISSUE_THINKING_EFFORT_OPTIONS.codex_local; + if (adapterType === "opencode_local") return ISSUE_THINKING_EFFORT_OPTIONS.opencode_local; + return ISSUE_THINKING_EFFORT_OPTIONS.claude_local; +} + +function thinkingEffortKeyFor(adapterType: string | null | undefined) { + if (adapterType === "codex_local") return "modelReasoningEffort"; + if (adapterType === "opencode_local") return "variant"; + return "effort"; +} + +function thinkingEffortValueFor(adapterType: string | null | undefined, adapterConfig: Record) { + if (adapterType === "codex_local") { + return String(adapterConfig.modelReasoningEffort ?? adapterConfig.reasoningEffort ?? adapterConfig.effort ?? ""); + } + if (adapterType === "opencode_local") { + return String(adapterConfig.variant ?? ""); + } + return String(adapterConfig.effort ?? ""); +} + +function overrideLane(overrides: Issue["assigneeAdapterOverrides"]): IssueModelLane { + if (overrides?.modelProfile === "cheap") return "cheap"; + if (overrides?.adapterConfig) return "custom"; + return "primary"; +} + +function sortAdapterModels(models: AdapterModel[]) { + return [...models].sort((a, b) => { + const providerA = extractProviderIdWithFallback(a.id); + const providerB = extractProviderIdWithFallback(b.id); + const byProvider = providerA.localeCompare(providerB); + if (byProvider !== 0) return byProvider; + return a.id.localeCompare(b.id); + }); +} + function RemovableIssueReferencePill({ issue, onRemove, @@ -317,7 +396,9 @@ export function IssueProperties({ const [approversOpen, setApproversOpen] = useState(false); const [approverSearch, setApproverSearch] = useState(""); const [monitorOpen, setMonitorOpen] = useState(false); + const [scheduledRetryOpen, setScheduledRetryOpen] = useState(false); const [labelsOpen, setLabelsOpen] = useState(false); + const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false); const [labelSearch, setLabelSearch] = useState(""); const [newLabelName, setNewLabelName] = useState(""); const [newLabelColor, setNewLabelColor] = useState("#6366f1"); @@ -341,12 +422,6 @@ export function IssueProperties({ queryFn: () => accessApi.listUserDirectory(companyId!), enabled: !!companyId, }); - const { data: experimentalSettings } = useQuery({ - queryKey: queryKeys.instance.experimentalSettings, - queryFn: () => instanceSettingsApi.getExperimental(), - retry: false, - }); - const { data: projects } = useQuery({ queryKey: queryKeys.projects.list(companyId!), queryFn: () => projectsApi.list(companyId!), @@ -414,16 +489,10 @@ export function IssueProperties({ ? orderedProjects.find((project) => project.id === issue.projectId) ?? null : null; const issueProject = issue.project ?? currentProject; - const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; const issueUsesMainWorkspace = useMemo( () => isMainIssueWorkspace({ issue, project: issueProject }), [issue, issueProject], ); - const workspaceTasksExecutionWorkspaceId = useMemo(() => { - if (!isolatedWorkspacesEnabled) return null; - if (issueUsesMainWorkspace) return null; - return issue.executionWorkspaceId ?? issue.currentExecutionWorkspace?.id ?? null; - }, [isolatedWorkspacesEnabled, issue, issueUsesMainWorkspace]); const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId) && !issueUsesMainWorkspace; const liveWorkspaceService = useMemo(() => { if (issueUsesMainWorkspace) return null; @@ -482,6 +551,219 @@ export function IssueProperties({ const assignee = issue.assigneeAgentId ? agents?.find((a) => a.id === issue.assigneeAgentId) : null; + const assigneeAdapterType = assignee?.adapterType ?? null; + const assigneeAdapterOverrides = issue.assigneeAdapterOverrides ?? null; + const showAssigneeAdapterOptions = assigneeAdapterOverrides !== null; + const supportsAssigneeOverrides = Boolean( + assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType), + ); + const assigneeSupportsCheapLane = Boolean( + supportsAssigneeOverrides + && (assigneeAdapterType === "claude_local" + || assigneeAdapterType === "codex_local" + || assigneeAdapterType === "opencode_local"), + ); + const assigneeOverrideLane = overrideLane(assigneeAdapterOverrides); + const assigneeOverrideAdapterConfig = asRecord(assigneeAdapterOverrides?.adapterConfig); + const assigneeOverrideModel = + typeof assigneeOverrideAdapterConfig.model === "string" ? assigneeOverrideAdapterConfig.model : ""; + const assigneeOverrideThinkingEffort = thinkingEffortValueFor( + assigneeAdapterType, + assigneeOverrideAdapterConfig, + ); + const assigneeOverrideChrome = assigneeAdapterType === "claude_local" + && assigneeOverrideAdapterConfig.chrome === true; + const { data: assigneeAdapterModels } = useQuery({ + queryKey: + companyId && assigneeAdapterType + ? queryKeys.agents.adapterModels(companyId, assigneeAdapterType) + : ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"], + queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!), + enabled: Boolean(companyId) && showAssigneeAdapterOptions && supportsAssigneeOverrides, + }); + const { data: assigneeCheapProfiles } = useQuery({ + queryKey: companyId && assigneeAdapterType + ? queryKeys.agents.adapterModelProfiles(companyId, assigneeAdapterType) + : ["agents", "none", "adapter-model-profiles", assigneeAdapterType ?? "none"], + queryFn: () => agentsApi.adapterModelProfiles(companyId!, assigneeAdapterType!), + enabled: Boolean(companyId) && showAssigneeAdapterOptions && assigneeSupportsCheapLane, + }); + const assigneeCheapProfile = useMemo( + () => (assigneeCheapProfiles ?? []).find((profile) => profile.key === "cheap") ?? null, + [assigneeCheapProfiles], + ); + const modelOverrideOptions = useMemo(() => { + const models = sortAdapterModels(assigneeAdapterModels ?? []); + const options = models.map((model) => ({ + id: model.id, + label: model.label, + searchText: `${model.id} ${extractProviderIdWithFallback(model.id)}`, + })); + if (assigneeOverrideModel && !options.some((option) => option.id === assigneeOverrideModel)) { + options.unshift({ + id: assigneeOverrideModel, + label: assigneeOverrideModel, + searchText: assigneeOverrideModel, + }); + } + return options; + }, [assigneeAdapterModels, assigneeOverrideModel]); + const updateAssigneeAdapterOverrides = (next: Issue["assigneeAdapterOverrides"]) => { + onUpdate({ assigneeAdapterOverrides: next }); + }; + const buildAssigneeOverrideWithConfig = (adapterConfig: Record) => { + const nextConfig = compactRecord(adapterConfig); + const next = compactRecord({ + useProjectWorkspace: assigneeAdapterOverrides?.useProjectWorkspace, + ...(Object.keys(nextConfig).length > 0 ? { adapterConfig: nextConfig } : {}), + }); + return Object.keys(next).length > 0 ? next : null; + }; + const updateAssigneeOverrideConfig = (patch: Record) => { + updateAssigneeAdapterOverrides( + buildAssigneeOverrideWithConfig({ + ...assigneeOverrideAdapterConfig, + ...patch, + }), + ); + }; + const updateAssigneeOverrideThinkingEffort = (nextValue: string) => { + const nextConfig = { ...assigneeOverrideAdapterConfig }; + delete nextConfig.modelReasoningEffort; + delete nextConfig.reasoningEffort; + delete nextConfig.effort; + delete nextConfig.variant; + if (nextValue) { + nextConfig[thinkingEffortKeyFor(assigneeAdapterType)] = nextValue; + } + updateAssigneeAdapterOverrides(buildAssigneeOverrideWithConfig(nextConfig)); + }; + const setAssigneeOverrideLane = (lane: IssueModelLane) => { + if (lane === "primary") { + updateAssigneeAdapterOverrides(null); + return; + } + if (lane === "cheap") { + updateAssigneeAdapterOverrides( + compactRecord({ + useProjectWorkspace: assigneeAdapterOverrides?.useProjectWorkspace, + modelProfile: "cheap", + }), + ); + return; + } + updateAssigneeAdapterOverrides(buildAssigneeOverrideWithConfig(assigneeOverrideAdapterConfig) ?? { adapterConfig: {} }); + }; + const assigneeOptionsTrigger = (() => { + if (assigneeOverrideLane === "cheap") { + return Cheap model; + } + if (assigneeOverrideLane === "custom") { + const details = [ + assigneeOverrideModel, + assigneeOverrideThinkingEffort, + assigneeOverrideChrome ? "Chrome" : "", + ].filter(Boolean); + return ( + + Custom{details.length > 0 ? ` · ${details.join(" · ")}` : " adapter options"} + + ); + } + return Primary model; + })(); + const assigneeOptionsContent = supportsAssigneeOverrides ? ( +
+
+
Model lane
+
+ {(["primary", ...(assigneeSupportsCheapLane ? (["cheap"] as const) : ([] as const)), "custom"] as const).map((lane) => ( + + ))} +
+ {assigneeOverrideLane === "cheap" ? ( +

+ Sends modelProfile: "cheap"{" "} + {assigneeCheapProfile?.adapterConfig && typeof (assigneeCheapProfile.adapterConfig as Record).model === "string" + ? <>· adapter default {String((assigneeCheapProfile.adapterConfig as Record).model)} + : assigneeCheapProfile + ? <>· uses the agent's configured cheap profile + : <>· falls back to the primary model if no cheap profile is configured} +

+ ) : null} +
+ {assigneeOverrideLane === "custom" ? ( + <> +
+
Model
+ updateAssigneeOverrideConfig({ model: model || undefined })} + /> +
+
+
Thinking effort
+
+ {thinkingEffortOptionsFor(assigneeAdapterType).map((option) => ( + + ))} +
+
+ {assigneeAdapterType === "claude_local" ? ( +
+
Enable Chrome (--chrome)
+ updateAssigneeOverrideConfig({ chrome: next ? true : undefined })} + /> +
+ ) : null} + + ) : null} +
+ ) : ( +
+

+ {assignee + ? "This assignee's adapter does not expose editable issue overrides." + : "Select a compatible agent assignee to edit these overrides."} +

+ +
+ ); const reviewerValues = stageParticipantValues(issue.executionPolicy, "review"); const approverValues = stageParticipantValues(issue.executionPolicy, "approval"); const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId, userLabelMap); @@ -651,6 +933,169 @@ export function IssueProperties({ Attempt {issue.monitorAttemptCount} ) : null; + + const scheduledRetry = issue.scheduledRetry ?? null; + const retryNow = useRetryNowMutation(issue.id); + const showScheduledRetryRow = scheduledRetry && scheduledRetry.status === "scheduled_retry"; + const scheduledRetryDueAtIso = scheduledRetry?.scheduledRetryAt + ? new Date(scheduledRetry.scheduledRetryAt).toISOString() + : null; + const scheduledRetryRelative = scheduledRetryDueAtIso + ? formatMonitorOffset(scheduledRetryDueAtIso) + : null; + const scheduledRetryAbsolute = scheduledRetry?.scheduledRetryAt + ? formatDateTime(scheduledRetry.scheduledRetryAt) + : null; + const scheduledRetryShortDate = scheduledRetry?.scheduledRetryAt + ? formatDate(new Date(scheduledRetry.scheduledRetryAt)) + : null; + const scheduledRetryReasonLabel = formatRetryReason(scheduledRetry?.scheduledRetryReason); + const scheduledRetryAttempt = + typeof scheduledRetry?.scheduledRetryAttempt === "number" + && Number.isFinite(scheduledRetry.scheduledRetryAttempt) + && scheduledRetry.scheduledRetryAttempt > 0 + ? scheduledRetry.scheduledRetryAttempt + : null; + const scheduledRetryIsContinuation = + scheduledRetry?.scheduledRetryReason === "max_turns_continuation"; + const scheduledRetryRelativeLabel = (() => { + if (!scheduledRetryRelative) return "Pending schedule"; + const action = scheduledRetryIsContinuation ? "Continuation" : "Retry"; + if (scheduledRetryRelative === "now") return `${action} due now`; + return `${action} ${scheduledRetryRelative}`; + })(); + const scheduledRetryRetryNowSuccess = retryNow.isSuccess + && (retryNow.data?.outcome === "promoted" || retryNow.data?.outcome === "already_promoted"); + const scheduledRetryAttemptBadge = scheduledRetryAttempt !== null ? ( + Attempt {scheduledRetryAttempt} + ) : null; + const scheduledRetryTrigger = ( + + + ); + const scheduledRetryContent = scheduledRetry ? ( +
+
+ + {scheduledRetryIsContinuation ? "Scheduled continuation" : "Scheduled retry"} + + {scheduledRetryAttempt !== null ? ( + + Attempt {scheduledRetryAttempt} + + ) : null} +
+
+ {scheduledRetryReasonLabel ? ( + <> +
Reason
+
{scheduledRetryReasonLabel}
+ + ) : null} + {scheduledRetryAbsolute ? ( + <> +
Next attempt
+
+ {scheduledRetryAbsolute} + {scheduledRetryRelative ? ( + · {scheduledRetryRelative} + ) : null} +
+ + ) : null} + {scheduledRetry.retryOfRunId ? ( + <> +
Replaces run
+
+ + {scheduledRetry.retryOfRunId.slice(0, 8)} + +
+ + ) : null} + {scheduledRetry.agentName ? ( + <> +
Agent
+
+ + {scheduledRetry.agentName} + +
+ + ) : null} + {scheduledRetry.error ? ( + <> +
Last error
+
{scheduledRetry.error}
+ + ) : null} +
+ { + retryNow.reset(); + retryNow.mutate(); + }} + /> + +
+ + + {retryNow.isPending + ? "Promoting scheduled retry" + : scheduledRetryRetryNowSuccess + ? retryNow.data?.outcome === "already_promoted" + ? "Already promoted — run starting" + : "Promoted — run starting" + : scheduledRetryIsContinuation + ? "Pulls continuation forward immediately" + : "Pulls retry forward immediately"} + +
+
+ ) : null; const monitorContent = (
@@ -1334,6 +1779,31 @@ export function IssueProperties({ {assigneeContent} + {showAssigneeAdapterOptions ? ( + updateAssigneeAdapterOverrides(null)} + aria-label="Clear adapter options" + title="Clear adapter options" + > + + + } + > + {assigneeOptionsContent} + + ) : null} + )} + {showScheduledRetryRow && scheduledRetryContent ? ( + + {scheduledRetryContent} + + ) : null} + )} - {workspaceTasksExecutionWorkspaceId && ( - - - View workspace tasks - - - - )} {issue.currentExecutionWorkspace?.branchName && ( vi.fn()); + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => ( + {children} + ), +})); + +vi.mock("../api/issues", () => ({ + issuesApi: { + retryScheduledRetryNow: retryNowMock, + }, +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; +let dateNowSpy: ReturnType | null = null; + +const SYSTEM_NOW = new Date("2026-04-18T20:00:00.000Z").getTime(); + +const baseRetry: IssueScheduledRetry = { + runId: "run-00000000", + status: "scheduled_retry", + agentId: "agent-1", + agentName: "ClaudeCoder", + retryOfRunId: "run-prev-1234567", + scheduledRetryAt: "2026-04-18T20:15:00.000Z", + scheduledRetryAttempt: 4, + scheduledRetryReason: "transient_failure", + retryExhaustedReason: null, + error: "Upstream provider rate limited", + errorCode: "rate_limited", +}; + +function buildRetryResponse(outcome: IssueRetryNowOutcome) { + return { + outcome, + message: + outcome === "promoted" + ? "Promoted scheduled retry" + : outcome === "already_promoted" + ? "Scheduled retry already promoted" + : outcome === "no_scheduled_retry" + ? "No scheduled retry" + : "Promotion suppressed by gate", + scheduledRetry: + outcome === "promoted" || outcome === "already_promoted" + ? { ...baseRetry, status: "queued" as const } + : null, + }; +} + +async function flushAll() { + for (let i = 0; i < 4; i += 1) { + // eslint-disable-next-line no-await-in-loop + await act(async () => { + await Promise.resolve(); + }); + } +} + +function renderWithProviders(ui: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + act(() => { + root.render( + + {ui} + , + ); + }); +} + +beforeEach(() => { + dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(SYSTEM_NOW); + retryNowMock.mockReset(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + dateNowSpy?.mockRestore(); +}); + +function getCard() { + return container.querySelector('[data-testid="issue-scheduled-retry-card"]'); +} + +function getRetryNowButton() { + return container.querySelector( + '[data-testid="issue-scheduled-retry-card-retry-now"]', + ); +} + +describe("IssueScheduledRetryCard", () => { + it("renders nothing when there is no scheduled retry", () => { + renderWithProviders(); + expect(getCard()).toBeNull(); + }); + + it("renders nothing when status is not scheduled_retry", () => { + renderWithProviders( + , + ); + expect(getCard()).toBeNull(); + }); + + it("shows attempt count, reason, absolute and relative timestamps", () => { + renderWithProviders( + , + ); + const card = getCard(); + expect(card).not.toBeNull(); + const text = card!.textContent ?? ""; + expect(text).toContain("Retry scheduled"); + expect(text).toContain("Attempt 4"); + expect(text).toContain("Transient failure"); + expect(text).toContain("Automatic retry in 15m"); + expect(text).toContain("run-prev"); + }); + + it("uses continuation copy for max-turn continuations", () => { + renderWithProviders( + , + ); + const text = getCard()?.textContent ?? ""; + expect(text).toContain("Continuation scheduled"); + expect(text).toContain("Automatic continuation"); + expect(text).toContain("Pulls continuation forward immediately"); + }); + + it("uses 'due now' label when scheduledRetryAt is at the current time", () => { + renderWithProviders( + , + ); + const text = getCard()?.textContent ?? ""; + expect(text).toContain("Automatic retry due now"); + }); + + it("invokes retry-now and shows promoted state on success", async () => { + retryNowMock.mockResolvedValue(buildRetryResponse("promoted")); + renderWithProviders( + , + ); + const button = getRetryNowButton(); + expect(button).not.toBeNull(); + + act(() => { + button!.click(); + }); + await flushAll(); + expect(retryNowMock).toHaveBeenCalledWith("issue-1"); + const finalButton = getRetryNowButton(); + expect(finalButton!.textContent ?? "").toContain("Promoted"); + expect(finalButton!.disabled).toBe(true); + }); + + it("shows already promoted state when backend reports duplicate click", async () => { + retryNowMock.mockResolvedValue(buildRetryResponse("already_promoted")); + renderWithProviders( + , + ); + act(() => { + getRetryNowButton()!.click(); + }); + await flushAll(); + expect(getRetryNowButton()!.textContent ?? "").toContain("Already promoted"); + expect(container.querySelector('[data-testid="issue-scheduled-retry-error-band"]')).toBeNull(); + }); + + it("renders an inline error band on backend failure", async () => { + retryNowMock.mockRejectedValue(new Error("Server error")); + renderWithProviders( + , + ); + act(() => { + getRetryNowButton()!.click(); + }); + await flushAll(); + const band = container.querySelector('[data-testid="issue-scheduled-retry-error-band"]'); + expect(band).not.toBeNull(); + expect((band?.textContent ?? "")).toContain("Server error"); + expect(getRetryNowButton()!.disabled).toBe(false); + }); + + it("surfaces gate-suppressed outcome via the inline error band", async () => { + retryNowMock.mockResolvedValue(buildRetryResponse("gate_suppressed")); + renderWithProviders( + , + ); + act(() => { + getRetryNowButton()!.click(); + }); + await flushAll(); + const band = container.querySelector('[data-testid="issue-scheduled-retry-error-band"]'); + expect(band).not.toBeNull(); + expect((band?.textContent ?? "")).toContain("Promotion suppressed"); + expect(getRetryNowButton()!.disabled).toBe(false); + }); +}); diff --git a/ui/src/components/IssueScheduledRetryCard.tsx b/ui/src/components/IssueScheduledRetryCard.tsx new file mode 100644 index 00000000..bd6eef0c --- /dev/null +++ b/ui/src/components/IssueScheduledRetryCard.tsx @@ -0,0 +1,194 @@ +import { Clock, RotateCcw, AlertCircle, Loader2, CheckCircle2 } from "lucide-react"; +import { Link } from "@/lib/router"; +import { Button } from "@/components/ui/button"; +import { cn, formatDateTime } from "@/lib/utils"; +import { formatMonitorOffset } from "@/lib/issue-monitor"; +import { formatRetryReason } from "@/lib/runRetryState"; +import type { IssueScheduledRetry } from "@paperclipai/shared"; +import { useRetryNowMutation, type RetryNowError } from "../hooks/useRetryNowMutation"; + +const MAX_TURN_CONTINUATION = "max_turns_continuation"; + +function isContinuationReason(reason: string | null | undefined) { + return reason === MAX_TURN_CONTINUATION; +} + +function shortRunId(runId: string | null | undefined) { + return typeof runId === "string" && runId.length >= 8 ? runId.slice(0, 8) : runId ?? ""; +} + +interface IssueScheduledRetryCardProps { + issueId: string | null | undefined; + scheduledRetry: IssueScheduledRetry | null | undefined; +} + +export function IssueScheduledRetryCard({ + issueId, + scheduledRetry, +}: IssueScheduledRetryCardProps) { + const retryNow = useRetryNowMutation(issueId); + + if (!scheduledRetry || !issueId) return null; + if (scheduledRetry.status !== "scheduled_retry") return null; + + const continuation = isContinuationReason(scheduledRetry.scheduledRetryReason); + const dueAtIso = scheduledRetry.scheduledRetryAt + ? new Date(scheduledRetry.scheduledRetryAt).toISOString() + : null; + const relative = dueAtIso ? formatMonitorOffset(dueAtIso) : null; + const absolute = scheduledRetry.scheduledRetryAt + ? formatDateTime(scheduledRetry.scheduledRetryAt) + : null; + const reason = formatRetryReason(scheduledRetry.scheduledRetryReason); + const attempt = + typeof scheduledRetry.scheduledRetryAttempt === "number" + && Number.isFinite(scheduledRetry.scheduledRetryAttempt) + && scheduledRetry.scheduledRetryAttempt > 0 + ? scheduledRetry.scheduledRetryAttempt + : null; + + const badgeLabel = continuation ? "Continuation scheduled" : "Retry scheduled"; + const titleAction = continuation ? "Automatic continuation" : "Automatic retry"; + let titleSuffix: string; + if (relative === "now") { + titleSuffix = "due now"; + } else if (relative) { + titleSuffix = relative; + } else { + titleSuffix = "pending schedule"; + } + const title = `${titleAction} ${titleSuffix}`; + + const helperIdle = continuation + ? "Pulls continuation forward immediately" + : "Pulls retry forward immediately"; + const isError = retryNow.isError || retryNow.lastError !== null; + const isSuccessTransient = retryNow.isSuccess + && (retryNow.data?.outcome === "promoted" || retryNow.data?.outcome === "already_promoted"); + + return ( +
+
+
+
+ + + {attempt !== null ? ( + Attempt {attempt} + ) : null} + {reason ? ( + {reason} + ) : null} +
+
{title}
+ {(absolute || scheduledRetry.retryOfRunId) ? ( +
+ {absolute ? {absolute} : null} + {absolute && scheduledRetry.retryOfRunId ? {" · "} : null} + {scheduledRetry.retryOfRunId ? ( + + Replaces run{" "} + + {shortRunId(scheduledRetry.retryOfRunId)} + + + ) : null} +
+ ) : null} + {scheduledRetry.error ? ( +
+ Last attempt failed: {scheduledRetry.error}. Paperclip will retry automatically. +
+ ) : null} + {isError ? ( + { + retryNow.reset(); + retryNow.mutate(); + }} + /> + ) : null} +
+
+ + + {retryNow.isPending + ? "Promoting scheduled retry" + : isSuccessTransient + ? retryNow.data?.outcome === "already_promoted" + ? "Already promoted — run starting" + : "Promoted — run starting" + : helperIdle} + +
+
+
+ ); +} + +interface RetryErrorBandProps { + error: RetryNowError | null; + onRetry: () => void; + className?: string; +} + +export function RetryErrorBand({ error, onRetry, className }: RetryErrorBandProps) { + if (!error) return null; + return ( +
+
+ ); +} diff --git a/ui/src/hooks/useRetryNowMutation.ts b/ui/src/hooks/useRetryNowMutation.ts new file mode 100644 index 00000000..6fec4746 --- /dev/null +++ b/ui/src/hooks/useRetryNowMutation.ts @@ -0,0 +1,104 @@ +import { useCallback } from "react"; +import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; +import type { IssueRetryNowOutcome, IssueRetryNowResponse } from "@paperclipai/shared"; +import { ApiError } from "../api/client"; +import { issuesApi } from "../api/issues"; +import { useToastActions } from "../context/ToastContext"; +import { queryKeys } from "../lib/queryKeys"; + +export type RetryNowError = { + message: string; + outcomeMessage: string | null; + status: number | null; +}; + +function readErrorMessage(error: unknown): string { + if (error instanceof ApiError) { + if (typeof error.message === "string" && error.message.trim().length > 0) return error.message; + return `Request failed (${error.status})`; + } + if (error instanceof Error && error.message) return error.message; + return "The request failed. Try again in a moment."; +} + +export const RETRY_NOW_OUTCOME_HEADLINE: Record = { + promoted: "Retry promoted", + already_promoted: "Retry already running", + no_scheduled_retry: "No scheduled retry", + gate_suppressed: "Couldn't retry now", +}; + +export function useRetryNowMutation( + issueId: string | null | undefined, +): UseMutationResult & { + lastError: RetryNowError | null; +} { + const queryClient = useQueryClient(); + const { pushToast } = useToastActions(); + + const mutation = useMutation({ + mutationFn: () => { + if (!issueId) throw new Error("Missing issue id"); + return issuesApi.retryScheduledRetryNow(issueId); + }, + onSuccess: (response) => { + if (issueId) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) }); + } + if (response.outcome === "promoted") { + pushToast({ + title: RETRY_NOW_OUTCOME_HEADLINE.promoted, + body: response.message, + tone: "success", + }); + } else if (response.outcome === "gate_suppressed") { + pushToast({ + title: RETRY_NOW_OUTCOME_HEADLINE.gate_suppressed, + body: response.message, + tone: "error", + }); + } + }, + onError: (error) => { + pushToast({ + title: "Couldn't retry now", + body: readErrorMessage(error), + tone: "error", + }); + }, + }); + + const reset = mutation.reset; + const wrappedReset = useCallback(() => reset(), [reset]); + + const lastError: RetryNowError | null = (() => { + if (mutation.error) { + const apiError = mutation.error instanceof ApiError ? mutation.error : null; + return { + message: readErrorMessage(mutation.error), + outcomeMessage: null, + status: apiError?.status ?? null, + }; + } + if (mutation.data && mutation.data.outcome === "gate_suppressed") { + return { + message: mutation.data.message, + outcomeMessage: mutation.data.message, + status: null, + }; + } + return null; + })(); + + return { + ...mutation, + reset: wrappedReset, + lastError, + } as UseMutationResult & { + lastError: RetryNowError | null; + }; +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 3ae3202e..6d356161 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -71,6 +71,7 @@ import { AgentIcon } from "../components/AgentIconPicker"; import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary"; import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel"; import { IssueMonitorActivityCard } from "../components/IssueMonitorActivityCard"; +import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard"; import { IssueProperties } from "../components/IssueProperties"; import { IssueRunLedger } from "../components/IssueRunLedger"; import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard"; @@ -1159,6 +1160,7 @@ function IssueDetailActivityTab({
)} + summary); + +const baseIssue: Issue = { + ...storybookIssues[0]!, + planDocument: storybookIssueDocuments.find((document) => document.key === "plan") ?? null, + documentSummaries: issueDocumentSummaries, + currentExecutionWorkspace: storybookExecutionWorkspaces[0]!, +}; + +const inFifteenMinutes = () => new Date(Date.now() + 15 * 60_000).toISOString(); +const justNow = () => new Date(Date.now() + 5_000).toISOString(); +const inTwoDays = () => new Date(Date.now() + 2 * 24 * 60 * 60_000).toISOString(); + +const transientRetry: IssueScheduledRetry = { + runId: "run-aaaaaaaa-1111-1111-1111-111111111111", + status: "scheduled_retry", + agentId: baseIssue.assigneeAgentId ?? "agent-1", + agentName: "ClaudeCoder", + retryOfRunId: "run-prev-2222-2222-2222-222222222222", + scheduledRetryAt: inFifteenMinutes(), + scheduledRetryAttempt: 4, + scheduledRetryReason: "transient_failure", + retryExhaustedReason: null, + error: "Upstream provider returned 502", + errorCode: "upstream_502", +}; + +const continuationRetry: IssueScheduledRetry = { + ...transientRetry, + runId: "run-bbbbbbbb-3333-3333-3333-333333333333", + retryOfRunId: "run-prev-4444-4444-4444-444444444444", + scheduledRetryAt: inTwoDays(), + scheduledRetryAttempt: 1, + scheduledRetryReason: "max_turns_continuation", + error: null, +}; + +const dueNowRetry: IssueScheduledRetry = { + ...transientRetry, + runId: "run-cccccccc-5555-5555-5555-555555555555", + scheduledRetryAt: justNow(), +}; + +const issueWithRetry = (retry: IssueScheduledRetry): Issue => ({ + ...baseIssue, + scheduledRetry: retry, +}); + +function ScheduledRetrySurfaceStories() { + return ( +
+
+
+ IssueScheduledRetryCard - transient failure, in 15m +
+ +
+ +
+
+ IssueScheduledRetryCard - max-turn continuation, in 2d +
+ +
+ +
+
+ IssueScheduledRetryCard - due now (overdue) +
+ +
+ +
+
+ IssueScheduledRetryCard - returns null with no live scheduled retry +
+
+ (intentionally renders nothing for issues without a live scheduled retry) +
+ +
+ +
+
+
+ IssueProperties Scheduled retry row - hidden when no live retry +
+
+ undefined} inline /> +
+
+ +
+
+ IssueProperties Scheduled retry row - transient failure, in 15m +
+
+ undefined} + inline + /> +
+
+ +
+
+ IssueProperties Scheduled retry row - continuation, in 2d +
+
+ undefined} + inline + /> +
+
+ +
+
+ IssueProperties Scheduled retry row - due now +
+
+ undefined} + inline + /> +
+
+
+
+ ); +} + +const meta = { + title: "Product/Issue Scheduled retry surfaces", + component: ScheduledRetrySurfaceStories, + parameters: { + docs: { + description: { + component: + "Surfaces the IssueScheduledRetryCard and IssueProperties Scheduled retry row in transient/continuation/due-now variants for UX review. The card mounts above IssueMonitorActivityCard and the property row sits sibling-to (and above) Monitor.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const ScheduledRetrySurfaces: Story = {};