mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 20:10:39 +09:00
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:
parent
50db8c01d2
commit
454edfe81e
70 changed files with 21919 additions and 125 deletions
|
|
@ -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";
|
||||
|
|
|
|||
14
server/src/services/recovery/model-profile-hint.ts
Normal file
14
server/src/services/recovery/model-profile-hint.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
295
server/src/services/recovery/successful-run-handoff.test.ts
Normal file
295
server/src/services/recovery/successful-run-handoff.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
405
server/src/services/recovery/successful-run-handoff.ts
Normal file
405
server/src/services/recovery/successful-run-handoff.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue