Add recovery handoff system notices (#5289)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Agent runs can end productively while the source issue still lacks a
durable final disposition.
> - That leaves the control plane unsure whether to resume, escalate, or
close the work.
> - Issue comments also need a presentation contract so system-authored
recovery notices can render as first-class thread messages without
overloading normal comments.
> - This pull request adds successful-run handoff recovery, comment
presentation metadata, and system notice rendering.
> - The benefit is stricter task liveness with clearer operator-facing
recovery state.

## What Changed

- Added successful-run handoff decisions, wake payloads, escalation
behavior, and recovery tests.
- Added issue comment presentation metadata with migration
`0078_white_darwin.sql` and shared/server/company portability support.
- Rendered recovery/system notices in issue chat with dedicated UI
components, fixtures, tests, and storybook/lab coverage.
- Included the current recovery model-profile hint patch so automatic
recovery follow-ups use the cheap profile.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
server/src/services/recovery/successful-run-handoff.test.ts
ui/src/components/SystemNotice.test.tsx
ui/src/lib/system-notice-comment.test.ts
ui/src/components/IssueChatThreadSystemNotice.test.tsx`

## Risks

- Migration-bearing PR: merge this before any other branch that might
later add a migration.
- The branch touches both recovery services and issue-thread rendering,
so review should pay attention to recovery wake idempotency and comment
metadata compatibility.

## Model Used

- OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with
shell/git/GitHub CLI tool use.

## 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>
This commit is contained in:
Dotta 2026-05-06 06:05:58 -05:00 committed by GitHub
parent 50db8c01d2
commit 454edfe81e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 21919 additions and 125 deletions

View file

@ -16,6 +16,7 @@ import type {
CompanyPortabilityImportResult,
CompanyPortabilityInclude,
CompanyPortabilityManifest,
CompanyPortabilityIssueCommentManifestEntry,
CompanyPortabilityPreview,
CompanyPortabilityPreviewAgentPlan,
CompanyPortabilityPreviewResult,
@ -42,6 +43,9 @@ import {
ROUTINE_TRIGGER_SIGNING_MODES,
deriveProjectUrlKey,
envConfigSchema,
issueCommentAuthorTypeSchema,
issueCommentMetadataSchema,
issueCommentPresentationSchema,
normalizeAgentUrlKey,
} from "@paperclipai/shared";
import {
@ -644,6 +648,96 @@ function asInteger(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) ? value : null;
}
function hasOwn(record: Record<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(record, key);
}
function readStringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const entries = value.filter((entry): entry is string => typeof entry === "string");
return entries.length === value.length ? entries : null;
}
function derivePortableCommentAuthorType(value: Record<string, unknown>) {
const explicit = issueCommentAuthorTypeSchema.safeParse(value.authorType);
if (explicit.success) return explicit.data;
return asString(value.authorAgentSlug) ? "agent" : asString(value.authorUserId) ? "user" : "system";
}
function readPortableIssueComments(
value: unknown,
warnings: string[],
sourceLabel: string,
): CompanyPortabilityIssueCommentManifestEntry[] {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) {
warnings.push(`${sourceLabel} comments were ignored because they are not an array.`);
return [];
}
const comments: CompanyPortabilityIssueCommentManifestEntry[] = [];
for (const [index, entry] of value.entries()) {
if (!isPlainRecord(entry)) {
warnings.push(`${sourceLabel} comment ${index + 1} was ignored because it is not an object.`);
continue;
}
const body = asString(entry.body);
if (!body) {
warnings.push(`${sourceLabel} comment ${index + 1} was ignored because it has no body.`);
continue;
}
const presentation = entry.presentation == null ? null : issueCommentPresentationSchema.safeParse(entry.presentation);
if (presentation && !presentation.success) {
warnings.push(`${sourceLabel} comment ${index + 1} has invalid presentation metadata and was ignored.`);
continue;
}
const metadata = entry.metadata == null ? null : issueCommentMetadataSchema.safeParse(entry.metadata);
if (metadata && !metadata.success) {
warnings.push(`${sourceLabel} comment ${index + 1} has invalid hidden metadata and was ignored.`);
continue;
}
const createdAt = asString(entry.createdAt);
comments.push({
body,
authorType: derivePortableCommentAuthorType(entry),
authorAgentSlug: asString(entry.authorAgentSlug),
authorUserId: asString(entry.authorUserId),
presentation: presentation ? presentation.data : null,
metadata: metadata ? metadata.data : null,
createdAt: createdAt && Number.isNaN(Date.parse(createdAt)) ? null : createdAt,
});
}
return comments;
}
function appendCodexImportArg(adapterConfig: Record<string, unknown>, arg: string) {
const extraArgs = readStringArray(adapterConfig.extraArgs);
if (extraArgs) {
if (!extraArgs.includes(arg)) adapterConfig.extraArgs = [...extraArgs, arg];
return;
}
const legacyArgs = readStringArray(adapterConfig.args);
if (legacyArgs && legacyArgs.length > 0) {
if (!legacyArgs.includes(arg)) adapterConfig.args = [...legacyArgs, arg];
return;
}
if (legacyArgs?.includes(arg)) return;
adapterConfig.extraArgs = [arg];
}
function applyImportAdapterRunDefaults(
adapterType: string,
adapterConfig: Record<string, unknown>,
) {
const next = { ...adapterConfig };
if (adapterType === "codex_local") {
appendCodexImportArg(next, "--skip-git-repo-check");
}
return next;
}
function normalizeRoutineTriggerExtension(value: unknown): CompanyPortabilityIssueRoutineTriggerManifestEntry | null {
if (!isPlainRecord(value)) return null;
const kind = asString(value.kind);
@ -2685,6 +2779,7 @@ function buildManifestFromPackageFiles(
assigneeAdapterOverrides: isPlainRecord(extension.assigneeAdapterOverrides)
? extension.assigneeAdapterOverrides
: null,
comments: readPortableIssueComments(extension.comments, warnings, `Task ${slug}`),
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
});
if (frontmatter.kind && frontmatter.kind !== "task") {
@ -2804,7 +2899,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) {
throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`);
}
const nextAdapterConfig = writePaperclipSkillSyncPreference({ ...adapterConfig }, desiredSkills);
const nextAdapterConfig = writePaperclipSkillSyncPreference(
applyImportAdapterRunDefaults(effectiveAdapterType, adapterConfig),
desiredSkills,
);
delete nextAdapterConfig.promptTemplate;
delete nextAdapterConfig.bootstrapPromptTemplate;
delete nextAdapterConfig.instructionsFilePath;
@ -3380,6 +3478,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
});
}
}
const comments = await issuesSvc.listComments(issue.id, { order: "asc" });
files[taskPath] = buildMarkdown(
{
name: issue.title,
@ -3397,6 +3496,20 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
projectWorkspaceKey: projectWorkspaceKey ?? undefined,
executionWorkspaceSettings: issue.executionWorkspaceSettings ?? undefined,
assigneeAdapterOverrides: issue.assigneeAdapterOverrides ?? undefined,
comments: comments.length > 0
? comments.map((comment) => ({
body: comment.body,
authorType: comment.authorType,
authorAgentSlug: comment.authorAgentId ? (idToSlug.get(comment.authorAgentId) ?? null) : null,
// Portable bundles preserve author kind, but not raw board user ids.
authorUserId: null,
presentation: comment.presentation,
metadata: comment.metadata,
createdAt: comment.createdAt instanceof Date
? comment.createdAt.toISOString()
: new Date(comment.createdAt).toISOString(),
}))
: undefined,
});
paperclipTasksOut[taskSlug] = isPlainRecord(extension) ? extension : {};
}
@ -4496,7 +4609,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
warnings.push(`Task ${manifestIssue.slug} was downgraded to todo because its assignee could not be imported as assignable work.`);
issueStatus = "todo";
}
await issues.create(targetCompany.id, {
const createdIssue = await issues.create(targetCompany.id, {
projectId,
projectWorkspaceId,
title: manifestIssue.title,
@ -4511,6 +4624,30 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
executionWorkspaceSettings: manifestIssue.executionWorkspaceSettings,
labelIds: manifestIssue.labelIds ?? [],
});
for (const comment of manifestIssue.comments ?? []) {
const authorAgentId = comment.authorType === "agent" && comment.authorAgentSlug
? importedSlugToAgentId.get(comment.authorAgentSlug)
?? existingSlugToAgentId.get(comment.authorAgentSlug)
?? null
: null;
if (comment.authorType === "agent" && comment.authorAgentSlug && !authorAgentId) {
warnings.push(`Comment on task ${manifestIssue.slug} was imported as a system comment because author agent ${comment.authorAgentSlug} was not imported.`);
}
const authorType = authorAgentId
? "agent"
: comment.authorType === "user"
? "user"
: "system";
await issues.addComment(createdIssue.id, comment.body, {
agentId: authorAgentId ?? undefined,
userId: authorType === "user" ? actorUserId ?? undefined : undefined,
}, {
authorType,
presentation: comment.presentation,
metadata: comment.metadata,
createdAt: comment.createdAt,
});
}
}
}

View file

@ -89,6 +89,9 @@ type FeedbackTargetRecord = {
createdAt: Date;
authorAgentId: string | null;
authorUserId: string | null;
authorType?: string | null;
presentation?: unknown;
metadata?: unknown;
createdByRunId: string | null;
documentId: string | null;
documentKey: string | null;
@ -797,6 +800,9 @@ async function resolveFeedbackTarget(
companyId: issueComments.companyId,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
authorType: issueComments.authorType,
presentation: issueComments.presentation,
metadata: issueComments.metadata,
createdByRunId: issueComments.createdByRunId,
body: issueComments.body,
createdAt: issueComments.createdAt,
@ -820,6 +826,9 @@ async function resolveFeedbackTarget(
createdAt: targetComment.createdAt,
authorAgentId: targetComment.authorAgentId,
authorUserId: targetComment.authorUserId,
authorType: targetComment.authorType ?? (targetComment.authorAgentId ? "agent" : targetComment.authorUserId ? "user" : "system"),
presentation: targetComment.presentation ?? null,
metadata: targetComment.metadata ?? null,
createdByRunId: targetComment.createdByRunId ?? null,
documentId: null,
documentKey: null,
@ -833,6 +842,9 @@ async function resolveFeedbackTarget(
createdAt: targetComment.createdAt.toISOString(),
authorAgentId: targetComment.authorAgentId,
authorUserId: targetComment.authorUserId,
authorType: targetComment.authorType ?? (targetComment.authorAgentId ? "agent" : targetComment.authorUserId ? "user" : "system"),
presentation: targetComment.presentation ?? null,
metadata: targetComment.metadata ?? null,
createdByRunId: targetComment.createdByRunId ?? null,
issuePath,
targetPath: issuePath ? `${issuePath}#comment-${targetComment.id}` : null,
@ -918,6 +930,9 @@ async function listIssueContextItems(
createdAt: issueComments.createdAt,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
authorType: issueComments.authorType,
presentation: issueComments.presentation,
metadata: issueComments.metadata,
createdByRunId: issueComments.createdByRunId,
})
.from(issueComments)
@ -952,6 +967,9 @@ async function listIssueContextItems(
createdAt: row.createdAt,
authorAgentId: row.authorAgentId,
authorUserId: row.authorUserId,
authorType: row.authorType ?? (row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"),
presentation: row.presentation ?? null,
metadata: row.metadata ?? null,
createdByRunId: row.createdByRunId ?? null,
documentId: null,
documentKey: null,
@ -1023,6 +1041,9 @@ async function buildIssueContext(
createdAt: item.createdAt.toISOString(),
authorAgentId: item.authorAgentId,
authorUserId: item.authorUserId,
authorType: item.authorType ?? null,
presentation: item.presentation ?? null,
metadata: item.metadata ?? null,
createdByRunId: item.createdByRunId,
documentKey: item.documentKey,
documentTitle: item.documentTitle,

View file

@ -26,13 +26,16 @@ import {
agentTaskSessions,
agentWakeupRequests,
activityLog,
approvals,
companySkills as companySkillsTable,
documentRevisions,
issueDocuments,
heartbeatRunEvents,
heartbeatRuns,
issueApprovals,
issueComments,
issueRelations,
issueThreadInteractions,
issues,
issueWorkProducts,
projects,
@ -119,18 +122,33 @@ import {
import { instanceSettingsService } from "./instance-settings.js";
import {
RECOVERY_ORIGIN_KINDS,
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
RUN_LIVENESS_CONTINUATION_REASON,
buildRunLivenessContinuationIdempotencyKey,
buildFinishSuccessfulRunHandoffIdempotencyKey,
buildSuccessfulRunHandoffRequiredNotice,
decideRunLivenessContinuation,
decideSuccessfulRunHandoff,
findExistingFinishSuccessfulRunHandoffWake,
findExistingRunLivenessContinuationWake,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
readContinuationAttempt,
} from "./recovery/index.js";
import { isAutomaticRecoverySuppressedByPauseHold } from "./recovery/pause-hold-guard.js";
import {
recoveryAssigneeAdapterOverrides,
withRecoveryModelProfileHint,
} from "./recovery/model-profile-hint.js";
import { recoveryService } from "./recovery/service.js";
import { productivityReviewService } from "./productivity-review.js";
import { withAgentStartLock } from "./agent-start-lock.js";
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
import { redactEventPayload } from "../redaction.js";
import {
redactCurrentUserText,
redactCurrentUserValue,
type CurrentUserRedactionOptions,
} from "../log-redaction.js";
import { redactEventPayload, redactSensitiveText } from "../redaction.js";
import {
hasSessionCompactionThresholds,
resolveSessionCompactionPolicy,
@ -150,6 +168,16 @@ const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024;
const MAX_RUN_EVENT_PAYLOAD_STRING_CHARS = 16 * 1024;
const MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS = 50;
export function redactDetectedSuccessfulRunProgressSummaryForBoard(
summary: string,
currentUserRedactionOptions?: CurrentUserRedactionOptions,
) {
const normalized = summary.replace(/\s+/g, " ").trim();
const redacted = redactSensitiveText(redactCurrentUserText(normalized, currentUserRedactionOptions));
return redacted.length <= 280 ? redacted : `${redacted.slice(0, 277)}...`;
}
const MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS = 100;
const MAX_RUN_EVENT_PAYLOAD_DEPTH = 6;
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = AGENT_DEFAULT_MAX_CONCURRENT_RUNS;
@ -1837,8 +1865,11 @@ async function buildPaperclipWakePayload(input: {
id: issueComments.id,
issueId: issueComments.issueId,
body: issueComments.body,
authorType: issueComments.authorType,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
presentation: issueComments.presentation,
metadata: issueComments.metadata,
createdAt: issueComments.createdAt,
})
.from(issueComments)
@ -1882,8 +1913,11 @@ async function buildPaperclipWakePayload(input: {
comments.push({
id: row.id,
issueId: row.issueId,
authorType: row.authorType ?? (row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"),
body,
bodyTruncated,
presentation: row.presentation ?? null,
metadata: row.metadata ?? null,
createdAt: row.createdAt.toISOString(),
author: row.authorAgentId
? { type: "agent", id: row.authorAgentId }
@ -2541,6 +2575,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
projectId: input.claimed.projectId,
goalId: input.claimed.goalId,
assigneeAgentId: input.claimed.assigneeAgentId,
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
originKind: RECOVERY_ORIGIN_KINDS.strandedIssueRecovery,
originId: input.claimed.id,
originFingerprint: `issue_monitor:${input.clearReason}`,
@ -2554,15 +2589,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
triggerDetail: "system",
reason: "issue_monitor_recovery_issue",
idempotencyKey: `issue-monitor-recovery-issue:${input.claimed.id}:${input.clearReason}:${input.scheduledAtIso}`,
payload: { issueId: recoveryIssue.id, sourceIssueId: input.claimed.id },
payload: withRecoveryModelProfileHint({ issueId: recoveryIssue.id, sourceIssueId: input.claimed.id }),
requestedByActorType: input.actorType,
requestedByActorId: input.actorId,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: recoveryIssue.id,
sourceIssueId: input.claimed.id,
source: "issue.monitor.recovery_issue",
wakeReason: "issue_monitor_recovery_issue",
},
}),
});
}
@ -2615,7 +2650,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
triggerDetail: "system",
reason: "issue_monitor_recovery",
idempotencyKey: `issue-monitor-recovery:${input.claimed.id}:${input.clearReason}:${input.scheduledAtIso}`,
payload: {
payload: withRecoveryModelProfileHint({
issueId: input.claimed.id,
monitorAttemptCount: input.nextAttemptCount,
monitorNotes: input.claimed.monitorNotes ?? null,
@ -2623,10 +2658,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
serviceName: input.monitor?.serviceName ?? null,
timeoutAt: input.monitor?.timeoutAt ?? null,
maxAttempts: input.monitor?.maxAttempts ?? null,
},
}),
requestedByActorType: input.actorType,
requestedByActorId: input.actorId,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: input.claimed.id,
source: "issue.monitor.recovery",
wakeReason: "issue_monitor_recovery",
@ -2636,7 +2671,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
serviceName: input.monitor?.serviceName ?? null,
timeoutAt: input.monitor?.timeoutAt ?? null,
maxAttempts: input.monitor?.maxAttempts ?? null,
},
}),
});
await logActivity(db, {
@ -3817,6 +3852,287 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
}
function issueUiLink(issue: Pick<typeof issues.$inferSelect, "id" | "identifier">) {
const label = issue.identifier ?? issue.id;
const prefix = issue.identifier?.split("-")[0] || "PAP";
return `[${label}](/${prefix}/issues/${label})`;
}
async function buildDetectedSuccessfulRunProgressSummary(run: typeof heartbeatRuns.$inferSelect) {
const resultJson = parseObject(run.resultJson);
const candidates = [
readNonEmptyString(run.nextAction) ? `Next action noted: ${readNonEmptyString(run.nextAction)}` : null,
readNonEmptyString(run.livenessReason),
readNonEmptyString(resultJson.summary),
readNonEmptyString(resultJson.result),
readNonEmptyString(resultJson.message),
].filter((value): value is string => Boolean(value));
const summary = candidates[0];
if (!summary) return null;
return redactDetectedSuccessfulRunProgressSummaryForBoard(
summary,
await getCurrentUserRedactionOptions(),
);
}
async function addSuccessfulRunHandoffCommentOnce(input: {
issue: Pick<typeof issues.$inferSelect, "id" | "identifier" | "title" | "status">;
run: typeof heartbeatRuns.$inferSelect;
agent: Pick<typeof agents.$inferSelect, "id" | "name">;
detectedProgressSummary: string;
}) {
const existing = await db
.select({ id: issueComments.id })
.from(issueComments)
.where(
and(
eq(issueComments.companyId, input.run.companyId),
eq(issueComments.issueId, input.issue.id),
eq(issueComments.createdByRunId, input.run.id),
sql`(${issueComments.body} = ${SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY} or ${issueComments.body} like '## This issue still needs a next step%' or ${issueComments.body} like '## Successful run missing issue disposition%')`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
if (existing) return null;
const notice = buildSuccessfulRunHandoffRequiredNotice(input);
return issuesSvc.addComment(
input.issue.id,
notice.body,
{ runId: input.run.id },
{
authorType: "system",
presentation: notice.presentation,
metadata: notice.metadata,
},
);
}
async function handleSuccessfulRunHandoff(run: typeof heartbeatRuns.$inferSelect, agent: typeof agents.$inferSelect) {
if (run.status !== "succeeded") return;
const context = parseObject(run.contextSnapshot);
const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
if (!issueId) return;
const issue = await db
.select({
id: issues.id,
companyId: issues.companyId,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
executionState: issues.executionState,
projectId: issues.projectId,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)))
.then((rows) => rows[0] ?? null);
const idempotencyKey = issue
? buildFinishSuccessfulRunHandoffIdempotencyKey({
issueId: issue.id,
sourceRunId: run.id,
})
: null;
const taskKey = deriveTaskKeyWithHeartbeatFallback(context, null);
const detectedProgressSummary = await buildDetectedSuccessfulRunProgressSummary(run);
const [
activeExecutionPath,
queuedWake,
pendingInteraction,
pendingApproval,
explicitBlocker,
openRecoveryIssue,
existingWake,
budgetBlock,
pauseHold,
] = await Promise.all([
issue
? db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, issue.companyId),
eq(heartbeatRuns.agentId, run.agentId),
inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]),
sql`(
${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}
or ${heartbeatRuns.contextSnapshot} ->> 'taskId' = ${issue.id}
)`,
sql`${heartbeatRuns.id} <> ${run.id}`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
issue
? db
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, issue.companyId),
eq(agentWakeupRequests.agentId, run.agentId),
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution", "claimed"]),
sql`(
${agentWakeupRequests.payload} ->> 'issueId' = ${issue.id}
or ${agentWakeupRequests.payload} ->> 'taskId' = ${issue.id}
or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId' = ${issue.id}
or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId' = ${issue.id}
)`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
issue
? db
.select({ id: issueThreadInteractions.id })
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.status, "pending"),
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
issue
? db
.select({ id: issueApprovals.approvalId })
.from(issueApprovals)
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
.where(
and(
eq(issueApprovals.companyId, issue.companyId),
eq(issueApprovals.issueId, issue.id),
inArray(approvals.status, ["pending", "revision_requested"]),
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
issue
? db
.select({ id: issueRelations.issueId })
.from(issueRelations)
.where(
and(
eq(issueRelations.companyId, issue.companyId),
eq(issueRelations.relatedIssueId, issue.id),
eq(issueRelations.type, "blocks"),
sql`exists (
select 1
from issues blocker
where blocker.id = ${issueRelations.issueId}
and blocker.company_id = ${issue.companyId}
and blocker.status not in ('done', 'cancelled')
and blocker.hidden_at is null
)`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
issue
? db
.select({ id: issues.id })
.from(issues)
.where(
and(
eq(issues.companyId, issue.companyId),
inArray(issues.originKind, [
RECOVERY_ORIGIN_KINDS.strandedIssueRecovery,
RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation,
]),
eq(issues.originId, issue.id),
isNull(issues.hiddenAt),
notInArray(issues.status, ["done", "cancelled"]),
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
idempotencyKey
? findExistingFinishSuccessfulRunHandoffWake(db, {
companyId: run.companyId,
idempotencyKey,
})
: Promise.resolve(null),
issue
? budgets.getInvocationBlock(issue.companyId, run.agentId, {
issueId: issue.id,
projectId: issue.projectId,
})
: Promise.resolve(null),
issue
? treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id)
: Promise.resolve(null),
]);
const decision = decideSuccessfulRunHandoff({
run,
issue,
agent,
livenessState: run.livenessState as RunLivenessState | null,
detectedProgressSummary,
taskKey,
hasActiveExecutionPath: Boolean(activeExecutionPath),
hasQueuedWake: Boolean(queuedWake),
hasPendingInteractionOrApproval: Boolean(pendingInteraction || pendingApproval),
hasExplicitBlockerPath: Boolean(explicitBlocker),
hasOpenRecoveryIssue: Boolean(openRecoveryIssue),
hasPauseHold: Boolean(pauseHold),
budgetBlocked: Boolean(budgetBlock),
idempotentWakeExists: Boolean(existingWake),
});
if (decision.kind !== "enqueue" || !issue) return;
const handoffRun = await enqueueWakeup(run.agentId, {
source: "automation",
triggerDetail: "system",
reason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
payload: decision.payload,
contextSnapshot: decision.contextSnapshot,
idempotencyKey: decision.idempotencyKey,
requestedByActorType: "system",
requestedByActorId: "heartbeat",
});
if (!handoffRun) return;
await addSuccessfulRunHandoffCommentOnce({
issue,
run,
agent,
detectedProgressSummary: detectedProgressSummary ?? "The run reported progress, but did not choose a next step.",
});
await logActivity(db, {
companyId: issue.companyId,
actorType: "system",
actorId: "heartbeat",
agentId: run.agentId,
runId: run.id,
action: "issue.successful_run_handoff_required",
entityType: "issue",
entityId: issue.id,
details: {
label: "Successful run missing issue disposition",
sourceRunId: run.id,
correctiveRunId: handoffRun.id,
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
missingDisposition: "clear_next_step",
detectedProgressSummary,
issue: issueUiLink(issue),
},
});
}
async function appendRunEvent(
run: typeof heartbeatRuns.$inferSelect,
seq: number,
@ -3998,13 +4314,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const contextSnapshot = parseObject(run.contextSnapshot);
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const retryContextSnapshot = {
const retryContextSnapshot = withRecoveryModelProfileHint({
...contextSnapshot,
retryOfRunId: run.id,
wakeReason: "missing_issue_comment",
retryReason: "missing_issue_comment",
missingIssueCommentForRunId: run.id,
};
});
const now = new Date();
const retryRun = await db.transaction(async (tx) => {
@ -4027,11 +4343,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
source: "automation",
triggerDetail: "system",
reason: "missing_issue_comment",
payload: {
payload: withRecoveryModelProfileHint({
issueId,
retryOfRunId: run.id,
retryReason: "missing_issue_comment",
},
}),
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
@ -4219,12 +4535,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const issueId = readNonEmptyString(contextSnapshot.issueId);
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const retryContextSnapshot = {
const retryContextSnapshot = withRecoveryModelProfileHint({
...contextSnapshot,
retryOfRunId: run.id,
wakeReason: "process_lost_retry",
retryReason: "process_lost",
};
});
const queued = await db.transaction(async (tx) => {
const wakeupRequest = await tx
@ -4235,10 +4551,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
source: "automation",
triggerDetail: "system",
reason: "process_lost_retry",
payload: {
payload: withRecoveryModelProfileHint({
...(issueId ? { issueId } : {}),
retryOfRunId: run.id,
},
}),
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
@ -4675,7 +4991,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const retryContextSnapshot: Record<string, unknown> = {
const retryContextSnapshot: Record<string, unknown> = withRecoveryModelProfileHint({
...contextSnapshot,
retryOfRunId: run.id,
wakeReason,
@ -4685,7 +5001,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
scheduledRetryAt: schedule.dueAt.toISOString(),
...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}),
};
});
const maxTurnContinuationIdempotencyKey = retryReason === MAX_TURN_CONTINUATION_RETRY_REASON
? `max-turn-continuation:${run.companyId}:${issueId ?? "no-issue"}:${run.id}:${schedule.attempt}`
: null;
@ -4846,7 +5162,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
source: "automation",
triggerDetail: "system",
reason: wakeReason,
payload: {
payload: withRecoveryModelProfileHint({
...(issueId ? { issueId } : {}),
retryOfRunId: run.id,
retryReason,
@ -4855,7 +5171,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
scheduledRetryAt: schedule.dueAt.toISOString(),
...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}),
},
}),
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
@ -6171,6 +6487,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
.select({
id: issueComments.id,
body: issueComments.body,
authorType: issueComments.authorType,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
presentation: issueComments.presentation,
metadata: issueComments.metadata,
})
.from(issueComments)
.where(and(
@ -7235,9 +7556,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
} else if (outcome === "failed" && readTransientRecoveryContractFromRun(livenessRun)) {
await scheduleBoundedRetryForRun(livenessRun, agent);
}
await finalizeIssueCommentPolicy(livenessRun, agent);
const issueCommentPolicyResult = await finalizeIssueCommentPolicy(livenessRun, agent);
await releaseIssueExecutionAndPromote(livenessRun);
await handleRunLivenessContinuation(livenessRun);
await handleSuccessfulRunHandoff(
issueCommentPolicyResult.outcome === "retry_queued" || issueCommentPolicyResult.outcome === "retry_exhausted"
? {
...livenessRun,
issueCommentStatus: issueCommentPolicyResult.outcome,
}
: livenessRun,
agent,
);
}
if (finalizedRun) {
@ -7739,10 +8069,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
source: "automation",
triggerDetail: "system",
reason: recoveryReason,
payload: {
payload: withRecoveryModelProfileHint({
issueId: issue.id,
retryOfRunId: run.id,
},
}),
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
@ -7760,14 +8090,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
triggerDetail: "system",
status: "queued",
wakeupRequestId: wakeupRequest.id,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: issue.id,
taskId: issue.id,
wakeReason: recoveryReason,
retryReason,
source: recoverySource,
retryOfRunId: run.id,
},
}),
sessionIdBefore: recoverySessionBefore,
retryOfRunId: run.id,
updatedAt: now,

View file

@ -28,6 +28,9 @@ import {
projects,
} from "@paperclipai/db";
import type {
IssueCommentAuthorType,
IssueCommentMetadata,
IssueCommentPresentation,
IssueBlockerAttention,
IssueProductivityReview,
IssueProductivityReviewTrigger,
@ -37,6 +40,9 @@ import {
clampIssueRequestDepth,
extractAgentMentionIds,
extractProjectMentionIds,
issueCommentAuthorTypeSchema,
issueCommentMetadataSchema,
issueCommentPresentationSchema,
isUuidLike,
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
} from "@paperclipai/shared";
@ -1679,10 +1685,47 @@ export function issueService(db: Db) {
return enriched;
}
function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
function deriveIssueCommentAuthorType(comment: {
authorType?: string | null;
authorAgentId?: string | null;
authorUserId?: string | null;
}): IssueCommentAuthorType {
const explicit = issueCommentAuthorTypeSchema.safeParse(comment.authorType);
if (explicit.success) return explicit.data;
if (comment.authorAgentId) return "agent";
if (comment.authorUserId) return "user";
return "system";
}
function assertIssueCommentAuthorTypeAllowed(
actor: { agentId?: string | null; userId?: string | null },
authorType: IssueCommentAuthorType,
) {
if (actor.agentId && authorType !== "agent") {
throw unprocessable("Comment authorType must match authenticated actor");
}
if (actor.userId && authorType !== "user") {
throw unprocessable("Comment authorType must match authenticated actor");
}
if (!actor.agentId && !actor.userId && authorType !== "system") {
throw unprocessable("System comments cannot use user or agent authorType without an author id");
}
}
function redactIssueComment<T extends { body: string; authorType?: string | null; authorAgentId?: string | null; authorUserId?: string | null; presentation?: unknown; metadata?: unknown }>(
comment: T,
censorUsernameInLogs: boolean,
): T & {
authorType: IssueCommentAuthorType;
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;
} {
return {
...comment,
authorType: deriveIssueCommentAuthorType(comment),
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
presentation: issueCommentPresentationSchema.nullable().catch(null).parse(comment.presentation ?? null),
metadata: issueCommentMetadataSchema.nullable().catch(null).parse(comment.metadata ?? null),
};
}
@ -3743,6 +3786,12 @@ export function issueService(db: Db) {
issueId: string,
body: string,
actor: { agentId?: string; userId?: string; runId?: string | null },
options?: {
authorType?: IssueCommentAuthorType | null;
presentation?: IssueCommentPresentation | null;
metadata?: IssueCommentMetadata | null;
createdAt?: Date | string | null;
},
) => {
const issue = await db
.select({ companyId: issues.companyId })
@ -3756,6 +3805,13 @@ export function issueService(db: Db) {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
const authorType = issueCommentAuthorTypeSchema.parse(
options?.authorType ?? (actor.agentId ? "agent" : actor.userId ? "user" : "system"),
);
assertIssueCommentAuthorTypeAllowed(actor, authorType);
const presentation = issueCommentPresentationSchema.nullable().parse(options?.presentation ?? null);
const metadata = issueCommentMetadataSchema.nullable().parse(options?.metadata ?? null);
const createdAt = options?.createdAt ? new Date(options.createdAt) : null;
const [comment] = await db
.insert(issueComments)
.values({
@ -3763,8 +3819,12 @@ export function issueService(db: Db) {
issueId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
authorType,
createdByRunId: actor.runId ?? null,
body: redactedBody,
presentation,
metadata,
...(createdAt && !Number.isNaN(createdAt.getTime()) ? { createdAt } : {}),
})
.returning();

View file

@ -14,6 +14,10 @@ import { logger } from "../middleware/logger.js";
import { logActivity } from "./activity-log.js";
import { budgetService } from "./budgets.js";
import { issueService } from "./issues.js";
import {
recoveryAssigneeAdapterOverrides,
withRecoveryModelProfileHint,
} from "./recovery/model-profile-hint.js";
import { RECOVERY_ORIGIN_KINDS } from "./recovery/origins.js";
export const PRODUCTIVITY_REVIEW_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.issueProductivityReview;
@ -687,6 +691,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
goalId: evidence.sourceIssue.goalId,
billingCode: evidence.sourceIssue.billingCode,
assigneeAgentId: ownerAgentId,
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
originId: evidence.sourceIssue.id,
originFingerprint: productivityReviewFingerprint(evidence.sourceIssue.id),
@ -732,21 +737,21 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: review.id,
sourceIssueId: evidence.sourceIssue.id,
trigger: evidence.trigger,
},
}),
requestedByActorType: "system",
requestedByActorId: "productivity_review",
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: review.id,
taskId: review.id,
wakeReason: "issue_assigned",
source: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
sourceIssueId: evidence.sourceIssue.id,
productivityReviewTrigger: evidence.trigger,
},
}),
});
}

View file

@ -42,3 +42,23 @@ export {
export type {
RunContinuationDecision,
} from "./run-liveness-continuations.js";
export {
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
LEGACY_SUCCESSFUL_RUN_HANDOFF_NOTICE_PREFIXES,
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_OPTIONS,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
buildFinishSuccessfulRunHandoffIdempotencyKey,
buildSuccessfulRunHandoffExhaustedNotice,
buildSuccessfulRunHandoffInstruction,
buildSuccessfulRunHandoffRequiredNotice,
decideSuccessfulRunHandoff,
findExistingFinishSuccessfulRunHandoffWake,
isSuccessfulRunHandoffRequiredNoticeBody,
} from "./successful-run-handoff.js";
export type {
SuccessfulRunHandoffNotice,
SuccessfulRunHandoffDecision,
} from "./successful-run-handoff.js";

View file

@ -0,0 +1,14 @@
export const RECOVERY_MODEL_PROFILE_KEY = "cheap" as const;
export function withRecoveryModelProfileHint<T extends Record<string, unknown>>(
input: T,
): T & { modelProfile: typeof RECOVERY_MODEL_PROFILE_KEY } {
return {
...input,
modelProfile: RECOVERY_MODEL_PROFILE_KEY,
};
}
export function recoveryAssigneeAdapterOverrides() {
return { modelProfile: RECOVERY_MODEL_PROFILE_KEY };
}

View file

@ -2,6 +2,7 @@ import { and, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db";
import type { RunLivenessState } from "@paperclipai/shared";
import { withRecoveryModelProfileHint } from "./model-profile-hint.js";
import { RECOVERY_REASON_KINDS } from "./origins.js";
export const RUN_LIVENESS_CONTINUATION_REASON = RECOVERY_REASON_KINDS.runLivenessContinuation;
@ -155,7 +156,7 @@ export function decideRunLivenessContinuation(input: {
return { kind: "skip", reason: "continuation wake already exists for this source run and attempt" };
}
const payload = {
const payload = withRecoveryModelProfileHint({
issueId: issue.id,
sourceRunId: run.id,
livenessState,
@ -165,14 +166,14 @@ export function decideRunLivenessContinuation(input: {
instruction:
nextAction ??
"The previous run ended without concrete progress. Take the first concrete action now or mark the issue blocked with a specific unblock request.",
};
});
return {
kind: "enqueue",
nextAttempt,
idempotencyKey,
payload,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: issue.id,
taskId: issue.id,
taskKey: issue.id,
@ -183,6 +184,6 @@ export function decideRunLivenessContinuation(input: {
livenessContinuationState: livenessState,
livenessContinuationReason: livenessReason,
livenessContinuationInstruction: payload.instruction,
},
}),
};
}

View file

@ -32,6 +32,13 @@ import { instanceSettingsService } from "../instance-settings.js";
import { issueTreeControlService } from "../issue-tree-control.js";
import { issueService } from "../issues.js";
import { getRunLogStore } from "../run-log-store.js";
import {
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
buildSuccessfulRunHandoffExhaustedNotice,
type SuccessfulRunHandoffNotice,
} from "./successful-run-handoff.js";
import {
RECOVERY_ORIGIN_KINDS,
buildIssueGraphLivenessLeafKey,
@ -42,6 +49,10 @@ import {
classifyIssueGraphLiveness,
type IssueLivenessFinding,
} from "./issue-graph-liveness.js";
import {
recoveryAssigneeAdapterOverrides,
withRecoveryModelProfileHint,
} from "./model-profile-hint.js";
import { isAutomaticRecoverySuppressedByPauseHold } from "./pause-hold-guard.js";
const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const;
@ -76,6 +87,16 @@ type LatestIssueRun = Pick<
> | null;
type SuccessfulLatestIssueRun = NonNullable<LatestIssueRun> & { status: "succeeded" };
type StrandedRecoveryCause = "stranded_assigned_issue" | typeof SUCCESSFUL_RUN_MISSING_STATE_REASON;
type SuccessfulRunHandoffRecoveryEvidence = {
sourceRunId: string | null;
correctiveRunId: string;
missingDisposition: string;
handoffAttempt: number;
maxHandoffAttempts: number;
};
type WatchdogDecisionActor =
| { type: "board"; userId?: string | null; runId?: string | null }
| { type: "agent"; agentId?: string | null; runId?: string | null }
@ -123,6 +144,39 @@ function didAutomaticRecoveryFail(
);
}
function successfulRunHandoffRecoveryEvidence(latestRun: LatestIssueRun): SuccessfulRunHandoffRecoveryEvidence | null {
if (!latestRun) return null;
const context = parseObject(latestRun.contextSnapshot);
const wakeReason = readNonEmptyString(context.wakeReason);
const handoffReason = readNonEmptyString(context.handoffReason);
const isSuccessfulRunHandoff =
wakeReason === FINISH_SUCCESSFUL_RUN_HANDOFF_REASON ||
handoffReason === SUCCESSFUL_RUN_MISSING_STATE_REASON ||
asBoolean(context.handoffRequired, false) === true;
if (!isSuccessfulRunHandoff) return null;
const handoffAttempt = asNumber(context.handoffAttempt, 1);
const maxHandoffAttempts = asNumber(
context.maxHandoffAttempts,
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
);
return {
sourceRunId: readNonEmptyString(context.sourceRunId) ?? readNonEmptyString(context.resumeFromRunId),
correctiveRunId: latestRun.id,
missingDisposition: readNonEmptyString(context.missingDisposition) ?? "clear_next_step",
handoffAttempt,
maxHandoffAttempts,
};
}
function isExhaustedSuccessfulRunHandoff(latestRun: LatestIssueRun) {
const evidence = successfulRunHandoffRecoveryEvidence(latestRun);
if (!evidence) return null;
if (evidence.handoffAttempt < evidence.maxHandoffAttempts) return { ...evidence, exhausted: false };
return { ...evidence, exhausted: true };
}
function issueIdFromRunContext(contextSnapshot: unknown) {
const context = parseObject(contextSnapshot);
return readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
@ -145,6 +199,11 @@ function runUiLink(run: { id: string; agentId: string }, prefix: string) {
return `[${run.id}](/${prefix}/agents/${run.agentId}/runs/${run.id})`;
}
function agentUiLink(agent: { id: string; name: string | null } | null, prefix: string) {
if (!agent) return "unknown";
return `[${agent.name ?? agent.id}](/${prefix}/agents/${agent.id})`;
}
function formatDuration(ms: number | null) {
if (ms === null) return "unknown";
const minutes = Math.floor(ms / 60_000);
@ -391,20 +450,20 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "automation",
triggerDetail: "system",
reason: input.reason,
payload: {
payload: withRecoveryModelProfileHint({
issueId: input.issueId,
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
},
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: input.issueId,
taskId: input.issueId,
wakeReason: input.reason,
retryReason: input.retryReason,
source: input.source,
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
},
}),
});
if (queued && input.retryOfRunId) {
@ -427,18 +486,18 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: issue.id,
mutation: "assigned_todo_liveness_dispatch",
},
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: issue.id,
taskId: issue.id,
wakeReason: "issue_assigned",
source: "issue.assigned_todo_liveness_dispatch",
},
}),
});
}
@ -542,18 +601,18 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "automation",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: candidate.id,
mutation: "unassigned_blocker_recovery",
},
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: candidate.id,
taskId: candidate.id,
wakeReason: "issue_assigned",
source: "issue.unassigned_blocker_recovery",
},
}),
});
if (queued) {
@ -995,6 +1054,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
goalId: sourceIssue?.goalId ?? null,
billingCode: sourceIssue?.billingCode ?? null,
assigneeAgentId: ownerAgentId,
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
originKind: STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND,
originId: input.run.id,
originRunId: input.run.id,
@ -1036,21 +1096,21 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: evaluation.id,
staleRunId: input.run.id,
sourceIssueId: sourceIssue?.id ?? null,
},
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: evaluation.id,
taskId: evaluation.id,
wakeReason: "issue_assigned",
source: STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND,
staleRunId: input.run.id,
sourceIssueId: sourceIssue?.id ?? null,
},
}),
});
}
return { kind: "created" as const, evaluationIssueId: evaluation.id };
@ -1294,11 +1354,45 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
latestRun: LatestIssueRun;
previousStatus: "todo" | "in_progress";
prefix: string;
recoveryCause?: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
sourceAssignee?: Pick<typeof agents.$inferSelect, "id" | "name"> | null;
}) {
const sourceIssue = issueUiLink({ identifier: input.issue.identifier, id: input.issue.id }, input.prefix);
const runLink = input.latestRun
? `[\`${input.latestRun.id}\`](/${input.prefix}/agents/${input.latestRun.agentId}/runs/${input.latestRun.id})`
: "none";
if (input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON) {
const sourceRunId = input.successfulRunHandoffEvidence?.sourceRunId;
const sourceRunLink = sourceRunId && input.latestRun
? `[\`${sourceRunId}\`](/${input.prefix}/agents/${input.latestRun.agentId}/runs/${sourceRunId})`
: "unknown";
const missingDisposition = input.successfulRunHandoffEvidence?.missingDisposition ?? "clear_next_step";
return [
"Paperclip exhausted the bounded corrective handoff for a successful run that still has no valid issue disposition.",
"",
"This is not a runtime/adapter crash report. The source run succeeded; the remaining problem is the missing `done`, `in_review`, `blocked`, delegated follow-up, or explicit continuation path.",
"",
"## Safe Evidence",
"",
`- Source issue: ${sourceIssue}`,
`- Source run: ${sourceRunLink}`,
`- Corrective handoff run: ${runLink}`,
`- Source assignee: ${agentUiLink(input.sourceAssignee ?? null, input.prefix)}`,
`- Latest issue status: \`${input.issue.status}\``,
`- Latest handoff run status: \`${input.latestRun?.status ?? "unknown"}\``,
`- Normalized cause: \`${SUCCESSFUL_RUN_MISSING_STATE_REASON}\``,
`- Missing disposition: \`${missingDisposition}\``,
"- Suggested manager action: choose and record a valid issue disposition without copying transcript content.",
"",
"## Required Action",
"",
"- Inspect the source issue and run metadata, not raw transcript excerpts.",
"- Choose a valid issue disposition: `done`/`cancelled`, `in_review` with an owner, `blocked` with first-class blockers, delegated follow-up work, or an explicit continuation path.",
"- When the source issue has a clear owner and disposition, mark this recovery issue done.",
].join("\n");
}
const retryReason = readNonEmptyString(parseObject(input.latestRun?.contextSnapshot)?.retryReason) ?? "unknown";
const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
@ -1331,6 +1425,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
issue: typeof issues.$inferSelect;
latestRun: LatestIssueRun;
previousStatus: "todo" | "in_progress";
recoveryCause?: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
}) {
if (isStrandedIssueRecoveryIssue(input.issue)) return null;
@ -1341,15 +1437,22 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
if (!ownerAgentId) return null;
const prefix = await getCompanyIssuePrefix(input.issue.companyId);
const sourceAssignee = input.issue.assigneeAgentId ? await getAgent(input.issue.assigneeAgentId) : null;
const recoveryCause = input.recoveryCause ?? "stranded_assigned_issue";
let recovery: Awaited<ReturnType<typeof issuesSvc.create>>;
try {
recovery = await issuesSvc.create(input.issue.companyId, {
title: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`,
title: recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
? `Recover missing next step ${input.issue.identifier ?? input.issue.title}`
: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`,
description: buildStrandedIssueRecoveryDescription({
issue: input.issue,
latestRun: input.latestRun,
previousStatus: input.previousStatus,
prefix,
recoveryCause,
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
sourceAssignee,
}),
status: "todo",
priority: input.issue.priority,
@ -1357,6 +1460,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
projectId: input.issue.projectId,
goalId: input.issue.goalId,
assigneeAgentId: ownerAgentId,
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
originKind: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
originId: input.issue.id,
originRunId: input.latestRun?.id ?? null,
@ -1364,6 +1468,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
input.issue.companyId,
input.issue.id,
recoveryCause,
input.latestRun?.id ?? "no-run",
].join(":"),
billingCode: input.issue.billingCode,
@ -1380,21 +1485,23 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: recovery.id,
sourceIssueId: input.issue.id,
strandedRunId: input.latestRun?.id ?? null,
},
recoveryCause,
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: recovery.id,
taskId: recovery.id,
wakeReason: "issue_assigned",
source: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
sourceIssueId: input.issue.id,
strandedRunId: input.latestRun?.id ?? null,
},
recoveryCause,
}),
});
return recovery;
@ -1512,7 +1619,9 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
issue: typeof issues.$inferSelect;
previousStatus: "todo" | "in_progress";
latestRun: LatestIssueRun;
comment: string;
comment?: string;
recoveryCause?: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
}) {
if (isStrandedIssueRecoveryIssue(input.issue)) {
return escalateStrandedRecoveryIssueInPlace({
@ -1526,6 +1635,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
issue: input.issue,
previousStatus: input.previousStatus,
latestRun: input.latestRun,
recoveryCause: input.recoveryCause,
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
});
const blockerIds = await existingUnresolvedBlockerIssueIds(input.issue.companyId, input.issue.id);
const nextBlockerIds = recoveryIssue
@ -1538,10 +1649,29 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
if (!updated) return null;
const prefix = await getCompanyIssuePrefix(input.issue.companyId);
const recoveryOwner = recoveryIssue?.assigneeAgentId ? await getAgent(recoveryIssue.assigneeAgentId) : null;
const sourceAssignee = input.issue.assigneeAgentId ? await getAgent(input.issue.assigneeAgentId) : null;
let notice: SuccessfulRunHandoffNotice | null = null;
if (input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON && input.successfulRunHandoffEvidence) {
notice = buildSuccessfulRunHandoffExhaustedNotice({
issue: input.issue,
sourceRun: input.successfulRunHandoffEvidence.sourceRunId
? { id: input.successfulRunHandoffEvidence.sourceRunId, status: "succeeded" }
: null,
correctiveRun: input.latestRun ? { id: input.latestRun.id, status: input.latestRun.status } : null,
sourceAssignee,
recoveryIssue,
recoveryOwner,
latestIssueStatus: input.issue.status,
latestHandoffRunStatus: input.latestRun?.status ?? "unknown",
missingDisposition: input.successfulRunHandoffEvidence.missingDisposition,
});
}
const recoveryLine = recoveryIssue
? [
"",
`- Recovery issue: ${issueUiLink({ identifier: recoveryIssue.identifier, id: recoveryIssue.id }, prefix)}`,
`- Recovery owner: ${agentUiLink(recoveryOwner, prefix)}`,
"- Next action: the recovery owner should either restore a live execution path or record the manual resolution, then mark the recovery issue done.",
].join("\n")
: [
@ -1550,7 +1680,15 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
"- Next action: a board operator should assign an invokable recovery owner, fix the agent/runtime state, or record an intentional manual resolution.",
].join("\n");
await issuesSvc.addComment(input.issue.id, `${input.comment}${recoveryLine}`, {});
if (notice) {
await issuesSvc.addComment(input.issue.id, notice.body, {}, {
authorType: "system",
presentation: notice.presentation,
metadata: notice.metadata,
});
} else {
await issuesSvc.addComment(input.issue.id, `${input.comment ?? ""}${recoveryLine}`, {});
}
await logActivity(db, {
companyId: input.issue.companyId,
@ -1558,14 +1696,19 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
actorId: "system",
agentId: null,
runId: null,
action: "issue.updated",
action: input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
? "issue.successful_run_handoff_escalated"
: "issue.updated",
entityType: "issue",
entityId: input.issue.id,
details: {
identifier: input.issue.identifier,
status: "blocked",
previousStatus: input.previousStatus,
source: "recovery.reconcile_stranded_assigned_issue",
source: input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
? "recovery.reconcile_successful_run_handoff_missing_state"
: "recovery.reconcile_stranded_assigned_issue",
recoveryCause: input.recoveryCause ?? "stranded_assigned_issue",
latestRunId: input.latestRun?.id ?? null,
latestRunStatus: input.latestRun?.status ?? null,
latestRunErrorCode: input.latestRun?.errorCode ?? null,
@ -1596,6 +1739,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
productiveContinuationObserved: 0,
successfulContinuationObserved: 0,
orphanBlockersAssigned: 0,
successfulRunHandoffEscalated: 0,
escalated: 0,
skipped: 0,
issueIds: [] as string[],
@ -1713,6 +1857,28 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
result.skipped += 1;
continue;
}
const handoffEvidence = isExhaustedSuccessfulRunHandoff(latestRun);
if (handoffEvidence) {
if (!handoffEvidence.exhausted) {
result.skipped += 1;
continue;
}
const updated = await escalateStrandedAssignedIssue({
issue,
previousStatus: "in_progress",
latestRun,
recoveryCause: SUCCESSFUL_RUN_MISSING_STATE_REASON,
successfulRunHandoffEvidence: handoffEvidence,
});
if (updated) {
result.successfulRunHandoffEscalated += 1;
result.issueIds.push(issue.id);
} else {
result.skipped += 1;
}
continue;
}
if (isSuccessfulInProgressContinuationRun(latestRun)) {
const successfulRun = latestRun;
@ -2393,6 +2559,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
projectId: recoveryIssue.projectId,
goalId: recoveryIssue.goalId,
assigneeAgentId: ownerSelection.agentId,
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
originKind: RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation,
originId: input.finding.incidentKey,
originFingerprint: livenessRecoveryLeafFingerprint(input.finding),
@ -2473,15 +2640,15 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: escalation.id,
sourceIssueId: issue.id,
recoveryIssueId: recoveryIssue.id,
incidentKey: input.finding.incidentKey,
},
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: escalation.id,
taskId: escalation.id,
wakeReason: "issue_assigned",
@ -2489,7 +2656,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
sourceIssueId: issue.id,
recoveryIssueId: recoveryIssue.id,
incidentKey: input.finding.incidentKey,
},
}),
});
logger.warn({

View file

@ -0,0 +1,295 @@
import { describe, expect, it } from "vitest";
import {
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
buildFinishSuccessfulRunHandoffIdempotencyKey,
buildSuccessfulRunHandoffExhaustedNotice,
buildSuccessfulRunHandoffRequiredNotice,
decideSuccessfulRunHandoff,
isIdempotentFinishSuccessfulRunHandoffWakeStatus,
isSuccessfulRunHandoffRequiredNoticeBody,
} from "./successful-run-handoff.js";
const run = {
id: "run-1",
companyId: "company-1",
agentId: "agent-1",
status: "succeeded",
contextSnapshot: { issueId: "issue-1" },
} as any;
const issue = {
id: "issue-1",
companyId: "company-1",
identifier: "PAP-1",
title: "Finish backend handoff",
status: "in_progress",
assigneeAgentId: "agent-1",
assigneeUserId: null,
executionState: null,
} as any;
const agent = {
id: "agent-1",
companyId: "company-1",
status: "idle",
} as any;
function decide(overrides: Partial<Parameters<typeof decideSuccessfulRunHandoff>[0]> = {}) {
return decideSuccessfulRunHandoff({
run,
issue,
agent,
livenessState: "advanced",
detectedProgressSummary: "Run produced concrete action evidence: 1 issue comment(s)",
taskKey: "issue-1",
hasActiveExecutionPath: false,
hasQueuedWake: false,
hasPendingInteractionOrApproval: false,
hasExplicitBlockerPath: false,
hasOpenRecoveryIssue: false,
hasPauseHold: false,
budgetBlocked: false,
idempotentWakeExists: false,
...overrides,
});
}
describe("successful run handoff decision", () => {
it("queues one corrective handoff wake for a successful progress run without a visible next action", () => {
const decision = decide();
expect(decision.kind).toBe("enqueue");
if (decision.kind !== "enqueue") return;
expect(decision.idempotencyKey).toBe("finish_successful_run_handoff:issue-1:run-1:1");
expect(decision.payload).toMatchObject({
issueId: "issue-1",
sourceRunId: "run-1",
handoffRequired: true,
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
missingDisposition: "clear_next_step",
handoffAttempt: 1,
maxHandoffAttempts: 1,
resumeIntent: true,
resumeFromRunId: "run-1",
modelProfile: "cheap",
});
expect(decision.contextSnapshot).toMatchObject({
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
handoffRequired: true,
modelProfile: "cheap",
});
expect(decision.instruction).toContain("Resolve the missing disposition before creating or revising any new artifacts");
expect(decision.instruction).toContain("Choose **exactly one** outcome");
expect(decision.instruction).toContain("record an explicit continuation path");
});
it("does not queue when the issue already has a valid disposition", () => {
expect(decide({ issue: { ...issue, status: "done" } as any })).toEqual({
kind: "skip",
reason: "issue status done is a valid disposition",
});
});
it("does not queue when a successful run records an accepted next-action path", () => {
expect(decide({ issue: { ...issue, status: "in_review" } as any })).toEqual({
kind: "skip",
reason: "issue status in_review is a valid disposition",
});
expect(decide({ issue: { ...issue, status: "blocked" } as any })).toEqual({
kind: "skip",
reason: "issue status blocked is a valid disposition",
});
expect(decide({ hasPendingInteractionOrApproval: true })).toEqual({
kind: "skip",
reason: "pending interaction or approval owns the next action",
});
expect(decide({ hasActiveExecutionPath: true })).toEqual({
kind: "skip",
reason: "issue already has an active execution path",
});
});
it("does not queue when another wake or dependency path already owns the next action", () => {
expect(decide({ hasQueuedWake: true })).toEqual({
kind: "skip",
reason: "issue already has a queued or deferred wake",
});
expect(decide({ hasExplicitBlockerPath: true })).toEqual({
kind: "skip",
reason: "explicit blocker path owns the next action",
});
});
it("does not queue when a successful run has no progress signal", () => {
expect(decide({ livenessState: null, detectedProgressSummary: null })).toEqual({
kind: "skip",
reason: "successful run did not produce handoff-relevant progress",
});
});
it("does not treat adapter or runtime failures as missing-disposition handoffs", () => {
expect(decide({ run: { ...run, status: "failed", errorCode: "adapter_failed" } as any })).toEqual({
kind: "skip",
reason: "source run did not succeed",
});
});
it("does not queue on missing-comment retry bookkeeping runs", () => {
expect(decide({ run: { ...run, issueCommentStatus: "retry_exhausted" } as any })).toEqual({
kind: "skip",
reason: "missing issue comment retry owns the next action",
});
});
it("does not loop from a corrective handoff run", () => {
expect(decide({
run: {
...run,
id: "run-2",
contextSnapshot: {
issueId: "issue-1",
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
handoffRequired: true,
},
} as any,
})).toEqual({
kind: "skip",
reason: "source run is already a corrective handoff run",
});
});
it("does not queue for issue monitor maintenance runs", () => {
expect(decide({
run: {
...run,
contextSnapshot: {
issueId: "issue-1",
source: "issue.monitor",
wakeReason: "issue_monitor_due",
},
} as any,
})).toEqual({
kind: "skip",
reason: "issue monitor run owns its own recovery path",
});
});
it("uses a stable one-attempt idempotency key", () => {
expect(buildFinishSuccessfulRunHandoffIdempotencyKey({
issueId: "issue-1",
sourceRunId: "run-1",
})).toBe("finish_successful_run_handoff:issue-1:run-1:1");
});
it("allows failed or cancelled corrective wakes to be retried", () => {
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("queued")).toBe(true);
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("claimed")).toBe(true);
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("completed")).toBe(true);
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("failed")).toBe(false);
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("cancelled")).toBe(false);
});
it("builds the required system notice with hidden structured metadata", () => {
const notice = buildSuccessfulRunHandoffRequiredNotice({
issue: {
id: "11111111-1111-4111-8111-111111111111",
identifier: "PAP-1",
title: "Finish backend handoff",
status: "in_progress",
} as any,
run: {
id: "22222222-2222-4222-8222-222222222222",
status: "succeeded",
} as any,
agent: {
id: "33333333-3333-4333-8333-333333333333",
name: "CodexCoder",
} as any,
detectedProgressSummary: "Run produced concrete action evidence: 1 issue comment(s)",
});
expect(notice.body).toBe(SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY);
expect(notice.presentation).toEqual({
kind: "system_notice",
tone: "warning",
title: "Missing issue disposition",
detailsDefaultOpen: false,
});
expect(notice.metadata.sections).toEqual(expect.arrayContaining([
expect.objectContaining({
title: "Required action",
rows: expect.arrayContaining([
expect.objectContaining({ type: "issue_link", identifier: "PAP-1" }),
expect.objectContaining({ type: "agent_link", name: "CodexCoder" }),
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
]),
}),
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
expect.objectContaining({ type: "run_link", runId: "22222222-2222-4222-8222-222222222222" }),
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
expect.objectContaining({ type: "key_value", label: "Detected progress" }),
]),
}),
]));
});
it("builds the exhausted system notice with recovery metadata", () => {
const notice = buildSuccessfulRunHandoffExhaustedNotice({
issue: {
id: "11111111-1111-4111-8111-111111111111",
identifier: "PAP-1",
title: "Finish backend handoff",
status: "in_progress",
} as any,
sourceRun: { id: "22222222-2222-4222-8222-222222222222", status: "succeeded" } as any,
correctiveRun: { id: "44444444-4444-4444-8444-444444444444", status: "failed" } as any,
sourceAssignee: { id: "33333333-3333-4333-8333-333333333333", name: "CodexCoder" } as any,
recoveryIssue: {
id: "55555555-5555-4555-8555-555555555555",
identifier: "PAP-2",
title: "Recover missing next step PAP-1",
status: "todo",
} as any,
recoveryOwner: { id: "66666666-6666-4666-8666-666666666666", name: "CTO" } as any,
latestIssueStatus: "in_progress",
latestHandoffRunStatus: "failed",
missingDisposition: "clear_next_step",
});
expect(notice.body).toBe(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY);
expect(notice.presentation).toMatchObject({
kind: "system_notice",
tone: "danger",
detailsDefaultOpen: false,
});
expect(notice.metadata.sections).toEqual(expect.arrayContaining([
expect.objectContaining({
title: "Recovery owner",
rows: expect.arrayContaining([
expect.objectContaining({ type: "issue_link", identifier: "PAP-2" }),
expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "CTO" }),
]),
}),
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
expect.objectContaining({ type: "run_link", label: "Source run" }),
expect.objectContaining({ type: "run_link", label: "Corrective handoff run" }),
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
]),
}),
]));
});
it("recognizes new notices and legacy markdown headings for fallback deduplication", () => {
expect(isSuccessfulRunHandoffRequiredNoticeBody(SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY)).toBe(true);
expect(isSuccessfulRunHandoffRequiredNoticeBody("## Successful run missing issue disposition\n\nold body")).toBe(true);
expect(isSuccessfulRunHandoffRequiredNoticeBody("## This issue still needs a next step\n\nold body")).toBe(true);
expect(isSuccessfulRunHandoffRequiredNoticeBody("Unrelated comment")).toBe(false);
});
});

View file

@ -0,0 +1,405 @@
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,
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,
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,
}),
};
}