mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
## 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>
129 lines
4.6 KiB
TypeScript
129 lines
4.6 KiB
TypeScript
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { activityLog, agentWakeupRequests, heartbeatRuns } from "@paperclipai/db";
|
|
import type { SuccessfulRunHandoffState } from "@paperclipai/shared";
|
|
import { logActivity } from "./activity-log.js";
|
|
|
|
export const SUCCESSFUL_RUN_HANDOFF_LIVE_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const;
|
|
export const SUCCESSFUL_RUN_HANDOFF_LIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution", "claimed"] as const;
|
|
|
|
const heartbeatRunIssueId = sql<string>`coalesce(
|
|
${heartbeatRuns.contextSnapshot} ->> 'issueId',
|
|
${heartbeatRuns.contextSnapshot} ->> 'taskId'
|
|
)`;
|
|
|
|
const wakeRequestIssueId = sql<string>`coalesce(
|
|
${agentWakeupRequests.payload} ->> 'issueId',
|
|
${agentWakeupRequests.payload} ->> 'taskId',
|
|
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId',
|
|
${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId'
|
|
)`;
|
|
|
|
export async function hydrateSuccessfulRunHandoffLiveness(
|
|
dbOrTx: any,
|
|
companyId: string,
|
|
states: Map<string, SuccessfulRunHandoffState>,
|
|
) {
|
|
const unresolvedIssueIds = [...states.entries()]
|
|
.filter(([, state]) => state.state === "required" || state.state === "escalated")
|
|
.map(([issueId]) => issueId);
|
|
if (unresolvedIssueIds.length === 0) return states;
|
|
|
|
const [activeRuns, activeWakes] = await Promise.all([
|
|
dbOrTx
|
|
.select({ id: heartbeatRuns.id, issueId: heartbeatRunIssueId })
|
|
.from(heartbeatRuns)
|
|
.where(and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
inArray(heartbeatRuns.status, [...SUCCESSFUL_RUN_HANDOFF_LIVE_RUN_STATUSES]),
|
|
inArray(heartbeatRunIssueId, unresolvedIssueIds),
|
|
)),
|
|
dbOrTx
|
|
.select({ issueId: wakeRequestIssueId })
|
|
.from(agentWakeupRequests)
|
|
.where(and(
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
inArray(agentWakeupRequests.status, [...SUCCESSFUL_RUN_HANDOFF_LIVE_WAKE_STATUSES]),
|
|
inArray(wakeRequestIssueId, unresolvedIssueIds),
|
|
)),
|
|
]);
|
|
|
|
const liveRunByIssueId = new Map<string, string>();
|
|
for (const row of activeRuns as Array<{ id: string; issueId: string | null }>) {
|
|
if (row.issueId && !liveRunByIssueId.has(row.issueId)) liveRunByIssueId.set(row.issueId, row.id);
|
|
}
|
|
const liveWakeIssueIds = new Set(
|
|
(activeWakes as Array<{ issueId: string | null }>)
|
|
.map((row) => row.issueId)
|
|
.filter((issueId): issueId is string => Boolean(issueId)),
|
|
);
|
|
|
|
for (const issueId of unresolvedIssueIds) {
|
|
const state = states.get(issueId);
|
|
if (!state) continue;
|
|
const liveRunId = liveRunByIssueId.get(issueId);
|
|
states.set(issueId, {
|
|
...state,
|
|
hasLiveContinuation: Boolean(liveRunId || liveWakeIssueIds.has(issueId)),
|
|
...(liveRunId ? { liveRunId } : {}),
|
|
});
|
|
}
|
|
|
|
return states;
|
|
}
|
|
|
|
export async function resolveRequiredSuccessfulRunHandoffOnValidPath(
|
|
db: Db,
|
|
input: {
|
|
companyId: string;
|
|
issueId: string;
|
|
issueIdentifier: string | null;
|
|
agentId: string;
|
|
runId: string;
|
|
skipReason: string;
|
|
},
|
|
) {
|
|
const latestHandoff = await db
|
|
.select({ action: activityLog.action, runId: activityLog.runId, details: activityLog.details })
|
|
.from(activityLog)
|
|
.where(and(
|
|
eq(activityLog.companyId, input.companyId),
|
|
eq(activityLog.entityType, "issue"),
|
|
eq(activityLog.entityId, input.issueId),
|
|
inArray(activityLog.action, [
|
|
"issue.successful_run_handoff_required",
|
|
"issue.successful_run_handoff_resolved",
|
|
"issue.successful_run_handoff_escalated",
|
|
]),
|
|
))
|
|
.orderBy(desc(activityLog.createdAt), desc(activityLog.id))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (latestHandoff?.action !== "issue.successful_run_handoff_required") return false;
|
|
|
|
const details = latestHandoff.details && typeof latestHandoff.details === "object"
|
|
? latestHandoff.details as Record<string, unknown>
|
|
: {};
|
|
const sourceRunId = [details.sourceRunId, details.source_run_id, details.resumeFromRunId]
|
|
.find((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
?.trim() ?? latestHandoff.runId;
|
|
await logActivity(db, {
|
|
companyId: input.companyId,
|
|
actorType: "system",
|
|
actorId: "heartbeat",
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
action: "issue.successful_run_handoff_resolved",
|
|
entityType: "issue",
|
|
entityId: input.issueId,
|
|
details: {
|
|
label: "Successful run handoff continuation confirmed",
|
|
sourceRunId,
|
|
resolvedByRunId: input.runId,
|
|
resolvedBySkipReason: input.skipReason,
|
|
issue: { id: input.issueId, identifier: input.issueIdentifier },
|
|
},
|
|
});
|
|
return true;
|
|
}
|