paperclip/server/src/services/recovery/successful-run-handoff.ts
Dotta d0e9cc76f2
Show workspace changes and stale notices in issue threads (#5356)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The issue thread is the operator's durable audit trail for what
changed and why
> - Workspace changes and stale disposition notices need to be visible
in that same timeline without noisy or misleading rendering
> - The local branch already contained backend activity details,
timeline conversion, and UI rendering work for those events
> - This pull request isolates the issue-thread activity work into a
standalone branch against `origin/master`
> - The benefit is a focused audit-trail PR that can merge independently
of the sidebar/operator UI polish branch

## What Changed

- Adds readable workspace-change activity details to issue update
activity events.
- Surfaces workspace-change events in issue chat/timeline rendering.
- Makes the existing issue comment migration idempotent.
- Folds and renders stale disposition notices inline so they match
activity-log styling and spacing.
- Adds focused route, timeline, and issue-thread system notice coverage.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
server/src/__tests__/issue-activity-events-routes.test.ts
ui/src/lib/issue-timeline-events.test.ts
ui/src/components/IssueChatThreadSystemNotice.test.tsx` — 3 files
passed, 22 tests passed.
- Confirmed the PR changes 9 files and does not include `pnpm-lock.yaml`
or `.github/workflows/*`.
- `pnpm exec vitest run
server/src/__tests__/issue-closed-workspace-routes.test.ts` — 1 file
passed, 4 tests passed.
- `pnpm exec vitest run
server/src/__tests__/issue-activity-events-routes.test.ts
ui/src/lib/issue-timeline-events.test.ts
ui/src/components/IssueChatThreadSystemNotice.test.tsx
server/src/services/recovery/successful-run-handoff.test.ts
packages/shared/src/validators/issue.test.ts` — 5 files passed, 54 tests
passed.
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/server typecheck && pnpm --filter @paperclipai/ui
typecheck`.
- `pnpm --filter @paperclipai/ui typecheck` after adding the Storybook
screenshot fixture.
- Captured Storybook screenshots for the new UI rendering paths:
- Collapsed stale notice + workspace-change row:
`docs/pr-screenshots/pr-5356/issue-thread-notices-collapsed.png`
- Expanded stale notice details:
`docs/pr-screenshots/pr-5356/issue-thread-notices-expanded.png`


### Screenshots

Collapsed stale notice with workspace-change row:

![Collapsed stale notice with workspace-change
row](docs/pr-screenshots/pr-5356/issue-thread-notices-collapsed.png)

Expanded stale notice details:

![Expanded stale notice
details](docs/pr-screenshots/pr-5356/issue-thread-notices-expanded.png)

## Risks

- Moderate risk: this touches issue activity serialization and
issue-thread rendering, both of which are central operator surfaces.
- Migration risk is low: the only migration change makes an existing
migration idempotent.
- No new migrations are introduced, so there is no cross-PR migration
ordering requirement.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, shell/tool-use enabled, used to
split the existing branch, verify the isolated PR branch, and create
this PR.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-06 09:00:54 -05:00

407 lines
15 KiB
TypeScript

import { and, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db";
import type { IssueCommentMetadata, IssueCommentPresentation, RunLivenessState } from "@paperclipai/shared";
import { withRecoveryModelProfileHint } from "./model-profile-hint.js";
export const FINISH_SUCCESSFUL_RUN_HANDOFF_REASON = "finish_successful_run_handoff";
export const SUCCESSFUL_RUN_MISSING_STATE_REASON = "successful_run_missing_state";
export const DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS = 1;
export const SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY =
"Paperclip needs a disposition before this issue can continue.";
export const SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY =
"Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner.";
export const LEGACY_SUCCESSFUL_RUN_HANDOFF_NOTICE_PREFIXES = [
"## This issue still needs a next step",
"## Successful run missing issue disposition",
] as const;
export const SUCCESSFUL_RUN_HANDOFF_OPTIONS = [
"mark_done_or_cancelled",
"send_for_review_or_ask_for_input",
"mark_blocked",
"delegate_or_continue_from_checkpoint",
] as const;
const PRODUCTIVE_SUCCESS_LIVENESS_STATES = new Set<RunLivenessState>([
"advanced",
"completed",
"blocked",
"needs_followup",
]);
const IDEMPOTENT_HANDOFF_WAKE_STATUSES = [
"queued",
"deferred_issue_execution",
"claimed",
"completed",
];
const IDEMPOTENT_HANDOFF_WAKE_STATUS_SET = new Set<string>(IDEMPOTENT_HANDOFF_WAKE_STATUSES);
export function isIdempotentFinishSuccessfulRunHandoffWakeStatus(status: string) {
return IDEMPOTENT_HANDOFF_WAKE_STATUS_SET.has(status);
}
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
type IssueRow = Pick<
typeof issues.$inferSelect,
"id" | "companyId" | "identifier" | "title" | "status" | "assigneeAgentId" | "assigneeUserId" | "executionState"
>;
type AgentRow = Pick<typeof agents.$inferSelect, "id" | "companyId" | "status">;
type NoticeIssue = Pick<typeof issues.$inferSelect, "id" | "identifier" | "title" | "status">;
type NoticeRun = Pick<typeof heartbeatRuns.$inferSelect, "id" | "status">;
type NoticeAgent = Pick<typeof agents.$inferSelect, "id" | "name">;
type NullableNoticeAgent = NoticeAgent | null | undefined;
type NullableNoticeIssue = NoticeIssue | null | undefined;
type NullableNoticeRun = NoticeRun | null | undefined;
export type SuccessfulRunHandoffNotice = {
body: string;
presentation: IssueCommentPresentation;
metadata: IssueCommentMetadata;
};
export type SuccessfulRunHandoffDecision =
| {
kind: "enqueue";
idempotencyKey: string;
payload: Record<string, unknown>;
contextSnapshot: Record<string, unknown>;
instruction: string;
}
| {
kind: "skip";
reason: string;
};
function metadataText(value: unknown, fallback = "unknown") {
const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
const resolved = text.length > 0 ? text : fallback;
return resolved.length > 2000 ? `${resolved.slice(0, 1997)}...` : resolved;
}
function keyValueRow(label: string, value: unknown): IssueCommentMetadata["sections"][number]["rows"][number] {
return { type: "key_value", label, value: metadataText(value) };
}
function issueLinkRow(
label: string,
issue: NullableNoticeIssue,
): IssueCommentMetadata["sections"][number]["rows"][number] {
if (!issue) return keyValueRow(label, "unknown");
return {
type: "issue_link",
label,
issueId: issue.id,
identifier: issue.identifier,
title: issue.title,
};
}
function runLinkRow(
label: string,
run: NullableNoticeRun,
): IssueCommentMetadata["sections"][number]["rows"][number] {
if (!run) return keyValueRow(label, "unknown");
return { type: "run_link", label, runId: run.id, title: run.status };
}
function agentLinkRow(
label: string,
agent: NullableNoticeAgent,
): IssueCommentMetadata["sections"][number]["rows"][number] {
if (!agent) return keyValueRow(label, "unknown");
return { type: "agent_link", label, agentId: agent.id, name: agent.name };
}
function systemNoticePresentation(input: {
tone: IssueCommentPresentation["tone"];
title: string;
}): IssueCommentPresentation {
return {
kind: "system_notice",
tone: input.tone,
title: input.title,
detailsDefaultOpen: false,
};
}
export function isSuccessfulRunHandoffRequiredNoticeBody(body: string) {
const trimmed = body.trim();
return trimmed === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY ||
LEGACY_SUCCESSFUL_RUN_HANDOFF_NOTICE_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
}
export function buildSuccessfulRunHandoffRequiredNotice(input: {
issue: NoticeIssue;
run: NoticeRun;
agent: NoticeAgent;
detectedProgressSummary: string;
}): SuccessfulRunHandoffNotice {
return {
body: SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
presentation: systemNoticePresentation({
tone: "warning",
title: "Missing issue disposition",
}),
metadata: {
version: 1,
sourceRunId: input.run.id,
sections: [
{
title: "Required action",
rows: [
issueLinkRow("Source issue", input.issue),
agentLinkRow("Assignee", input.agent),
keyValueRow("Missing disposition", "clear_next_step"),
keyValueRow(
"Valid dispositions",
"done, cancelled, in_review with an owner, blocked with blockers, delegated follow-up, or explicit continuation",
),
],
},
{
title: "Run evidence",
rows: [
runLinkRow("Successful run", input.run),
keyValueRow("Run status", input.run.status),
keyValueRow("Normalized cause", SUCCESSFUL_RUN_MISSING_STATE_REASON),
keyValueRow("Detected progress", input.detectedProgressSummary),
keyValueRow("Automatic retry", "one corrective handoff wake queued"),
],
},
],
},
};
}
export function buildSuccessfulRunHandoffExhaustedNotice(input: {
issue: NoticeIssue;
sourceRun: NullableNoticeRun;
correctiveRun: NullableNoticeRun;
sourceAssignee: NullableNoticeAgent;
recoveryIssue: NullableNoticeIssue;
recoveryOwner: NullableNoticeAgent;
latestIssueStatus: string;
latestHandoffRunStatus: string;
missingDisposition: string;
}): SuccessfulRunHandoffNotice {
return {
body: SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
presentation: systemNoticePresentation({
tone: "danger",
title: "Missing disposition recovery blocked",
}),
metadata: {
version: 1,
sourceRunId: input.sourceRun?.id ?? null,
sections: [
{
title: "Recovery owner",
rows: [
issueLinkRow("Source issue", input.issue),
issueLinkRow("Recovery issue", input.recoveryIssue),
agentLinkRow("Recovery owner", input.recoveryOwner),
agentLinkRow("Source assignee", input.sourceAssignee),
keyValueRow("Suggested action", "choose and record a valid issue disposition without copying transcript content"),
],
},
{
title: "Run evidence",
rows: [
runLinkRow("Source run", input.sourceRun),
runLinkRow("Corrective handoff run", input.correctiveRun),
keyValueRow("Latest issue status", input.latestIssueStatus),
keyValueRow("Latest handoff run status", input.latestHandoffRunStatus),
keyValueRow("Normalized cause", SUCCESSFUL_RUN_MISSING_STATE_REASON),
keyValueRow("Missing disposition", input.missingDisposition),
],
},
],
},
};
}
export function buildFinishSuccessfulRunHandoffIdempotencyKey(input: {
issueId: string;
sourceRunId: string;
attempt?: number;
}) {
return [
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
input.issueId,
input.sourceRunId,
String(input.attempt ?? 1),
].join(":");
}
export async function findExistingFinishSuccessfulRunHandoffWake(
db: Db,
input: {
companyId: string;
idempotencyKey: string;
},
) {
return db
.select({ id: agentWakeupRequests.id, status: agentWakeupRequests.status })
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, input.companyId),
eq(agentWakeupRequests.idempotencyKey, input.idempotencyKey),
inArray(agentWakeupRequests.status, IDEMPOTENT_HANDOFF_WAKE_STATUSES),
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
}
function readRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function readString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function isCorrectiveHandoffRun(run: HeartbeatRunRow) {
const context = readRecord(run.contextSnapshot);
return context.handoffRequired === true ||
readString(context.wakeReason) === FINISH_SUCCESSFUL_RUN_HANDOFF_REASON;
}
function isIssueMonitorMaintenanceRun(run: HeartbeatRunRow) {
const context = readRecord(run.contextSnapshot);
const wakeReason = readString(context.wakeReason);
const source = readString(context.source);
return Boolean(wakeReason?.startsWith("issue_monitor") || source?.startsWith("issue.monitor"));
}
function isProductiveSuccessfulRun(input: {
livenessState: RunLivenessState | null;
detectedProgressSummary: string | null;
}) {
if (input.livenessState && PRODUCTIVE_SUCCESS_LIVENESS_STATES.has(input.livenessState)) return true;
return Boolean(input.detectedProgressSummary);
}
export function buildSuccessfulRunHandoffInstruction(input: {
issueIdentifier: string | null;
sourceRunId: string;
}) {
const issueLabel = input.issueIdentifier ?? "this issue";
return [
`Your previous run on ${issueLabel} succeeded, but the issue is still in \`in_progress\` and Paperclip cannot identify a valid issue disposition.`,
"",
"Resolve the missing disposition before creating or revising any new artifacts. Choose **exactly one** outcome and perform the matching Paperclip action:",
"",
"**Is the issue finished?**",
"1. Mark it `done` (scope complete) or `cancelled` (intentionally stopped).",
"",
"**Does someone else need to look at it?**",
"2. Move it to `in_review` with a real reviewer path — `executionState.currentParticipant`, a human owner via `assigneeUserId`, a pending issue-thread interaction, or a linked pending approval.",
"",
"**Can it not continue right now?**",
"3. Mark it `blocked` with first-class blockers (`blockedByIssueIds`) or a clearly named unblock owner/action.",
"",
"**Is there more work to do?**",
`4. Either delegate follow-up work (create/link a follow-up issue and block this one on it, or close this issue if its scope is independently complete) or record an explicit continuation path with \`resumeIntent: true\`, \`resumeFromRunId: ${input.sourceRunId}\`, and a concrete next action.`,
"",
"Comments, document revisions, work-product writes, and continuation summaries are supporting evidence only — they do not satisfy this handoff unless the issue state/path also records one valid disposition.",
].join("\n");
}
export function decideSuccessfulRunHandoff(input: {
run: HeartbeatRunRow;
issue: IssueRow | null;
agent: AgentRow | null;
livenessState: RunLivenessState | null;
detectedProgressSummary: string | null;
taskKey: string | null;
hasActiveExecutionPath: boolean;
hasQueuedWake: boolean;
hasPendingInteractionOrApproval: boolean;
hasExplicitBlockerPath: boolean;
hasOpenRecoveryIssue: boolean;
hasPauseHold: boolean;
budgetBlocked: boolean;
idempotentWakeExists: boolean;
}): SuccessfulRunHandoffDecision {
const { run, issue, agent } = input;
if (run.status !== "succeeded") return { kind: "skip", reason: "source run did not succeed" };
if (isCorrectiveHandoffRun(run)) return { kind: "skip", reason: "source run is already a corrective handoff run" };
if (isIssueMonitorMaintenanceRun(run)) return { kind: "skip", reason: "issue monitor run owns its own recovery path" };
if (run.issueCommentStatus === "retry_queued" || run.issueCommentStatus === "retry_exhausted") {
return { kind: "skip", reason: "missing issue comment retry owns the next action" };
}
if (!issue) return { kind: "skip", reason: "issue not found" };
if (!agent) return { kind: "skip", reason: "agent not found" };
if (issue.companyId !== run.companyId || agent.companyId !== run.companyId) {
return { kind: "skip", reason: "company scope mismatch" };
}
if (issue.assigneeAgentId !== run.agentId) {
return { kind: "skip", reason: "issue is no longer assigned to the source run agent" };
}
if (issue.assigneeUserId) return { kind: "skip", reason: "issue is human-owned" };
if (issue.status !== "in_progress") return { kind: "skip", reason: `issue status ${issue.status} is a valid disposition` };
if (issue.executionState) return { kind: "skip", reason: "issue has execution policy state" };
if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") {
return { kind: "skip", reason: `agent status ${agent.status} is not invokable` };
}
if (!isProductiveSuccessfulRun(input)) {
return { kind: "skip", reason: "successful run did not produce handoff-relevant progress" };
}
if (input.hasActiveExecutionPath) return { kind: "skip", reason: "issue already has an active execution path" };
if (input.hasQueuedWake) return { kind: "skip", reason: "issue already has a queued or deferred wake" };
if (input.hasPendingInteractionOrApproval) {
return { kind: "skip", reason: "pending interaction or approval owns the next action" };
}
if (input.hasExplicitBlockerPath) return { kind: "skip", reason: "explicit blocker path owns the next action" };
if (input.hasOpenRecoveryIssue) return { kind: "skip", reason: "open recovery issue owns the ambiguity" };
if (input.hasPauseHold) return { kind: "skip", reason: "issue is under an active pause hold" };
if (input.budgetBlocked) return { kind: "skip", reason: "budget hard stop blocks corrective wake" };
if (input.idempotentWakeExists) {
return { kind: "skip", reason: "corrective handoff wake already exists for this source run" };
}
const instruction = buildSuccessfulRunHandoffInstruction({
issueIdentifier: issue.identifier,
sourceRunId: run.id,
});
const payload = withRecoveryModelProfileHint({
issueId: issue.id,
taskId: issue.id,
sourceIssueId: issue.id,
sourceRunId: run.id,
handoffRequired: true,
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
missingDisposition: "clear_next_step",
validDispositionOptions: [...SUCCESSFUL_RUN_HANDOFF_OPTIONS],
detectedProgressSummary: input.detectedProgressSummary,
handoffAttempt: 1,
maxHandoffAttempts: DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
resumeIntent: true,
followUpRequested: true,
resumeFromRunId: run.id,
...(input.taskKey ? { taskKey: input.taskKey } : {}),
instruction,
});
return {
kind: "enqueue",
idempotencyKey: buildFinishSuccessfulRunHandoffIdempotencyKey({
issueId: issue.id,
sourceRunId: run.id,
}),
payload,
instruction,
contextSnapshot: withRecoveryModelProfileHint({
...payload,
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
livenessState: input.livenessState,
}),
};
}