Files
PaperClipAI/server/src/services/execution-continuation.ts
T
Devin FoleyandPaperclip edb9d29942 fix(recovery): preserve Retry authority and reopen cross-task cases (#18997)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Tasks keep execution identity and saved workspace files separate
from control-plane updates.
> - A scanner can have permission to reopen a case while its run cannot
restore that case's files.
> - Retry can also follow a preparation failure that has no
recovery-action record.
> - Both paths can accept valid work but leave the target unable to run.
> - This change restores files under the target executor and saves
admitted Retry authority on the canonical receipt.
> - Existing workspace, user, source and successor checks still apply.

## Linked Issues or Issue Description

**What happened?**

An authorized agent updates another closed task. The route tries to
restore the target workspace under the caller's run identity and returns
`403 Task workspace run identity is unavailable`.

A separate failure occurs when a normal board Retry follows a
pre-provider preparation failure. Admission has no recovery-action row
on which to save continuation authority. Dispatch then fails with
`continuation_user_authorization_missing`.

**Expected behavior**

Accept the authorized task update. Restore its terminal workspace when
the target's admitted executor starts. An admitted Retry must retain its
exact dispatch authority even when the predecessor has no
recovery-action record.

**Steps to reproduce**

1. Complete a task with an isolated workspace. From a different running
task, use an authorized comment or PATCH with `resume: true` on that
closed task.
2. Separately, fail native preparation before provider work starts, with
no recovery-action row. Submit board Retry, admit its successor, and
build the execution continuation.
3. Before this change, the first path returns 403 and the second rejects
its admitted authority.

**Paperclip version or commit**

Reproduced against `e968131efa7e63d321f3bcca8d47a62513c2efb4`. Rebased
onto `afbc5a885b5`.

**Deployment mode**

Server-managed native task execution. Tests use real PostgreSQL and
temporary workspace files.

Related PRs: #16927 covers terminal workspace races. #17916 retains
exact native export sources. Neither covers these route and Retry
receipt failures. A GitHub search found no duplicate. ROADMAP.md was
checked; this is a recovery bug fix.

## What Changed

- Defer workspace restoration only for authorized cross-task comments
and updates from a live run. Checkout keeps its existing workspace
identity checks.
- Restore the target's selected terminal workspace under its own
admitted run. Preserve its saved files and existing source checks. Leave
manually archived workspaces unchanged.
- Store server-created continuation authority on the canonical admitted
Retry receipt when no source recovery-action record exists. Existing
action records remain authoritative. Read the fallback only after
matching the company, task, agent, user, source, generation and
successor.
- Add route and PostgreSQL regression tests. Document both paths.

## Verification

- Red: both cross-task route tests returned the observed 403 before the
route fix.
- Red: the target executor could not reuse its archived terminal
workspace before the restoration fix.
- Red: an admitted preparation Retry with no recovery-action row failed
with `continuation_user_authorization_missing` before the receipt fix.
- Green: 419 recovery and continuation tests pass, plus 183 focused
route, environment and Retry tests. The assembled heartbeat test starts
the agent in the same terminal worktree and verifies both its unpushed
commit and uncommitted file bytes. It fails before the restoration fix.
- Negative cases reject wrong Retry runs, users, source runs,
generations, unadmitted receipts and missing authority. The original
failed run stays unchanged. Workspace tests verify saved file bytes.
- Full `pnpm build`, `pnpm -r typecheck`, `pnpm check:module-boundaries`
and `git diff --check` pass.
- The full suite found an existing assembled chat authority regression:
clearing a recovery-action record could be bypassed by the new fallback.
The fix now writes the fallback only when that source has no action
record. That assembled regression passes with the original test
unchanged.
- Full `pnpm test:run` is running again. Its first attempt also hit an
unrelated ancestor skill-root lookup for a missing Slack skill. The
rerun uses a temporary untracked link to the repository's own bundled
skills. This link is excluded from the PR. Full-suite completion and
hosted CI are pending.
- Hosted CI has multiple runner interruptions. E2e shard 1/8, server
shards 3/12, 8/12 and 12/12, and workspaces-a shard 1/2 show runner
shutdown signals. E2e shard 5/8 and server shards 1/12 and 2/12 have
GitHub annotations reporting lost runner communication; their logs
return 404. The `ci / verify` assertion failed because the general-test
matrix was cancelled. GitHub rejected job and failed-workflow reruns
while this workflow is still running. Rerun the interrupted checks after
it settles.
- Greptile reviewed exact head
`a1e3929aedb1ee3bfcd001f4ebb1510106bf4070` at 2026-11-04 17:37 UTC: 5/5,
with no new blocking findings and no unresolved review threads.

## Risks

- A deferred workspace restore can still fail at target admission. The
run keeps the existing workspace failure path; this change does not
bypass file recovery or provider stop checks.
- Retry receipts now store server-created authority. Existing action
receipts remain valid. No schema migration is required.
- Local regression tests establish the software behavior. The new
revision has not been deployed to a hosted instance.

## Model Used

OpenAI `gpt-6.1-sol` through the Codex harness in T3 Code, with xhigh
reasoning, repository inspection, tool use and code execution. The
context-window size is not exposed by the runtime.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-04 11:55:16 -06:00

550 lines
26 KiB
TypeScript

import { createHash } from "node:crypto";
import { and, asc, desc, eq, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
import { z } from "zod";
import {
agentWakeupRequests,
heartbeatRuns,
issueComments,
issueRecoveryActions,
issueThreadInteractions,
issues,
type Db,
} from "@paperclipai/db";
import type { ExecutionContinuationEnvelope } from "@paperclipai/shared";
import { sanitizeQuarantinedCommentForHigherTrust } from "./source-trust.js";
import { hasConversationContinuationPolicy } from "./conversation-continuation.js";
import { queuedCommentIdsFromWakePayload } from "./issue-queued-comment-queue.js";
import { queuedInteractionId } from "./queued-interaction-response.js";
import { childReviewOutcomes } from "./native-runtime/child-review-outcomes.js";
import { isCompletedOnboardingHandoffWake } from "./chat-completion-delivery.js";
import { githubRecoveryReceipts } from "./chat-channels/github/recovery-receipts.js";
import { readTaskRetryIntent } from "./task-retry.js";
export class StaleExecutionContinuationError extends Error {
constructor(readonly code: "continuation_task_ownership_changed") {
super(code);
this.name = "StaleExecutionContinuationError";
}
}
const object = (v: unknown): Record<string, unknown> =>
v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: {};
const string = (v: unknown) =>
typeof v === "string" && v.length > 0 ? v : null;
const historyResult = sql<Record<string, unknown> | null>`case
when jsonb_typeof(${heartbeatRuns.resultJson}) = 'object' then (
select coalesce(jsonb_object_agg(field.key, case
when field.key = 'apiToolReceipts' and jsonb_typeof(field.value) = 'object' then (
select coalesce(jsonb_object_agg(receipt.key, (
select jsonb_object_agg(detail.key, detail.value)
from jsonb_each(receipt.value) as detail(key, value)
where detail.key in ('state', 'operationId', 'result')
)), '{}'::jsonb)
from jsonb_each(field.value) as receipt(key, value)
where receipt.value->'state' = '"completed"'::jsonb
and jsonb_typeof(receipt.value->'operationId') = 'string'
)
when field.key = 'workspaceRestoreRecovery' and jsonb_typeof(field.value) = 'object' then (
select coalesce(jsonb_object_agg(detail.key, detail.value), '{}'::jsonb)
from jsonb_each(field.value) as detail(key, value)
where detail.key = 'schema'
)
else field.value end), '{}'::jsonb)
from jsonb_each(${heartbeatRuns.resultJson}) as field(key, value)
where field.key in ('apiToolReceipts', 'conversationContinuation', 'workspaceRestoreFailure', 'workspaceRestoreRecovery')
) else ${heartbeatRuns.resultJson} end`;
export function continuationOriginCommentIds(context: unknown): string[] {
const c = object(context);
const prior = object(c.executionContinuation);
return [
...new Set(
[
c.commentId,
c.latestCommentId,
...(Array.isArray(c.commentIds) ? c.commentIds : []),
...(Array.isArray(c.wakeCommentIds) ? c.wakeCommentIds : []),
...(Array.isArray(prior.originCommentIds)
? prior.originCommentIds
: []),
].filter((v): v is string => typeof v === "string" && v.length > 0),
),
];
}
/**
* Return the comment IDs explicitly represented in a delivered continuation.
* An absent list is intentionally different from an empty list: historical or
* third-party snapshots cannot prove what the provider received.
*/
export function deliveredContinuationCommentIds(context: unknown): {
known: boolean;
ids: Set<string>;
} {
const c = object(context);
const wake = object(c.paperclipWake);
const continuation = object(c.executionContinuation);
const ids = new Set<string>();
let known = false;
const collect = (value: unknown) => {
if (!Array.isArray(value)) return;
known = true;
for (const item of value) {
const id = typeof item === "string" ? item : string(object(item).id);
if (id) ids.add(id);
}
};
collect(wake.comments);
collect(continuation.messages);
return { known, ids };
}
/** Keep service/tool results and generated summaries out of human authority. */
export function projectHumanInteractionResponse(row: {
id: string; kind: string; status: string; result: unknown;
resolvedByUserId: string | null; resolvedByAgentId: string | null;
resolvedByRunId: string | null; resolvedAt: Date | null;
}): NonNullable<ExecutionContinuationEnvelope["humanResponses"]>[number] | null {
if (!row.resolvedByUserId || row.resolvedByAgentId || row.resolvedByRunId || !row.resolvedAt) return null;
const result = object(row.result);
let response: Record<string, unknown>;
if (row.kind === "ask_user_questions" && row.status === "answered" && Array.isArray(result.answers)) {
response = { answers: result.answers.map(value => {
const answer = object(value);
return { questionId: answer.questionId, optionIds: answer.optionIds, otherText: answer.otherText };
}) };
} else if (["request_confirmation", "request_checkbox_confirmation"].includes(row.kind)
&& ["accepted", "rejected"].includes(row.status) && result.outcome === row.status) {
response = { outcome: result.outcome, reason: result.reason,
...(Array.isArray(result.selectedOptionIds) ? { selectedOptionIds: result.selectedOptionIds } : {}) };
} else return null;
return { id: row.id, kind: row.kind, status: row.status, resolvedByUserId: row.resolvedByUserId,
resolvedAt: row.resolvedAt.toISOString(), result: response };
}
/** Also retain user direction delivered after the source run's initial wake. */
export async function currentContinuationOrigins(
db: Db,
companyId: string,
issueId: string,
context: unknown,
): Promise<string[]> {
const candidates = continuationOriginCommentIds(context);
// A run may create an interaction on another task. Its own comments do not
// become authority on that task. Keep unknown references for dispatch to
// reject, rather than silently claiming complete context.
const foreignComments = candidates.length
? await db.select({ id: issueComments.id }).from(issueComments).where(and(
eq(issueComments.companyId, companyId),
sql`${issueComments.issueId} != ${issueId}`,
inArray(sql<string>`${issueComments.id}::text`, candidates),
))
: [];
const foreignIds = new Set(foreignComments.map(row => row.id));
const [latest] = await db
.select({ id: issueComments.id })
.from(issueComments)
.where(
and(
eq(issueComments.companyId, companyId),
eq(issueComments.issueId, issueId),
isNotNull(issueComments.authorUserId),
isNull(issueComments.createdByRunId),
isNull(issueComments.authorAgentId),
isNull(issueComments.deletedAt),
),
)
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
.limit(1);
return [
...new Set([
...candidates.filter(id => !foreignIds.has(id)),
...(latest ? [latest.id] : []),
]),
];
}
/** Re-read task scope at dispatch, including messages already delivered to an earlier provider session. */
export async function buildExecutionContinuation(input: {
db: Db;
companyId: string;
issueId: string;
agentId: string;
context: Record<string, unknown>;
previousContextRunId?: string | null;
/** Server-owned current run identity when validating dispatch authority. */
runId?: string;
summary: string | null;
exposeLowTrustRaw: boolean;
}): Promise<ExecutionContinuationEnvelope> {
const { db, companyId, issueId } = input;
const [issue] = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)));
if (
!issue ||
issue.assigneeAgentId !== input.agentId ||
issue.status === "cancelled" ||
(issue.status === "done" && !await isCompletedOnboardingHandoffWake(db, {
companyId, issueId, agentId: input.agentId,
reason: string(input.context.wakeReason), contextSnapshot: input.context,
}))
)
throw new StaleExecutionContinuationError("continuation_task_ownership_changed");
const rows = await db
.select()
.from(issueComments)
.where(
and(
eq(issueComments.companyId, companyId),
eq(issueComments.issueId, issueId),
),
)
.orderBy(asc(issueComments.createdAt), asc(issueComments.id));
const interactions = await db
.select()
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, companyId),
eq(issueThreadInteractions.issueId, issueId),
),
)
.orderBy(
asc(issueThreadInteractions.createdAt),
asc(issueThreadInteractions.id),
);
const triggerInteraction = interactions.find(
(row) => row.id === input.context.interactionId,
);
const explicitContinuation = object(input.context.explicitUserContinuation);
const explicitUserSource = string(explicitContinuation.previousRunId);
const interruptedSource = string(input.context.interruptedRunId);
const resumeSourceRunId =
explicitUserSource ??
string(input.context.retryOfRunId) ??
string(input.context.previousRunId) ??
interruptedSource;
const producerRunId = triggerInteraction?.sourceRunId ?? null;
const requestedSourceRunId = resumeSourceRunId ?? producerRunId;
const loadRun = async (id: string) => (await db
.select({ context: heartbeatRuns.contextSnapshot, result: heartbeatRuns.resultJson })
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, id)))
)[0];
const candidate = requestedSourceRunId ? await loadRun(requestedSourceRunId) : null;
// An interaction producer is provenance. Explicit resume history must still
// belong to this task, and only task-scoped content can enter the envelope.
const sourceRun = object(candidate?.context).issueId === issueId ? candidate : null;
// interruptedRunId is a best-effort hint written when a run is interrupted.
// After a cross-issue reassign it can name another task's run or a deleted
// row. Unlike an explicit user resume or a retry chain, a hint that does
// not resolve to this task must not fail the whole wake: degrade to "no
// resume source" and continue.
const staleInterruptedSource =
interruptedSource !== null
&& resumeSourceRunId === interruptedSource
&& explicitUserSource === null
&& string(input.context.retryOfRunId) === null
&& string(input.context.previousRunId) === null
&& sourceRun === null;
// A degraded hint falls back to the interaction producer so interaction
// wakes keep their source attribution.
const sourceRunId = staleInterruptedSource ? (producerRunId ?? null) : requestedSourceRunId;
if (
!staleInterruptedSource
&& ((requestedSourceRunId && !candidate) || (resumeSourceRunId && !sourceRun))
)
throw new Error(explicitUserSource ? "continuation_user_authorization_missing" : "continuation_source_context_missing");
const producer = producerRunId === requestedSourceRunId ? candidate
: producerRunId ? await loadRun(producerRunId) : null;
if (producerRunId && !producer) throw new Error("continuation_source_context_missing");
const producerIssueId = string(object(producer?.context).issueId);
const producerOrigins = new Set(continuationOriginCommentIds(producer?.context));
const recordedOrigins = triggerInteraction?.originCommentIds ?? [];
const inheritedOrigins = recordedOrigins.filter(id => producerOrigins.has(id));
// Older interactions copied the producer's origins without task scope.
// Ignore only references proven to be comments on that producer's other
// task. Missing rows, unrelated references, and explicit wake origins keep
// the existing fail-closed check below.
const inheritedForeignComments = producerIssueId && producerIssueId !== issueId && inheritedOrigins.length
? await db.select({ id: issueComments.id }).from(issueComments).where(and(
eq(issueComments.companyId, companyId),
sql`${issueComments.issueId}::text = ${producerIssueId}`,
inArray(sql<string>`${issueComments.id}::text`, inheritedOrigins),
))
: [];
const inheritedForeignIds = new Set(inheritedForeignComments.map(row => row.id));
const originCommentIds = [
...new Set([
...continuationOriginCommentIds(input.context),
...continuationOriginCommentIds(sourceRun?.context),
...recordedOrigins.filter(id => !inheritedForeignIds.has(id)),
...(triggerInteraction?.sourceCommentId
? [triggerInteraction.sourceCommentId]
: []),
]),
];
// Missing source rows cannot silently become a claim of complete context.
if (originCommentIds.some((id) => !rows.some((row) => row.id === id)))
throw new Error("continuation_source_context_missing");
const messages = rows.map((row) => {
const safe = input.exposeLowTrustRaw
? row
: sanitizeQuarantinedCommentForHigherTrust(row);
return {
id: row.id,
authorType:
row.authorType ??
(row.authorUserId ? "user" : row.authorAgentId ? "agent" : "system"),
authorId: row.authorUserId ?? row.authorAgentId,
createdByRunId: row.createdByRunId,
body: row.deletedAt ? "" : safe.body,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deleted: row.deletedAt !== null,
sourceTrust: row.sourceTrust,
};
});
const previousRun = input.previousContextRunId
? (
await db
.select({ context: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.agentId, input.agentId),
eq(heartbeatRuns.id, input.previousContextRunId),
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
),
)
)[0]
: null;
const priorEnvelope = object(previousRun?.context?.executionContinuation);
const deliveredMessages = Array.isArray(priorEnvelope.messages)
? priorEnvelope.messages.map(object)
: null;
const resumeDelta =
deliveredMessages && input.previousContextRunId
? {
baseRunId: input.previousContextRunId,
messages: messages.filter(
(message) =>
originCommentIds.includes(message.id) ||
!deliveredMessages.some(
(prior) =>
prior.id === message.id &&
prior.updatedAt === message.updatedAt &&
prior.body === message.body &&
prior.deleted === message.deleted &&
prior.authorId === message.authorId &&
(prior.createdByRunId ?? null) === message.createdByRunId &&
JSON.stringify(prior.sourceTrust) ===
JSON.stringify(message.sourceTrust),
),
),
}
: undefined;
const latestRequest = messages.findLast(
(row) =>
row.authorType === "user" && !row.createdByRunId && !row.deleted && row.body.trim().length > 0,
);
const hashObjectiveSource = (value: string) => createHash("sha256").update(value.trim()).digest("hex");
const objectiveSource = latestRequest
? { kind: "comment" as const, id: latestRequest.id, revision: latestRequest.updatedAt }
: issue.description !== null && issue.description !== undefined
? { kind: "description" as const, id: issue.id, revision: hashObjectiveSource(issue.description) }
: { kind: "title" as const, id: issue.id, revision: hashObjectiveSource(issue.title) };
const priorRuns = await db
.select({ id: heartbeatRuns.id, result: historyResult, status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode, runtimeMode: heartbeatRuns.runtimeMode, retryOfRunId: heartbeatRuns.retryOfRunId })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
or(eq(heartbeatRuns.agentId, input.agentId),
sourceRunId ? eq(heartbeatRuns.id, sourceRunId) : undefined),
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
),
)
.orderBy(asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id));
const completedActions = priorRuns.flatMap((run) =>
Object.entries(object(object(run.result).apiToolReceipts)).flatMap(
([receiptId, receipt]) => {
const value = object(receipt);
return value.state === "completed" &&
typeof value.operationId === "string"
? [
{
runId: run.id,
receiptId,
operationId: value.operationId,
result: value.result,
},
]
: [];
},
),
);
completedActions.push(...await githubRecoveryReceipts(db, companyId, issueId));
const reconciliations = await db
.select({
id: issueRecoveryActions.id,
evidence: issueRecoveryActions.evidence,
})
.from(issueRecoveryActions)
.where(
and(
eq(issueRecoveryActions.companyId, companyId),
eq(issueRecoveryActions.sourceIssueId, issueId),
eq(issueRecoveryActions.status, "resolved"),
),
);
const lastTerminal = priorRuns.findLast((run) =>
["succeeded", "failed", "timed_out", "interrupted", "cancelled"].includes(run.status) &&
!(run.status === "cancelled" && run.errorCode === "execution_reconciliation_required"),
);
if (explicitUserSource) {
const predecessor = priorRuns.find(run => run.id === explicitUserSource &&
["failed", "timed_out", "interrupted", "cancelled"].includes(run.status));
const failedRunId = string(explicitContinuation.failedRunId);
const retryWakes = failedRunId ? await db.select().from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, input.agentId),
eq(agentWakeupRequests.reason, "retry_failed_run"), eq(agentWakeupRequests.requestedByActorType, "user"),
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
)) : [];
// Admission records the board operator's authority separately from the
// message author. At dispatch, prove that exact queue was adopted by this
// run; caller-supplied continuation context cannot grant this authority.
const queueIntentId = z.string().guid().safeParse(explicitContinuation.queuedCommentInterruptId);
const nativeQueuedContinuation = predecessor?.runtimeMode === "native" && ["cancelled", "failed"].includes(predecessor.status);
const [recordedQueue] = nativeQueuedContinuation && input.runId && queueIntentId.success ? await db.select().from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.id, queueIntentId.data), eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, input.agentId), eq(agentWakeupRequests.status, "coalesced"),
eq(agentWakeupRequests.runId, input.runId), sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
)) : [];
const taskRetryId = string(explicitContinuation.taskRetryRequestId);
const [taskRetryWake] = taskRetryId && z.string().guid().safeParse(taskRetryId).success
? await db.select().from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.id, taskRetryId),
eq(agentWakeupRequests.agentId, input.agentId), eq(agentWakeupRequests.reason, "task_retry"),
eq(agentWakeupRequests.requestedByActorType, "user"), eq(agentWakeupRequests.status, "coalesced"),
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
input.runId ? eq(agentWakeupRequests.runId, input.runId) : undefined,
)) : [];
const taskRetry = readTaskRetryIntent(taskRetryWake?.payload);
const retryAuthorization = taskRetry && taskRetry.generationId === taskRetryWake?.id
&& taskRetry.actorId === taskRetryWake.requestedByActorId
? object(object(taskRetryWake.payload?.taskRetry).continuation) : {};
const continuationAuthorizations = [...reconciliations.map(row => object(row.evidence.explicitUserContinuation)),
...(recordedQueue ? [object(object(recordedQueue.payload?.queuedCommentInterrupt).continuation)] : []),
retryAuthorization]
.filter(value => value.previousRunId === explicitUserSource &&
(!input.runId || value.runId === input.runId) &&
value.commentId === explicitContinuation.commentId &&
priorRuns.some(run => run.id === value.runId));
const interruptQueueIds = [...new Set(continuationAuthorizations.flatMap(value => {
const parsed = z.string().guid().safeParse(value.queuedCommentInterruptId);
return parsed.success ? [parsed.data] : [];
}))];
const interruptQueues = interruptQueueIds.length
? await db.select().from(agentWakeupRequests).where(and(
inArray(agentWakeupRequests.id, interruptQueueIds),
eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, input.agentId),
eq(agentWakeupRequests.status, "coalesced"),
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
sql`${agentWakeupRequests.payload}->'queuedCommentInterrupt' is not null`,
input.runId ? eq(agentWakeupRequests.runId, input.runId) : undefined,
)) : [];
const authorization = continuationAuthorizations.find(value => taskRetryId
? value.taskRetryRequestId === taskRetryId && taskRetryWake?.runId === value.runId &&
taskRetryWake.requestedByActorId === value.actorId &&
object(taskRetryWake.payload?.taskRetry).sourceRunId === value.previousRunId
: failedRunId
? value.failedRunId === failedRunId && retryWakes.some(wake =>
wake.runId === value.runId && wake.requestedByActorId === value.actorId &&
priorRuns.some(run => run.id === wake.runId && run.retryOfRunId === failedRunId))
: value.interactionId
? interactions.some(interaction => interaction.id === value.interactionId && interaction.id === triggerInteraction?.id &&
["accepted", "answered", "rejected"].includes(interaction.status) &&
interruptQueues.some(queue => queue.id === value.queuedCommentInterruptId && queue.runId === value.runId &&
object(queue.payload?.queuedCommentInterrupt).actorId === value.actorId &&
queuedInteractionId(queue.payload) === interaction.id && queue.payload?.interactionStatus === interaction.status))
: rows.some(comment => comment.id === value.commentId &&
comment.authorType === "user" &&
(!("commentUpdatedAt" in value || "commentBodyHash" in value || value.automaticRetry) || (
value.commentUpdatedAt === comment.updatedAt.toISOString() &&
value.commentBodyHash === createHash("sha256").update(comment.body).digest("hex")
)) &&
(!value.automaticRetry || (
object(value.automaticRetry).sourceRunId === explicitUserSource &&
comment.body.trim().length > 0 && issue.executionRunId === input.runId &&
priorRuns.some(run => run.id === value.runId && run.retryOfRunId === explicitUserSource)
)) &&
(value.queuedCommentInterruptId
? interruptQueues.some(queue => queue.id === value.queuedCommentInterruptId &&
queue.runId === value.runId &&
object(object(queue.payload).queuedCommentInterrupt).actorId === value.actorId &&
queuedCommentIdsFromWakePayload(queue.payload).includes(comment.id))
: comment.authorUserId === value.actorId) &&
!comment.createdByRunId && !comment.deletedAt));
if (!predecessor || !authorization || explicitUserSource !== sourceRunId)
throw new Error("continuation_user_authorization_missing");
}
const interruptedRunId = explicitUserSource ?? (staleInterruptedSource ? undefined : interruptedSource) ?? (lastTerminal && lastTerminal.status !== "succeeded" &&
(hasConversationContinuationPolicy(lastTerminal.result) ||
lastTerminal.status === "interrupted" || lastTerminal.errorCode === "process_lost")
? lastTerminal.id : undefined);
return {
...(interruptedRunId ? { interruptedRunId } : {}),
...(resumeDelta ? { resumeDelta } : {}),
recoveryOutcomes: reconciliations
.filter((row) => row.evidence.executionReconciliation)
.map((row) => ({
recoveryActionId: row.id,
decision: row.evidence.executionReconciliation,
})),
version: 1,
companyId,
issueId,
trigger: {
reason: string(input.context.wakeReason) ?? "task_execution",
interactionId: triggerInteraction?.id ?? null,
sourceRunId,
},
originCommentIds,
objective: latestRequest?.body ?? issue.description ?? issue.title,
objectiveSource,
messages,
humanResponses: interactions.flatMap(row => {
const response = projectHumanInteractionResponse(row);
return response ? [response] : [];
}),
interactionOutcomes: [...interactions
.filter((row) => row.status !== "pending")
.map((row) => ({
id: row.id,
kind: row.kind,
status: row.status,
result: row.result,
})), ...await childReviewOutcomes(db, companyId, issueId, { type: "agent", agentId: input.agentId, companyId, onBehalfOfUserId: issue.responsibleUserId })],
// Low-trust evidence only: renderPaperclipWakePrompt removes completedWork
// from requestContext and encodes it in the fenced, non-authoritative
// continuation-evidence section. It cannot supply objective or authority.
completedWork: input.summary ??
string(object(object(sourceRun?.result).nativeResult).summary)?.slice(0, 32_000) ??
string(object(sourceRun?.result).summary)?.slice(0, 32_000) ?? null,
completedActions,
unresolvedInteractionIds: interactions
.filter((row) => row.status === "pending")
.map((row) => row.id),
coverage: {
kind: "full_task_history",
throughCommentId: messages.at(-1)?.id ?? null,
summaryThroughCommentId: null,
},
};
}