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>
2026-05-06 06:05:58 -05:00
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 ,
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:

Expanded stale notice details:

## 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
sourceRunId : input.run.id ,
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>
2026-05-06 06:05:58 -05:00
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 ,
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:

Expanded stale notice details:

## 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
sourceRunId : input.sourceRun?.id ? ? null ,
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>
2026-05-06 06:05:58 -05:00
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 ,
} ) ,
} ;
}