mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30: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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue