fix(server): keep a successor's issue lock when a finalizing native run releases its checkout (#19156)

`release_checkout` now clears `checkoutRunId` and the execution lock only when they belong to the committing run, so a successor that claimed the issue while its predecessor was finalizing keeps its claim and passes reviewed-chat attestation.

`attestReviewedExternalChatRun` reports the first failed check as a closed label. Setup failures store it in `resultJson.reviewedChatBinding.denialReason`, and Sentry run-failure diagnostics export it as `execution.reviewedChatBindingDenialReason` (no IDs or text). The error message is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Devin FoleyandClaude Opus 5.5 authored and GitHub committed 2026-11-04 17:49:03 -08:00
1 parent 6fa54d5577
commit 7edbc1fc07
9 files changed
+271 -34

No files matched your search

@@ -1876,6 +1876,9 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
expect(deniedRun).toMatchObject({
status: "failed",
error: "reviewed_chat_execution_binding_not_authorized",
resultJson: expect.objectContaining({
reviewedChatBinding: { denialReason: "question_response_missing" },
}),
contextSnapshot: expect.objectContaining({
interactionId: answered.id,
sourceRunId,
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
companies,
@@ -19,6 +19,11 @@ import {
companyMemberships,
chatDeliveries,
chatMessageLinks,
completionContracts,
nativeRunFinalizations,
nativeRunResults,
statusDecisionEffects,
workAssessments,
} from "@paperclipai/db";
import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
@@ -32,6 +37,9 @@ import {
} from "../services/native-runtime/chat-attachment-reuse.js";
import { decideIssueReviewPathRecovery } from "../services/recovery/review-path-recovery.js";
import { resolveCurrentWakeCommentsBinding } from "../services/native-runtime/current-wake-comments.js";
import { commitNativeStatusDecision } from "../services/native-runtime/status-decision-committer.js";
import { NATIVE_STATUS_ARBITER_POLICY_VERSION } from "../services/native-runtime/status-arbiter.js";
import { recoveryService } from "../services/recovery/service.js";
describe.each(["slack", "discord"] as const)("reviewed %s execution binding", (provider) => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
@@ -432,6 +440,33 @@ describe.each(["slack", "discord"] as const)("reviewed %s execution binding", (p
}
});
it("names the first failed check with a closed label", async () => {
const denial = async (overrides: Partial<Parameters<typeof attestReviewedExternalChatRun>[0]> = {}) => {
const reasons: string[] = [];
const attested = await attestReviewedExternalChatRun({
db, ...binding, contextSnapshot: context, ...overrides, onDenied: (reason) => reasons.push(reason),
});
return { attested, reasons };
};
await expect(denial()).resolves.toEqual({ attested: true, reasons: [] });
await expect(denial({ issueId: otherIssueId })).resolves.toEqual({ attested: false, reasons: ["owner_mismatch"] });
await expect(denial({ runId: randomUUID() })).resolves.toEqual({ attested: false, reasons: ["binding_missing"] });
await expect(denial({ contextSnapshot: { ...context, wakeCommentIds: [randomUUID()] } }))
.resolves.toEqual({ attested: false, reasons: ["context_mismatch"] });
await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.principalId, userId));
try {
await expect(denial()).resolves.toEqual({ attested: false, reasons: ["principal_denied"] });
} finally {
await db.update(companyMemberships).set({ status: "active" }).where(eq(companyMemberships.principalId, userId));
}
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
try {
await expect(denial()).resolves.toEqual({ attested: false, reasons: ["run_not_running"] });
} finally {
await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, runId));
}
});
it("rejects wrong or revoked principals and disabled provider reach", async () => {
await db
.update(chatExternalPrincipals)
@@ -519,6 +554,103 @@ describe.each(["slack", "discord"] as const)("reviewed %s execution binding", (p
}
});
// The prior native run commits its status decision before its run row is
// terminal. A preserve decision does not bump statusVersion, and neither does
// the successor's claim, so its version check passes after the claim.
let predecessorContractRevision = 0;
async function finalizingPredecessor() {
const predecessorRunId = randomUUID(), contractId = randomUUID(), resultId = randomUUID(), assessmentId = randomUUID();
await db.insert(completionContracts).values({ id: contractId, companyId, issueId, revision: ++predecessorContractRevision,
schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: "standard",
completionAuthority: "server_arbiter", incompleteCriteriaPolicy: "preserve_non_terminal",
contractJson: {}, canonicalSha256: contractId, createdByActorType: "system", createdByActorId: "test" });
await db.insert(heartbeatRuns).values({ id: predecessorRunId, companyId, agentId, status: "running",
runtimeMode: "native", nativeIssueId: issueId, invocationSource: "automation", triggerDetail: "system",
completionContractId: contractId, contextSnapshot: { issueId, source: "github.pull_request_review" } });
await db.insert(nativeRunResults).values({ id: resultId, companyId, issueId, runId: predecessorRunId,
completionContractId: contractId, serverFingerprint: resultId, schemaStatus: "accepted", resultJson: {}, canonicalSha256: resultId });
const [snapshot] = await db.select().from(issues).where(eq(issues.id, issueId));
await db.insert(workAssessments).values({ id: assessmentId, companyId, issueId, runId: predecessorRunId,
contractId, resultId, triggerKind: "native_result", triggerActorCompanyId: companyId,
priorIssueStatus: snapshot.status, priorStatusVersion: snapshot.statusVersion,
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, assessmentJson: {}, inputDigest: assessmentId });
await db.insert(nativeRunFinalizations).values({ runId: predecessorRunId, companyId, issueId, phase: "arbitrating", resultId, assessmentId });
const commit = () => commitNativeStatusDecision({
db, companyId, issueId, runId: predecessorRunId, assessmentId,
priorStatus: snapshot.status, priorStatusVersion: Number(snapshot.statusVersion), priorDecisionId: snapshot.lastStatusDecisionId,
decision: { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, statusAction: "preserve", toStatus: "in_review",
reasonCode: "native_review_action_finished", unblockDescriptor: null, effects: [{ kind: "release_checkout" }] },
});
return { predecessorRunId, commit };
}
async function restoreSuccessorLock(previous: typeof issues.$inferSelect) {
await db.update(issues).set({
checkoutRunId: previous.checkoutRunId, executionRunId: previous.executionRunId,
executionAgentNameKey: previous.executionAgentNameKey, executionLockedAt: previous.executionLockedAt,
lastStatusDecisionId: previous.lastStatusDecisionId,
}).where(eq(issues.id, issueId));
}
it("keeps the successor's claim when the finalizing predecessor releases its checkout", async () => {
const [before] = await db.select().from(issues).where(eq(issues.id, issueId));
const { predecessorRunId, commit } = await finalizingPredecessor();
const lockedAt = new Date();
// The successor holds execution; the predecessor still holds its own checkout.
await db.update(issues).set({ checkoutRunId: predecessorRunId, executionRunId: runId,
executionAgentNameKey: "chat runner", executionLockedAt: lockedAt }).where(eq(issues.id, issueId));
try {
const committed = await commit();
const [after] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(after).toMatchObject({ status: "in_review", statusVersion: before.statusVersion,
checkoutRunId: null, executionRunId: runId, executionAgentNameKey: "chat runner" });
expect(after.executionLockedAt?.getTime()).toBe(lockedAt.getTime());
const [effect] = await db.select().from(statusDecisionEffects)
.where(eq(statusDecisionEffects.decisionId, committed.decision.id));
expect(effect).toMatchObject({ effectKind: "release_checkout", targetType: "issue_checkout",
payload: { checkoutRunId: null, executionRunId: runId } });
await expect(attest()).resolves.toBe(true);
} finally {
await restoreSuccessorLock(before);
}
});
it("still releases the predecessor's own execution lock and checkout", async () => {
const [before] = await db.select().from(issues).where(eq(issues.id, issueId));
const { predecessorRunId, commit } = await finalizingPredecessor();
await db.update(issues).set({ checkoutRunId: predecessorRunId, executionRunId: predecessorRunId,
executionAgentNameKey: "chat runner", executionLockedAt: new Date() }).where(eq(issues.id, issueId));
try {
await commit();
const [after] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(after).toMatchObject({ status: "in_review", checkoutRunId: null, executionRunId: null,
executionAgentNameKey: null, executionLockedAt: null });
} finally {
await restoreSuccessorLock(before);
}
});
it("leaves a cancelled successor's lock to the stale-lock sweep", async () => {
const [before] = await db.select().from(issues).where(eq(issues.id, issueId));
const { predecessorRunId, commit } = await finalizingPredecessor();
const cancelledRunId = randomUUID();
await db.insert(heartbeatRuns).values({ id: cancelledRunId, companyId, agentId, status: "cancelled",
finishedAt: new Date(), invocationSource: "automation", triggerDetail: "system", contextSnapshot: context });
await db.update(issues).set({ checkoutRunId: predecessorRunId, executionRunId: cancelledRunId,
executionAgentNameKey: "chat runner", executionLockedAt: new Date() }).where(eq(issues.id, issueId));
try {
await commit();
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0])
.toMatchObject({ checkoutRunId: null, executionRunId: cancelledRunId });
const swept = await recoveryService(db, { enqueueWakeup: vi.fn() }).sweepStaleIssueLocks();
expect(swept.issueIds).toEqual([issueId]);
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0])
.toMatchObject({ checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, executionLockedAt: null });
} finally {
await restoreSuccessorLock(before);
}
});
it("waits for exact inbound delivery processing to commit before attesting", async () => {
await db
.update(chatDeliveries)
@@ -223,6 +223,22 @@ describe("run failure diagnostics", () => {
expect(collectRunFailureDiagnostics(run({ errorCode: "adapter_failed" }), {}).execution).toEqual({});
});
it("exports only the closed reviewed-chat denial label of a setup failure", () => {
const result = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics(run({
errorCode: "setup_failed", error: "reviewed_chat_execution_binding_not_authorized",
resultJson: { reviewedChatBinding: { denialReason: "owner_mismatch", runId: "private-run", commentId: "private-comment" } },
}), {}));
expect(result.execution).toEqual({ reviewedChatBindingDenialReason: "owner_mismatch" });
expect(JSON.stringify(result)).not.toContain("private");
for (const value of [null, [], { denialReason: "private-reason" },
Object.defineProperty({}, "denialReason", { get() { throw new Error("private"); } })]) {
expect(collectRunFailureDiagnostics(run({ errorCode: "setup_failed",
resultJson: { reviewedChatBinding: value } }), {}).execution).toEqual({});
}
expect(collectRunFailureDiagnostics(run({ errorCode: "adapter_failed",
resultJson: { reviewedChatBinding: { denialReason: "owner_mismatch" } } }), {}).execution).toEqual({});
});
it("exports the remote recovery stage and reason from an error thrown before the result was saved", () => {
const thrown = Object.assign(new RemoteRunnerRecoveryError({ stage: "process_verification", reason: "process_not_running" }),
{ path: "private-path", pid: 123 });
+9 -2
View File
@@ -407,6 +407,10 @@ import {
emitAgentTaskRunById,
} from "./agent-task-run-telemetry.js";
import { reportRunFailure } from "./run-failure-report.js";
import {
ReviewedChatBindingDeniedError,
type ReviewedChatBindingDenialReason,
} from "./reviewed-chat-binding-diagnostics.js";
import { collectRunFailureSecretValues, type RunFailureReportOptions } from "./run-failure-diagnostics.js";
import { companySkillService } from "./company-skills.js";
@@ -2241,6 +2245,7 @@ export function heartbeatService(
context.interactionKind === "ask_user_questions" &&
["in_progress", "in_review"].includes(issueContext?.status ?? "")))
) {
let denialReason: ReviewedChatBindingDenialReason | null = null;
const attested = await attestReviewedExternalChatRun({
db,
companyId: agent.companyId,
@@ -2251,9 +2256,11 @@ export function heartbeatService(
onQuestionResponseAttested: (answeredAtMs) => {
attestedQuestionResponseAtMs = answeredAtMs;
},
onDenied: (reason) => {
denialReason = reason;
},
});
if (!attested)
throw new Error("reviewed_chat_execution_binding_not_authorized");
if (!attested) throw new ReviewedChatBindingDeniedError(denialReason);
context[PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY] = true;
}
const wakeCommentId = deriveCommentId(context, null);
@@ -16,6 +16,7 @@ import { isWorkspaceValidationFailedRun, isWorkspaceValidationFailure, attachPap
import { preserveWorkspaceRestoreRecoveryMetadataSql } from "../legacy-workspace-restore-recovery.js";
import { cancellationResultJson, controllerLeaseLossCancellation, readRunCancellation } from "../run-cancellation.js";
import { CONTROLLER_LEASE_LOST_FAILURE_CODE, LegacyControllerLeaseLostError } from "../legacy-controller-lease.js";
import { reviewedChatBindingDenialResultJson } from "../reviewed-chat-binding-diagnostics.js";
import { accountRunCost } from "../run-cost-accounting.js";
import { isConversation, settleConversationTurn } from "../agent-conversations.js";
import { legacyExecutionNeedsReconciliationWithEvidence } from "../legacy-execution-recovery.js";
@@ -1409,6 +1410,7 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC
// the signal's reason when a setup step threw a different error after it.
...controllerLeaseLossEvidence(outerErr),
...controllerLeaseLossResultJson(signal),
...reviewedChatBindingDenialResultJson(outerErr),
...(!providerDispatchStarted
? { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } }
: {}),
@@ -69,6 +69,7 @@ import {
toolProfiles,
} from "@paperclipai/db";
import { HttpError } from "../../errors.js";
import type { ReviewedChatBindingDenialReason } from "../reviewed-chat-binding-diagnostics.js";
import { filterResolvedGitHubConnectionsForRun } from "../git-credentials.js";
import type {
AdapterRuntimeMcpAccess,
@@ -2007,6 +2008,15 @@ export async function resolveExternalChatWakeProvider(input: {
}
/** Bind an already-claimed chat/reply turn without checking out or approving it. */
const CHAT_ATTACHMENT_DENIAL_REASONS: ReadonlyMap<
string,
ReviewedChatBindingDenialReason
> = new Map<string, ReviewedChatBindingDenialReason>([
["paperclip_runner_chat_attachment_binding_denied", "binding_denied"],
["paperclip_runner_chat_attachment_destination_denied", "destination_denied"],
["paperclip_runner_chat_attachment_principal_denied", "principal_denied"],
]);
export async function attestReviewedExternalChatRun(input: {
db: Db;
companyId: string;
@@ -2016,12 +2026,19 @@ export async function attestReviewedExternalChatRun(input: {
contextSnapshot: Record<string, unknown>;
/** Diagnostic-only value from the committed, authorized answer; never a wake field. */
onQuestionResponseAttested?: (answeredAtMs: number) => void;
/** Receives the closed label of the first failed check when attestation is denied. */
onDenied?: (reason: ReviewedChatBindingDenialReason) => void;
}): Promise<boolean> {
const attempt = () =>
input.db.transaction(
async (
transaction,
): Promise<boolean | "pending_delivery" | { answeredAtMs: number }> => {
): Promise<
| true
| { denied: ReviewedChatBindingDenialReason }
| "pending_delivery"
| { answeredAtMs: number }
> => {
const tx = transaction as unknown as Db;
const [issue] = await tx
.select()
@@ -2057,25 +2074,27 @@ export async function attestReviewedExternalChatRun(input: {
)
.for("update", { noWait: true })
.limit(1);
if (!issue || !run || !actor) return { denied: "binding_missing" };
if (
!issue ||
!run ||
!actor ||
!(
issue.status === "in_review" ||
(input.contextSnapshot.source === "issue.interaction.respond" &&
issue.status === "in_progress")
) ||
issue.assigneeAgentId !== input.agentId ||
issue.executionRunId !== input.runId ||
run.status !== "running" ||
(run.nativeIssueId !== null && run.nativeIssueId !== input.issueId) ||
// This attestation precedes execution-start's transition to running.
// A prior run's error projection is invokable, unlike an operator
// pause, termination, or pending approval; all binding checks remain.
DIRECT_NON_INVOKABLE_STATUSES.has(actor.status)
)
)
return false;
return { denied: "issue_status" };
if (issue.assigneeAgentId !== input.agentId)
return { denied: "assignee_mismatch" };
if (issue.executionRunId !== input.runId)
return { denied: "owner_mismatch" };
if (run.status !== "running") return { denied: "run_not_running" };
if (run.nativeIssueId !== null && run.nativeIssueId !== input.issueId)
return { denied: "native_issue_mismatch" };
// This attestation precedes execution-start's transition to running.
// A prior run's error projection is invokable, unlike an operator
// pause, termination, or pending approval; all binding checks remain.
if (DIRECT_NON_INVOKABLE_STATUSES.has(actor.status))
return { denied: "agent_not_invokable" };
const admittedContext = parseObject(run.contextSnapshot);
const admittedIds = extractWakeCommentIds(admittedContext);
const suppliedIds = extractWakeCommentIds(input.contextSnapshot);
@@ -2087,7 +2106,7 @@ export async function attestReviewedExternalChatRun(input: {
admittedIds.length !== suppliedIds.length ||
admittedIds.some((id, index) => id !== suppliedIds[index])
)
return false;
return { denied: "context_mismatch" };
try {
const answer =
admittedContext.source === "issue.interaction.respond"
@@ -2100,7 +2119,7 @@ export async function attestReviewedExternalChatRun(input: {
)
: null;
if (admittedContext.source === "issue.interaction.respond" && !answer)
return false;
return { denied: "question_response_missing" };
// The marker is built here only after proving the real execution owner.
// The shared boundary then verifies current provider/resource/principal
// access for every admitted message; no lifecycle state is mutated.
@@ -2164,15 +2183,11 @@ export async function attestReviewedExternalChatRun(input: {
return "pending_delivery";
}
}
if (
error instanceof Error &&
[
"paperclip_runner_chat_attachment_binding_denied",
"paperclip_runner_chat_attachment_destination_denied",
"paperclip_runner_chat_attachment_principal_denied",
].includes(error.message)
)
return false;
const denied =
error instanceof Error
? CHAT_ATTACHMENT_DENIAL_REASONS.get(error.message)
: undefined;
if (denied) return { denied };
throw error;
}
},
@@ -2183,6 +2198,10 @@ export async function attestReviewedExternalChatRun(input: {
for (let attemptNumber = 0; attemptNumber < 51; attemptNumber += 1) {
try {
const result = await attempt();
if (typeof result === "object" && "denied" in result) {
input.onDenied?.(result.denied);
return false;
}
if (typeof result === "object") {
// The transaction (including COMMIT) must succeed before any attempt-
// local timing is accepted. A retry/rollback cannot publish this value.
@@ -1540,13 +1540,17 @@ async function materializeDecisionEffect(input: {
}
if (effect.kind === "release_checkout") {
await notifyUnsafeWorkspaceExportRecovery(input.tx, { companyId: input.companyId, issueId: input.issue.id });
// Release only this run's own locks. A successor can claim execution
// without changing statusVersion, so a preserve decision committed after
// that claim must not clear it. Each SET reads the pre-update row.
const ownsExecution = sql`${issues.executionRunId} = ${input.runId}::uuid`;
const [released] = await input.tx
.update(issues)
.set({
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
checkoutRunId: sql`case when ${issues.checkoutRunId} = ${input.runId}::uuid then null else ${issues.checkoutRunId} end`,
executionRunId: sql`case when ${ownsExecution} then null else ${issues.executionRunId} end`,
executionAgentNameKey: sql`case when ${ownsExecution} then null else ${issues.executionAgentNameKey} end`,
executionLockedAt: sql`case when ${ownsExecution} then null else ${issues.executionLockedAt} end`,
updatedAt: new Date(),
})
.where(
@@ -1555,13 +1559,20 @@ async function materializeDecisionEffect(input: {
eq(issues.companyId, input.companyId),
),
)
.returning({ id: issues.id });
.returning({
id: issues.id,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
});
if (!released) throw new Error("native_checkout_not_released");
return {
effectKind: effect.kind,
targetType: "issue_checkout",
targetId: released.id,
payload: { checkoutRunId: null, executionRunId: null },
payload: {
checkoutRunId: released.checkoutRunId,
executionRunId: released.executionRunId,
},
};
}
if (effect.kind === "record_recovery") {
@@ -0,0 +1,43 @@
export const REVIEWED_CHAT_BINDING_NOT_AUTHORIZED = "reviewed_chat_execution_binding_not_authorized";
/**
* The first reviewed-chat attestation check that denied a run. Closed labels
* only: the run result and its failure report never carry IDs or message text.
*/
const reasons = [
"binding_missing", "issue_status", "assignee_mismatch", "owner_mismatch",
"run_not_running", "native_issue_mismatch", "agent_not_invokable",
"context_mismatch", "question_response_missing",
"binding_denied", "destination_denied", "principal_denied",
] as const;
export type ReviewedChatBindingDenialReason = typeof reasons[number];
export function isReviewedChatBindingDenialReason(value: unknown): value is ReviewedChatBindingDenialReason {
return reasons.some((reason) => reason === value);
}
/** Keeps the established run error text while carrying the denied check. */
export class ReviewedChatBindingDeniedError extends Error {
readonly code = REVIEWED_CHAT_BINDING_NOT_AUTHORIZED;
constructor(readonly denialReason: ReviewedChatBindingDenialReason | null) {
super(REVIEWED_CHAT_BINDING_NOT_AUTHORIZED);
}
}
export function reviewedChatBindingDenialResultJson(error: unknown) {
return error instanceof ReviewedChatBindingDeniedError && error.denialReason
? { reviewedChatBinding: { denialReason: error.denialReason } }
: {};
}
export function readReviewedChatBindingDenialReason(value: unknown): ReviewedChatBindingDenialReason | null {
if (!value || typeof value !== "object") return null;
try {
const reason = (value as Record<string, unknown>).denialReason;
return isReviewedChatBindingDenialReason(reason) ? reason : null;
} catch {
return null;
}
}
@@ -3,6 +3,7 @@ import { getEnvironmentAcquisitionDiagnostic } from "./environment-acquisition-d
import type { heartbeatRuns } from "@paperclipai/db";
import { readControllerLeaseLoss, readControllerLeaseLossCause, readRunCancellation } from "./run-cancellation.js";
import { readProcessLossDiagnostic } from "./process-loss-diagnostics.js";
import { readReviewedChatBindingDenialReason } from "./reviewed-chat-binding-diagnostics.js";
import { WORKSPACE_RESTORE_FAILURE_CODES } from "@paperclipai/shared";
import { redactDiagnosticText } from "@paperclipai/adapter-utils/command-redaction";
import { sanitizeWorkspaceRestoreDiagnostic } from "@paperclipai/adapter-utils/workspace-restore-diagnostics";
@@ -327,6 +328,9 @@ export function collectRunFailureDiagnostics(run: Run, options: RunFailureReport
if (leaseLoss.deadlineLateMs !== undefined) execution.controllerLeaseDeadlineLateMs = leaseLoss.deadlineLateMs;
if (leaseLoss.renewalCount !== undefined) execution.controllerLeaseRenewalCount = leaseLoss.renewalCount;
}
const reviewedChatDenial = run.errorCode === "setup_failed"
? readReviewedChatBindingDenialReason(read(result, "reviewedChatBinding")) : null;
if (reviewedChatDenial) execution.reviewedChatBindingDenialReason = reviewedChatDenial;
Object.assign(execution, scalars(result, [
"mode", "stopReason", "timeoutFired", "timeoutSource", "timeoutConfigured",
"effectiveTimeoutSec", "errorFamily",