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

@ -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,