From c2b41bb7cde7bceb9a55dcf251f0bdb60fdd89eb Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:59:01 -0500 Subject: [PATCH] fix(issues): quiet missing-disposition warnings while a live continuation is running (#10899) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - When an agent run ends without recording a disposition, Paperclip raises a "missing disposition" handoff so the work does not silently stall > - The server already tracks whether such an issue has a live continuation (a running or queued run, or a queued wake) in `successfulRunHandoff.hasLiveContinuation` > - But no UI surface read that flag, so an issue that an agent was actively working on still showed the "This task still needs a next step" banner, a loud thread warning, and "Needs next step" badges > - This pull request makes every missing-disposition complaint respect liveness: warn only when no live agent is on the issue and it is really stuck > - The benefit is that users see the warning only when action is needed, and the noise disappears while an agent is already handling the issue ## Linked Issues or Issue Description No public GitHub issue exists for this bug. Description follows the bug-report template: **What happened?** An issue that a live agent run was actively working on showed the "missing disposition" warning banner, a loud thread notice, and "Needs next step" badges at the same time. The API payload for that issue showed `successfulRunHandoff.required: true` together with `hasLiveContinuation: true` and a `liveRunId`, but the UI ignored the liveness fields. **Expected behavior** The missing-disposition warning appears only when the issue has no live run or queued wake. A live agent records a disposition when its run ends. Paperclip complains only if the run ends and no disposition exists. **Steps to reproduce** 1. Let a run finish on an in-progress issue without a disposition. Paperclip raises the handoff and queues a corrective wake. 2. Open the issue page while the corrective run (or any new run) is live. 3. See the banner, the badges, and the loud thread notice — all visible while the agent works. **Paperclip version or commit** Current `master` (reproduced at commit 6ffe9df842). **Deployment mode** Self-hosted development instance. ## What Changed - `isSuccessfulRunHandoffRequired` (ui lib) returns `false` while a live continuation exists. This quiets the Kanban card badge and the issues-list badge. Exception: when the only continuation is a not-yet-promoted scheduled retry, the notice stays visible so the **Retry now** control stays reachable. - `IssueBlockedNotice` also checks the real-time live-run set (`liveIssueIds`). A run that starts after the issue payload was fetched hides the banner at once. - `IssueChatThread` derives an effective handoff state from the live runs it already tracks. The loud "Missing issue disposition" thread notice folds into the quiet collapsed row while a continuation is live, and unfolds if the run ends without a disposition. - Server: `hydrateSuccessfulRunHandoffLiveness` now hydrates escalated handoffs too. The blocked-inbox `missing_disposition` attention is suppressed for escalated handoffs with a live run or wake. This matches the existing required-state suppression. ## Verification - `cd ui && npx vitest run src/components/IssueBlockedNotice.test.tsx src/components/IssueChatThreadSystemNotice.test.tsx src/components/IssueChatThread.test.tsx` — 106 tests pass, including 6 new tests for the live/stale/scheduled-retry matrix - `cd ui && npx vitest run src/components/IssuesList.test.tsx src/components/KanbanBoard.test.tsx src/lib` — pass - `cd server && npx vitest run src/__tests__/issue-blocker-attention.test.ts src/__tests__/issue-list-assignee-filter-routes.test.ts src/services/recovery/successful-run-handoff.test.ts src/__tests__/attention-service.test.ts` — pass, including new escalated-liveness cases - `pnpm typecheck` clean in `ui` and `server`; `node scripts/check-token-gates.mjs` clean - Manual check: a live issue's API payload showed `required: true` with `hasLiveContinuation: true` and a `liveRunId` while the banner was still on screen; with this change that state renders no complaint ## Risks - Behavioral shift only; no schema or migration changes. All complaints reappear as soon as the continuation stops without a disposition, so nothing can get lost permanently. - A queued wake counts as a live continuation. If a wake sits queued for a long time, the warning stays hidden for that time. The blocked-inbox path already behaved this way; the UI now matches it. - The scheduled-retry carve-out keeps the current Retry-now workflow intact. ## Model Used - Claude Fable 5 (`claude-fable-5`), Anthropic — agentic coding session with extended thinking and tool use (file edit, shell, test execution). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 --- .../__tests__/issue-blocker-attention.test.ts | 27 ++++ .../issue-list-assignee-filter-routes.test.ts | 64 ++++++++ server/src/services/issues.ts | 2 +- .../services/successful-run-handoff-state.ts | 12 +- ui/src/components/IssueBlockedNotice.test.tsx | 74 +++++++++ ui/src/components/IssueBlockedNotice.tsx | 10 +- ui/src/components/IssueChatThread.tsx | 20 ++- .../IssueChatThreadSystemNotice.test.tsx | 152 +++++++++++++++++- ui/src/lib/successful-run-handoff.ts | 13 +- 9 files changed, 360 insertions(+), 14 deletions(-) diff --git a/server/src/__tests__/issue-blocker-attention.test.ts b/server/src/__tests__/issue-blocker-attention.test.ts index 66247ae4a1..5635320dd4 100644 --- a/server/src/__tests__/issue-blocker-attention.test.ts +++ b/server/src/__tests__/issue-blocker-attention.test.ts @@ -834,6 +834,33 @@ describeEmbeddedPostgres("issue blocker attention", () => { state: "missing_disposition", reason: "missing_successful_run_disposition", }); + + await db.insert(activityLog).values({ + companyId, + actorType: "system", + actorId: "system", + action: "issue.successful_run_handoff_escalated", + entityType: "issue", + entityId: handoffId, + agentId, + details: { sourceRunId: randomUUID() }, + }); + const escalatedRows = await svc.list(companyId, { attention: "blocked" }); + expect(escalatedRows.find((row) => row.id === handoffId)?.blockedInboxAttention).toMatchObject({ + state: "missing_disposition", + reason: "missing_successful_run_disposition", + }); + + const escalatedLiveRunId = await activeRun({ companyId, agentId, issueId: handoffId, current: false }); + const escalatedLiveRows = await svc.list(companyId, { attention: "blocked" }); + expect(escalatedLiveRows.some((row) => row.id === handoffId)).toBe(false); + + await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, escalatedLiveRunId)); + const escalatedStoppedRows = await svc.list(companyId, { attention: "blocked" }); + expect(escalatedStoppedRows.find((row) => row.id === handoffId)?.blockedInboxAttention).toMatchObject({ + state: "missing_disposition", + reason: "missing_successful_run_disposition", + }); }); it("applies assigneeAgentId='null' as an IS NULL filter on the blocked-inbox path", async () => { diff --git a/server/src/__tests__/issue-list-assignee-filter-routes.test.ts b/server/src/__tests__/issue-list-assignee-filter-routes.test.ts index ab348695be..1b09113950 100644 --- a/server/src/__tests__/issue-list-assignee-filter-routes.test.ts +++ b/server/src/__tests__/issue-list-assignee-filter-routes.test.ts @@ -309,6 +309,70 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => { }); }); + it("marks an escalated successful-run handoff live while a run targets the issue", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: uniqueIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }); + await seedCloudTenantMember(companyId); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Assignee", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Escalated handoff issue", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + }); + await db.insert(activityLog).values({ + companyId, + actorType: "system", + actorId: "system", + action: "issue.successful_run_handoff_escalated", + entityType: "issue", + entityId: issueId, + agentId, + details: { sourceRunId: randomUUID() }, + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + status: "running", + contextSnapshot: { taskId: issueId }, + }); + + const app = createApp(companyId); + const res = await request(app) + .get(`/api/companies/${companyId}/issues`) + .query({ view: "compact", limit: "20" }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body[0]?.successfulRunHandoff).toMatchObject({ + state: "escalated", + required: false, + hasLiveContinuation: true, + liveRunId: runId, + }); + }); + it("logs resolved when a valid-path skip closes a stale required handoff", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index b39da59610..917a941a58 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -3891,7 +3891,7 @@ async function listIssueBlockedInboxAttentionMap( const source = issueRef(row); const handoff = handoffMap.get(row.id); const hasLiveHandoffContinuation = Boolean( - handoff?.state === "required" + (handoff?.state === "required" || handoff?.state === "escalated") && (liveHandoffRunIssueIds.has(row.id) || liveHandoffWakeIssueIds.has(row.id)) ); if (handoff && !hasLiveHandoffContinuation && (handoff.required || handoff.state === "escalated")) { diff --git a/server/src/services/successful-run-handoff-state.ts b/server/src/services/successful-run-handoff-state.ts index 3bfc1effe6..ea48539765 100644 --- a/server/src/services/successful-run-handoff-state.ts +++ b/server/src/services/successful-run-handoff-state.ts @@ -24,10 +24,10 @@ export async function hydrateSuccessfulRunHandoffLiveness( companyId: string, states: Map, ) { - const requiredIssueIds = [...states.entries()] - .filter(([, state]) => state.state === "required") + const unresolvedIssueIds = [...states.entries()] + .filter(([, state]) => state.state === "required" || state.state === "escalated") .map(([issueId]) => issueId); - if (requiredIssueIds.length === 0) return states; + if (unresolvedIssueIds.length === 0) return states; const [activeRuns, activeWakes] = await Promise.all([ dbOrTx @@ -36,7 +36,7 @@ export async function hydrateSuccessfulRunHandoffLiveness( .where(and( eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.status, [...SUCCESSFUL_RUN_HANDOFF_LIVE_RUN_STATUSES]), - inArray(heartbeatRunIssueId, requiredIssueIds), + inArray(heartbeatRunIssueId, unresolvedIssueIds), )), dbOrTx .select({ issueId: wakeRequestIssueId }) @@ -44,7 +44,7 @@ export async function hydrateSuccessfulRunHandoffLiveness( .where(and( eq(agentWakeupRequests.companyId, companyId), inArray(agentWakeupRequests.status, [...SUCCESSFUL_RUN_HANDOFF_LIVE_WAKE_STATUSES]), - inArray(wakeRequestIssueId, requiredIssueIds), + inArray(wakeRequestIssueId, unresolvedIssueIds), )), ]); @@ -58,7 +58,7 @@ export async function hydrateSuccessfulRunHandoffLiveness( .filter((issueId): issueId is string => Boolean(issueId)), ); - for (const issueId of requiredIssueIds) { + for (const issueId of unresolvedIssueIds) { const state = states.get(issueId); if (!state) continue; const liveRunId = liveRunByIssueId.get(issueId); diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx index 0ac990446e..70fc802709 100644 --- a/ui/src/components/IssueBlockedNotice.test.tsx +++ b/ui/src/components/IssueBlockedNotice.test.tsx @@ -187,6 +187,80 @@ describe("IssueBlockedNotice", () => { }); }); + it("hides the next-step notice while a live continuation is running the issue", () => { + const node = render( + , + ); + + expect(node.querySelector('[data-successful-run-handoff="required"]')).toBeNull(); + expect(node.textContent).toBe(""); + }); + + it("hides the next-step notice when the live-run set includes this issue", () => { + const node = render( + , + ); + + expect(node.querySelector('[data-successful-run-handoff="required"]')).toBeNull(); + expect(node.textContent).toBe(""); + }); + + it("keeps the next-step notice and retry-now when the only continuation is an unpromoted scheduled retry", () => { + const node = render( + , + ); + + expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull(); + expect(node.querySelector('[data-testid="issue-next-step-retry-now"]')).not.toBeNull(); + }); + it("does not render when the issue is done even if a stale handoff state is required", () => { const node = render( displayLiveRuns.some((run) => run.status === "running") || activeRun?.status === "running", [displayLiveRuns, activeRun], ); + // Real-time view of the handoff: a run that starts after the issue payload + // was fetched must quiet the missing-disposition warnings without waiting + // for a refetch to update `hasLiveContinuation`. + const successfulRunHandoffWithLiveness = useMemo(() => { + if (!successfulRunHandoff || successfulRunHandoff.hasLiveContinuation) { + return successfulRunHandoff ?? null; + } + const liveNow = activeRunIds.size > 0 || Boolean(issueId && liveIssueIds?.has(issueId)); + return liveNow ? { ...successfulRunHandoff, hasLiveContinuation: true } : successfulRunHandoff; + }, [successfulRunHandoff, activeRunIds, issueId, liveIssueIds]); const clearLatestSettleTimeouts = useCallback(() => { for (const timeout of latestSettleTimeoutsRef.current) { window.clearTimeout(timeout); @@ -4954,7 +4968,7 @@ export function IssueChatThread({ onUploadImage: stableOnUploadImage, issueStatus, issueAssigneeAgentId, - successfulRunHandoff, + successfulRunHandoff: successfulRunHandoffWithLiveness, externalReferences, linkCaseReferences, }), @@ -4983,7 +4997,7 @@ export function IssueChatThread({ stableOnUploadImage, issueStatus, issueAssigneeAgentId, - successfulRunHandoff, + successfulRunHandoffWithLiveness, externalReferences, linkCaseReferences, ], @@ -5126,7 +5140,7 @@ export function IssueChatThread({ allBlockers={blockedBy} liveIssueIds={liveIssueIds} blockerAttention={blockerAttention} - successfulRunHandoff={recoveryAction ? null : successfulRunHandoff} + successfulRunHandoff={recoveryAction ? null : successfulRunHandoffWithLiveness} scheduledRetry={scheduledRetry} agentName={ successfulRunHandoff?.assigneeAgentId diff --git a/ui/src/components/IssueChatThreadSystemNotice.test.tsx b/ui/src/components/IssueChatThreadSystemNotice.test.tsx index a3799deccb..410d6b3068 100644 --- a/ui/src/components/IssueChatThreadSystemNotice.test.tsx +++ b/ui/src/components/IssueChatThreadSystemNotice.test.tsx @@ -7,6 +7,7 @@ import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IssueChatThread } from "./IssueChatThread"; import type { IssueChatComment } from "../lib/issue-chat-messages"; +import type { LiveRunForIssue } from "../api/heartbeats"; import type { Agent, SuccessfulRunHandoffState } from "@paperclipai/shared"; vi.mock("@assistant-ui/react", () => ({ @@ -76,6 +77,7 @@ function renderThread( agentMap?: Map; issueStatus?: string; successfulRunHandoff?: SuccessfulRunHandoffState | null; + liveRuns?: LiveRunForIssue[]; } = {}, ) { act(() => { @@ -85,7 +87,7 @@ function renderThread( comments={comments} linkedRuns={[]} timelineEvents={[]} - liveRuns={[]} + liveRuns={options.liveRuns ?? []} onAdd={async () => {}} showComposer={false} enableLiveTranscriptPolling={false} @@ -652,4 +654,152 @@ describe("IssueChatThread system notice routing", () => { expect(details).toHaveProperty("hidden", false); expect(container.textContent).toContain("run-stale"); }); + + it("folds a required disposition warning while a live continuation is running the issue", () => { + const comment: IssueChatComment = { + id: "comment-live-disposition-warning", + companyId: "company-1", + issueId: "issue-1", + authorType: "system", + authorAgentId: null, + authorUserId: null, + runId: "run-source", + runAgentId: "agent-codex", + body: "Paperclip needs a disposition before this issue can continue.", + presentation: { + kind: "system_notice", + tone: "warning", + title: "Missing issue disposition", + detailsDefaultOpen: false, + }, + metadata: { + version: 1, + sourceRunId: "run-source", + sections: [], + }, + ...baseTimestamps, + }; + + renderThread([comment], { + issueStatus: "in_progress", + successfulRunHandoff: { + state: "required", + required: true, + hasLiveContinuation: true, + liveRunId: "run-live", + sourceRunId: "run-source", + correctiveRunId: null, + assigneeAgentId: "agent-codex", + detectedProgressSummary: null, + createdAt: new Date("2026-05-04T17:00:00.000Z"), + }, + }); + + const row = container.querySelector('[data-testid="stale-disposition-warning"]'); + expect(row).not.toBeNull(); + expect(row?.textContent).not.toContain("Paperclip needs a disposition before this issue can continue."); + }); + + it("keeps the required disposition warning loud when no live continuation exists", () => { + const comment: IssueChatComment = { + id: "comment-stuck-disposition-warning", + companyId: "company-1", + issueId: "issue-1", + authorType: "system", + authorAgentId: null, + authorUserId: null, + runId: "run-source", + runAgentId: "agent-codex", + body: "Paperclip needs a disposition before this issue can continue.", + presentation: { + kind: "system_notice", + tone: "warning", + title: "Missing issue disposition", + detailsDefaultOpen: false, + }, + metadata: { + version: 1, + sourceRunId: "run-source", + sections: [], + }, + ...baseTimestamps, + }; + + renderThread([comment], { + issueStatus: "in_progress", + successfulRunHandoff: { + state: "required", + required: true, + hasLiveContinuation: false, + sourceRunId: "run-source", + correctiveRunId: null, + assigneeAgentId: "agent-codex", + detectedProgressSummary: null, + createdAt: new Date("2026-05-04T17:00:00.000Z"), + }, + }); + + expect(container.querySelector('[data-testid="stale-disposition-warning"]')).toBeNull(); + expect(container.textContent).toContain("Paperclip needs a disposition before this issue can continue."); + }); + + it("folds a required disposition warning when a live run starts after the issue payload was fetched", () => { + const comment: IssueChatComment = { + id: "comment-realtime-disposition-warning", + companyId: "company-1", + issueId: "issue-1", + authorType: "system", + authorAgentId: null, + authorUserId: null, + runId: "run-source", + runAgentId: "agent-codex", + body: "Paperclip needs a disposition before this issue can continue.", + presentation: { + kind: "system_notice", + tone: "warning", + title: "Missing issue disposition", + detailsDefaultOpen: false, + }, + metadata: { + version: 1, + sourceRunId: "run-source", + sections: [], + }, + ...baseTimestamps, + }; + + renderThread([comment], { + issueStatus: "in_progress", + liveRuns: [ + { + id: "run-live", + status: "running", + invocationSource: "wakeup", + triggerDetail: null, + startedAt: "2026-05-04T17:05:00.000Z", + finishedAt: null, + createdAt: "2026-05-04T17:05:00.000Z", + agentId: "agent-codex", + agentName: "CodexCoder", + adapterType: "codex_local", + issueId: "issue-1", + }, + ], + successfulRunHandoff: { + state: "required", + required: true, + // Stale server view: the payload was fetched before run-live started. + hasLiveContinuation: false, + sourceRunId: "run-source", + correctiveRunId: null, + assigneeAgentId: "agent-codex", + detectedProgressSummary: null, + createdAt: new Date("2026-05-04T17:00:00.000Z"), + }, + }); + + const row = container.querySelector('[data-testid="stale-disposition-warning"]'); + expect(row).not.toBeNull(); + expect(row?.textContent).not.toContain("Paperclip needs a disposition before this issue can continue."); + }); }); diff --git a/ui/src/lib/successful-run-handoff.ts b/ui/src/lib/successful-run-handoff.ts index 8246861f56..1f54db8b81 100644 --- a/ui/src/lib/successful-run-handoff.ts +++ b/ui/src/lib/successful-run-handoff.ts @@ -14,8 +14,17 @@ export function isSuccessfulRunHandoffActivity(action: string) { || action === SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION; } -export function isSuccessfulRunHandoffRequired(issue: Pick) { - return issue.successfulRunHandoff?.required === true; +export function isSuccessfulRunHandoffRequired( + issue: Pick & Partial>, +) { + const handoff = issue.successfulRunHandoff; + if (handoff?.required !== true) return false; + // A live continuation (running/queued run or queued wake) means an agent is + // already on the issue — only complain when nothing is moving. The one + // carve-out is a not-yet-promoted scheduled retry: the notice stays visible + // there so the "Retry now" control remains reachable. + if (!handoff.hasLiveContinuation) return true; + return issue.scheduledRetry?.status === "scheduled_retry"; } function readString(value: unknown) {