fix(issues): quiet missing-disposition warnings while a live continuation is running (#10899)

## 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 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-08-05 15:59:01 -05:00 committed by GitHub
parent 427509e6e0
commit c2b41bb7cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 360 additions and 14 deletions

View File

@ -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 () => {

View File

@ -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();

View File

@ -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")) {

View File

@ -24,10 +24,10 @@ export async function hydrateSuccessfulRunHandoffLiveness(
companyId: string,
states: Map<string, SuccessfulRunHandoffState>,
) {
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);

View File

@ -187,6 +187,80 @@ describe("IssueBlockedNotice", () => {
});
});
it("hides the next-step notice while a live continuation is running the issue", () => {
const node = render(
<IssueBlockedNotice
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: true,
liveRunId: "87654321-dddd-eeee-ffff-123456789abc",
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: "Updated the plan and left follow-up work.",
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
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(
<IssueBlockedNotice
issueId="issue-1"
issueStatus="in_progress"
blockers={[]}
liveIssueIds={new Set(["issue-1"])}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: false,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: null,
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
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(
<IssueBlockedNotice
issueId="issue-1"
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
scheduledRetry={baseRetry}
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: true,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: null,
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
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(
<IssueBlockedNotice

View File

@ -16,6 +16,7 @@ import { useRetryNowMutation } from "../hooks/useRetryNowMutation";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
import { RetryErrorBand } from "./IssueScheduledRetryCard";
import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
import { Badge } from "@/components/ui/badge";
import {
deriveActiveRecoveryDisplayState,
@ -379,7 +380,14 @@ export function IssueBlockedNotice({
agentName?: string | null;
}) {
if (issueStatus === "done" || issueStatus === "cancelled") return null;
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
// A live run on this issue means an agent is already handling it — the
// missing-disposition complaint only applies when the issue is stuck.
// `hasLiveContinuation` is the server's view; `liveIssueIds` catches runs
// that started after the issue payload was fetched.
const showSuccessfulRunHandoff =
successfulRunHandoff != null
&& isSuccessfulRunHandoffRequired({ successfulRunHandoff, scheduledRetry })
&& !(issueId && liveIssueIds?.has(issueId));
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
const successfulRunRetryNow = showSuccessfulRunHandoff
&& issueId

View File

@ -2411,6 +2411,10 @@ function isStaleSuccessfulRunHandoffNotice(input: {
const currentHandoff = input.successfulRunHandoff ?? null;
if (currentHandoff?.state === "resolved") return true;
if (issueStatusIsTerminalDisposition(input.issueStatus)) return true;
// A live continuation (running/queued run or queued wake) means an agent is
// already handling the issue — fold the warning until the issue is actually
// stuck again.
if (currentHandoff?.hasLiveContinuation) return true;
const noticeSourceRunId = sourceRunIdFromSuccessfulRunHandoffMetadata(input.metadata) ?? input.runId ?? null;
if (
@ -4476,6 +4480,16 @@ export function IssueChatThread({
() => 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

View File

@ -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<string, Agent>;
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.");
});
});

View File

@ -14,8 +14,17 @@ export function isSuccessfulRunHandoffActivity(action: string) {
|| action === SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION;
}
export function isSuccessfulRunHandoffRequired(issue: Pick<Issue, "successfulRunHandoff">) {
return issue.successfulRunHandoff?.required === true;
export function isSuccessfulRunHandoffRequired(
issue: Pick<Issue, "successfulRunHandoff"> & Partial<Pick<Issue, "scheduledRetry">>,
) {
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) {