Files
PaperClipAI/server/src/services/native-runtime/current-wake-comments.ts
T
DottaandClaude Fable 5.2 b2779054d4 fix(issues): let a native run hand its own task to a person without cancelling itself (#20884)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A task has one assignee. An agent often finishes its work by handing
its own task to a person for review.
> - The task update route stops the outgoing run before it saves a
reassignment.
> - Since #16082, a legacy run that hands its own task to a person is
exempt, so it finishes its turn. Native runs were not exempt.
> - On staging, a native run (ACPX Claude, 11.5 minutes of work) sent
`PATCH /api/issues/{id}` to hand its task to a person. The route
cancelled that run with `issue_reassigned`. The agent saw
`api_transport_failure`. The task moved to the person, but it kept a
stale execution blocker ("Saved work recovered. Send a message to
continue.").
> - The native runtime assumes a running run still owns its task. So
widening the exemption alone is not enough: the run then fails after its
result is accepted, and its finalizer can act on the person's task.
> - This pull request applies the exemption to native runs and makes the
native terminal fence, restart recovery and finalizer handle a run that
gave its task away.
> - The benefit is that an agent can hand its task to a person and
finish normally, with no blocker on the person's task.

## Linked Issues or Issue Description

Refs #16082 (legacy exemption). Related: #20541 (agent-to-agent self
handoff) and #18293 (agent key handoff). Those PRs cover other actors
and targets. This PR covers native runs that hand their task to a
person.

**What happened?**

A native agent run handed its own task to a person (`assigneeAgentId:
null`, `assigneeUserId`, status `in_review`) through its signed run
token. The route cancelled the run that sent the request
(`issue_reassigned`, "Cancelled before issue reassignment"). The
cancelled native run then went through workspace recovery. The person's
task kept an execution blocker with `restore_failed`.

**Expected behavior**

The run that hands its own task to a person keeps running and finishes
its turn. The task rests `in_review` with the person. No execution
blocker or recovery action stays on the task. The outgoing run cannot
change the task after the hand-off.

**Steps to reproduce**

1. Run a native agent on its assigned task.
2. From that run, send `PATCH /api/issues/{id}` with `assigneeAgentId:
null`, a person's `assigneeUserId`, and status `in_review`.
3. Before this fix, the route cancels the run with `issue_reassigned`.

**Why the exemption was legacy-only**

#16082 limited the exemption to the run shape of its incident and kept a
verified stop for every other case. The new H2 chaos workload showed why
native needed more than the route change. With only the route widened:

1. The run's terminal fence `assertCurrentWakeCommentsRead` requires the
run to still own the task. It threw
`native_current_wake_comments_binding_changed` after the result was
accepted. The run then failed with `native_session_lease_lost`, and its
finalizer recorded `workspace_finalization_failed` as an agent-owned
recovery on the person's task.
2. A restart after the hand-off left the run `running`, because restart
recovery refused it with `issue_execution_lock_changed`.
3. The native finalizer only superseded a `done` decision. A
`needs_review` report moved the person's task back to `in_progress` and
queued a continuation for the outgoing agent.
4. The REST guard against the outgoing run's later mutations
(`issue_handed_off`) applied to legacy runs only.

## What Changed

- `server/src/routes/issues.ts`: the signed current-run human
self-handoff exemption now applies to native runs. All other checks
stay: exact run, agent, company and task, `agent_jwt` source, human
recipient. For a native run the route first records the hand-off on the
run (`resultJson.humanSelfHandoff`, new `native-human-self-handoff.ts`).
Board reassignment, agent keys, and handoffs to an agent or to nobody
still stop the outgoing run.
- `current-wake-comments.ts`: the terminal fence accepts a run whose
recorded hand-off matches the person who now owns the task. Without the
record (for example a board reassignment), or after a later
reassignment, it still refuses.
- `native-restart-recovery.ts`: after a restart, a handed-off run is not
resumed. It passes the execution-lock check and is then settled by the
existing ownership gate (`task_ownership_or_status_changed`) without
changing the person's task, instead of staying `running`.
- `status-arbiter.ts` and `native-run-finalizer.ts`:
`supersedeHandedOffDecision` keeps the person's task status and strips
retries, continuations, owner wakes, recoveries, blockers and reviewer
bindings, also from decisions that already preserve the status. Two
effects stay: a governed request's interaction, and the
workspace-finalization repair, so saved work is not lost. Review runs
are not affected.
- `server/src/run-task-ownership.ts` (renamed from
`legacy-run-task-ownership.ts`): `assertHandedOffRunTaskOwnership`
rejects later task mutations from the outgoing legacy or native run, in
the route and under the update transaction's locks.
- `tests/restart-chaos/`: new opt-in workload `H2`. The agent saves a
brief, then hands the task to its creator in review as its last step.
The oracle requires the task to rest `in_review` with the creator, every
run to settle without `issue_reassigned`, readable non-empty run
history, and no execution blocker or active recovery action.
- Docs: `doc/execution-semantics.md`, task feature map and agent access
map, chaos README.

## Verification

- Red on `origin/master` (same tests, master source): the route cancels
the native run (500, and 409
`runner_goal_reassignment_stop_unconfirmed`); the finalizer moves the
person's task to `in_progress` with a continuation for the outgoing
agent; a late `done` from the outgoing native run closes the person's
task.
- Red chaos wave on master's server (`--server-from` a master checkout,
`--adapters claude --workloads H2 --faults K1 --matrix`): 0/2 pass. Both
runs ended `cancelled` / `issue_reassigned`
(`T:handoff_did_not_cancel_run`).
- Green focused tests on this branch:
- `native-human-self-handoff.test.ts` (embedded Postgres): hand-off
through the real issue service; the run stays `running`; `done`,
`needs_review` and failed-turn reports leave the task `in_review` with
the person, with `getExecutionBlocker` null and no recovery action; the
terminal fence accepts only the recorded hand-off; a late change from
the outgoing run fails with `issue_handed_off`; pure policy cases.
- `issue-agent-mutation-ownership-routes.test.ts`: native and legacy
self-handoffs persist without `cancelRun`, and native records the
hand-off before the update. Board, agent key, other run, task, agent and
company still stop the run.
- Native runtime suite (restart recovery, wake comments, arbiter,
stale-owner completion), `issues-service`, `low-trust-red-team-routes`:
pass. Server and restart-chaos typecheck pass. `node
scripts/check-maps.mjs` passes.
- Green chaos waves on this branch (local, `--adapters claude
--workloads H2`):
- `--matrix --faults K1 --seed 4459`: 2/2 pass (K0 baseline and a K1
crossing).
- `--tasks 3 --faults K1 --fault-after-seconds 60` and
`--fault-after-seconds 100`: 3/3 pass each. In the first, all three
restarts landed after the hand-off while the runs were live; the runs
settled and the tasks stayed with the person.
- The final waves ran on the pre-rebase head `8a2a3b6a74`. The rebase
onto master only merged the chaos workload lists (`types.ts`,
`config.ts`, `journeys.ts`). No Daytona wave was run.

## Risks

- The outgoing native turn keeps running after the person owns the task.
Its native task tools already require the run to own the task, so they
refuse (`paperclip_runner_tool_binding_not_authorized`). Its REST
mutations are rejected with `issue_handed_off`.
- A restart after the hand-off settles the run as failed
(`native_restart_recovery_blocked`) instead of resuming it. The person's
task is not changed and needs no Retry.
- The finalizer rule covers any native run whose task belongs to a
person at finalization, not only a self-handoff. Before this change such
a run could still move the person's task and queue its own continuation.
- Saved state: `resultJson.humanSelfHandoff` is a new optional run key.
An older server ignores it, so a run handed off on the new server and
finished on an old one fails its fence as today. No schema change.
- No API shape change.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude (Anthropic), model ID `claude-opus-5-5`, through Claude Code
with tool use and code execution. The session does not expose the
context window or reasoning mode.

## 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
- [x] 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
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:30:59 -06:00

843 lines
26 KiB
TypeScript

import { createHash } from "node:crypto";
import { and, asc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
assets,
heartbeatRuns,
issueAttachments,
issueComments,
issues,
} from "@paperclipai/db";
import type { SourceTrustMetadata } from "@paperclipai/shared";
import { createRunSecretRedactionRegistry } from "../run-secret-redaction.js";
import { sanitizeQuarantinedCommentForHigherTrust } from "../source-trust.js";
import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-review-participant.js";
import { nativeRunHandedOffTask } from "./native-human-self-handoff.js";
export const READ_CURRENT_WAKE_COMMENTS_TOOL_NAME =
"read_current_wake_comments";
export const CURRENT_WAKE_COMMENTS_BINDING_SCHEMA =
"paperclip.current-wake-comments-binding.v1";
export const CURRENT_WAKE_COMMENTS_RECEIPT_SCHEMA =
"paperclip.current-wake-comments-receipt.v1";
export const NATIVE_CURRENT_WAKE_COMMENTS_UNREAD = "native_current_wake_comments_unread";
export const NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ = "native_current_wake_comments_changed_after_read";
const CURRENT_WAKE_COMMENTS_RECEIPT_KEY = "currentWakeCommentsReceipt";
const MAX_PAGE_BODY_CHARS = 12_000;
const MAX_COMMENT_CHUNK_CHARS = 4_000;
const MAX_PAGE_COMMENT_CHUNKS = 8;
const MAX_CURSOR_CHARS = 1_024;
const EXTERNAL_CHAT_PROVIDERS = new Set([
"slack",
"github",
"discord",
"microsoft-teams",
"telegram",
"imessage-photon",
"speko",
]);
const ATTACHMENT_OMISSION_REASONS = new Set([
"attachment_limit",
"storage_unavailable",
"declared_too_large",
"download_unavailable",
"unsupported_type",
"empty_download",
"downloaded_too_large",
"processing_failed",
]);
type CurrentWakeAttachmentOmission = {
commentId: string;
reasons: Record<string, number>;
};
export const READ_CURRENT_WAKE_COMMENTS_TOOL_DEFINITION = Object.freeze({
name: READ_CURRENT_WAKE_COMMENTS_TOOL_NAME,
description:
"Read only the complete, server-bound comments accepted for this external-chat turn. Start without a cursor, then pass each returned nextCursor until complete is true before answering.",
inputSchema: {
type: "object",
properties: {
cursor: {
type: ["string", "null"],
maxLength: MAX_CURSOR_CHARS,
description:
"Opaque cursor returned by the immediately preceding page. Omit for the first page.",
},
},
required: [],
additionalProperties: false,
},
annotations: {
semanticContract: "paperclip.server-current-wake-comments.v1",
operationId: READ_CURRENT_WAKE_COMMENTS_TOOL_NAME,
version: 1,
exposure: "run_scoped",
requiredClaims: [],
},
});
export type CurrentWakeCommentsBinding = {
schema: typeof CURRENT_WAKE_COMMENTS_BINDING_SCHEMA;
companyId: string;
issueId: string;
runId: string;
agentId: string;
provider: string;
commentIds: readonly string[];
attachmentOmissions: readonly CurrentWakeAttachmentOmission[];
bindingDigest: string;
};
type CurrentWakeComment = {
id: string;
state: "available" | "deleted" | "missing";
body: string;
authorType: string | null;
authorId: string | null;
createdAt: string | null;
deletedAt: string | null;
attachmentImportNotice: string | null;
attachments: Array<{
id: string;
filename: string;
contentType: string;
byteSize: number;
contentAccess: "metadata_only";
}>;
};
type CurrentWakeCommentsSnapshot = {
comments: CurrentWakeComment[];
snapshotDigest: string;
};
type CurrentWakeCommentsCursor = {
schema: "paperclip.current-wake-comments-cursor.v1";
bindingDigest: string;
snapshotDigest: string;
commentIndex: number;
bodyOffset: number;
};
type CurrentWakeCommentChunk = Omit<CurrentWakeComment, "body"> & {
bodyChunk: string;
bodyOffset: number;
bodyComplete: boolean;
};
export type CurrentWakeCommentsPage = {
schema: "paperclip.current-wake-comments-page.v1";
bindingDigest: string;
snapshotDigest: string;
requestedCount: number;
comments: CurrentWakeCommentChunk[];
nextCursor: string | null;
complete: boolean;
};
type CurrentWakeCommentsReceipt = {
schema: typeof CURRENT_WAKE_COMMENTS_RECEIPT_SCHEMA;
bindingDigest: string;
snapshotDigest: string;
inputCursor: string | null;
nextCursor: string | null;
complete: boolean;
result: CurrentWakeCommentsPage;
updatedAt: string;
};
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (typeof value === "object" && value !== null) {
const object = value as Record<string, unknown>;
return `{${Object.keys(object)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`)
.join(",")}}`;
}
return JSON.stringify(value) ?? "null";
}
function sha256(value: unknown): string {
return createHash("sha256").update(canonicalJson(value)).digest("hex");
}
function commentIdsFromWakePayload(value: unknown): string[] {
const raw = record(value).commentIds;
if (!Array.isArray(raw)) return [];
const ids: string[] = [];
for (const entry of raw) {
const id = nonEmptyString(entry);
if (!id || ids.includes(id)) continue;
ids.push(id);
}
return ids;
}
function attachmentOmissionsFromWakePayload(
value: unknown,
commentIds: readonly string[],
): CurrentWakeAttachmentOmission[] {
const raw = record(value).attachmentOmissions;
if (!Array.isArray(raw)) return [];
const commentIdSet = new Set(commentIds);
const byCommentId = new Map<string, CurrentWakeAttachmentOmission>();
for (const candidate of raw) {
const omission = record(candidate);
const commentId = nonEmptyString(omission.commentId);
if (!commentId || !commentIdSet.has(commentId)) continue;
const reasons = Object.fromEntries(
Object.entries(record(omission.reasons)).flatMap(([reason, count]) =>
ATTACHMENT_OMISSION_REASONS.has(reason) &&
typeof count === "number" &&
Number.isSafeInteger(count) &&
count > 0
? [[reason, count]]
: [],
),
);
if (Object.keys(reasons).length === 0) continue;
byCommentId.set(commentId, { commentId, reasons });
}
return [...byCommentId.values()];
}
function attachmentImportNotice(
omission: CurrentWakeAttachmentOmission | undefined,
) {
if (!omission) return null;
const entries = Object.entries(omission.reasons);
const omitted = entries.reduce((total, [, count]) => total + count, 0);
const reasons = entries
.map(([reason, count]) => `${reason.replaceAll("_", " ")}: ${count}`)
.join(", ");
return `Paperclip could not import every attachment from this exact external message: ${omitted} attachment${omitted === 1 ? " was" : "s were"} omitted (${reasons}). Treat omitted attachments as unavailable; do not infer their contents or substitute an older workspace file.`;
}
function bindingDigest(input: {
companyId: string;
issueId: string;
runId: string;
agentId: string;
provider: string;
commentIds: readonly string[];
attachmentOmissions: readonly CurrentWakeAttachmentOmission[];
}) {
return sha256({
schema: CURRENT_WAKE_COMMENTS_BINDING_SCHEMA,
companyId: input.companyId,
issueId: input.issueId,
runId: input.runId,
agentId: input.agentId,
provider: input.provider,
commentIds: input.commentIds,
attachmentOmissions: input.attachmentOmissions,
});
}
/**
* Resolve the reader only from a server-built, durably persisted native wake.
* The provider marker itself was produced by the chat-link join in heartbeat;
* callers cannot manufacture reader authority with source text or arbitrary ids.
*/
export async function resolveCurrentWakeCommentsBinding(
db: Db,
input: {
companyId: string;
issueId: string;
runId: string;
agentId: string;
},
): Promise<CurrentWakeCommentsBinding | null> {
const bound = await db
.select({
run: heartbeatRuns,
issueAssigneeAgentId: issues.assigneeAgentId,
issueExecutionRunId: issues.executionRunId,
})
.from(heartbeatRuns)
.innerJoin(
issues,
and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)),
)
.where(
and(
eq(heartbeatRuns.id, input.runId),
eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.agentId, input.agentId),
eq(heartbeatRuns.nativeIssueId, input.issueId),
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
if (
!bound ||
bound.run.runtimeMode !== "native" ||
bound.run.status !== "running" ||
bound.issueAssigneeAgentId !== input.agentId ||
bound.issueExecutionRunId !== input.runId
) {
return null;
}
const wake = record(record(bound.run.contextSnapshot).paperclipWake);
const provider = nonEmptyString(wake.externalChatProvider);
const commentIds = commentIdsFromWakePayload(wake);
const attachmentOmissions = attachmentOmissionsFromWakePayload(
wake,
commentIds,
);
const commentWindow = record(wake.commentWindow);
if (
!provider ||
!EXTERNAL_CHAT_PROVIDERS.has(provider) ||
(wake.checkedOutByHarness !== true &&
!(
wake.externalChatExecutionBound === true &&
record(bound.run.contextSnapshot)
.paperclipExternalChatExecutionBound === true
)) ||
wake.fallbackFetchNeeded !== true ||
commentIds.length === 0 ||
wake.latestCommentId !== commentIds[commentIds.length - 1] ||
commentWindow.requestedCount !== commentIds.length
) {
return null;
}
const digest = bindingDigest({
...input,
provider,
commentIds,
attachmentOmissions,
});
return {
schema: CURRENT_WAKE_COMMENTS_BINDING_SCHEMA,
companyId: input.companyId,
issueId: input.issueId,
runId: input.runId,
agentId: input.agentId,
provider,
commentIds,
attachmentOmissions,
bindingDigest: digest,
};
}
function encodeCursor(cursor: CurrentWakeCommentsCursor): string {
return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
}
function decodeCursor(
value: unknown,
binding: CurrentWakeCommentsBinding,
snapshotDigest: string,
): CurrentWakeCommentsCursor | null {
if (value === undefined || value === null) return null;
if (
typeof value !== "string" ||
value.length === 0 ||
value.length > MAX_CURSOR_CHARS
) {
throw new Error("paperclip_current_wake_comments_cursor_invalid");
}
try {
const parsed = record(
JSON.parse(Buffer.from(value, "base64url").toString("utf8")),
);
if (
parsed.schema !== "paperclip.current-wake-comments-cursor.v1" ||
parsed.bindingDigest !== binding.bindingDigest ||
parsed.snapshotDigest !== snapshotDigest ||
!Number.isSafeInteger(parsed.commentIndex) ||
!Number.isSafeInteger(parsed.bodyOffset) ||
Number(parsed.commentIndex) < 0 ||
Number(parsed.bodyOffset) < 0
) {
throw new Error("paperclip_current_wake_comments_cursor_invalid");
}
return parsed as CurrentWakeCommentsCursor;
} catch (error) {
if (
error instanceof Error &&
error.message === "paperclip_current_wake_comments_cursor_invalid"
) {
throw error;
}
throw new Error("paperclip_current_wake_comments_cursor_invalid");
}
}
function parseReceipt(value: unknown): CurrentWakeCommentsReceipt | null {
const receipt = record(value);
const result = record(receipt.result);
if (
receipt.schema !== CURRENT_WAKE_COMMENTS_RECEIPT_SCHEMA ||
typeof receipt.bindingDigest !== "string" ||
typeof receipt.snapshotDigest !== "string" ||
!(
receipt.inputCursor === null || typeof receipt.inputCursor === "string"
) ||
!(receipt.nextCursor === null || typeof receipt.nextCursor === "string") ||
typeof receipt.complete !== "boolean" ||
result.schema !== "paperclip.current-wake-comments-page.v1"
) {
return null;
}
return receipt as CurrentWakeCommentsReceipt;
}
async function currentWakeCommentsSnapshot(
db: Db,
binding: CurrentWakeCommentsBinding,
): Promise<CurrentWakeCommentsSnapshot> {
const rows = await db
.select({
id: issueComments.id,
body: issueComments.body,
authorType: issueComments.authorType,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
deletedAt: issueComments.deletedAt,
sourceTrust: issueComments.sourceTrust,
createdAt: issueComments.createdAt,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, binding.companyId),
eq(issueComments.issueId, binding.issueId),
inArray(issueComments.id, [...binding.commentIds]),
),
)
.orderBy(asc(issueComments.id))
.for("update");
const attachments = await db
.select({
id: issueAttachments.id,
issueCommentId: issueAttachments.issueCommentId,
filename: assets.originalFilename,
contentType: assets.contentType,
byteSize: assets.byteSize,
createdAt: issueAttachments.createdAt,
})
.from(issueAttachments)
.innerJoin(
assets,
and(
eq(assets.id, issueAttachments.assetId),
eq(assets.companyId, binding.companyId),
),
)
.where(
and(
eq(issueAttachments.companyId, binding.companyId),
eq(issueAttachments.issueId, binding.issueId),
inArray(issueAttachments.issueCommentId, [...binding.commentIds]),
),
)
.orderBy(asc(issueAttachments.createdAt), asc(issueAttachments.id));
const rowsById = new Map(rows.map((row) => [row.id, row]));
const omissionsByCommentId = new Map(
binding.attachmentOmissions.map((omission) => [
omission.commentId,
omission,
]),
);
const attachmentsByCommentId = new Map<
string,
CurrentWakeComment["attachments"]
>();
for (const attachment of attachments) {
if (!attachment.issueCommentId) continue;
const current = attachmentsByCommentId.get(attachment.issueCommentId) ?? [];
current.push({
id: attachment.id,
filename: attachment.filename?.trim() || "attachment",
contentType: attachment.contentType,
byteSize: attachment.byteSize,
// This closed reader intentionally has no general Paperclip API key.
// Inline wake attachments are staged separately by the native harness;
// overflow attachments remain visible as metadata so the agent can be
// truthful rather than claiming it inspected bytes it cannot access.
contentAccess: "metadata_only",
});
attachmentsByCommentId.set(attachment.issueCommentId, current);
}
const unredacted = binding.commentIds.map((commentId): CurrentWakeComment => {
const row = rowsById.get(commentId);
if (!row) {
return {
id: commentId,
state: "missing",
body: "",
authorType: null,
authorId: null,
createdAt: null,
deletedAt: null,
attachmentImportNotice: attachmentImportNotice(
omissionsByCommentId.get(commentId),
),
attachments: [],
};
}
const deletedAt = row.deletedAt?.toISOString() ?? null;
const safe = deletedAt
? row
: sanitizeQuarantinedCommentForHigherTrust({
...row,
presentation: null,
metadata: null,
sourceTrust: row.sourceTrust as SourceTrustMetadata | null,
});
return {
id: commentId,
state: deletedAt ? "deleted" : "available",
body: deletedAt ? "" : safe.body,
authorType:
row.authorType ??
(row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"),
authorId: row.authorAgentId ?? row.authorUserId ?? null,
createdAt: row.createdAt.toISOString(),
deletedAt,
attachmentImportNotice: deletedAt
? null
: attachmentImportNotice(omissionsByCommentId.get(commentId)),
attachments: deletedAt
? []
: (attachmentsByCommentId.get(commentId) ?? []),
};
});
const comments = await createRunSecretRedactionRegistry(db).redactForIssue(
binding.companyId,
binding.issueId,
unredacted,
);
return {
comments,
snapshotDigest: sha256({
bindingDigest: binding.bindingDigest,
comments,
}),
};
}
function buildPage(
binding: CurrentWakeCommentsBinding,
snapshot: CurrentWakeCommentsSnapshot,
cursor: CurrentWakeCommentsCursor | null,
): CurrentWakeCommentsPage {
let commentIndex = cursor?.commentIndex ?? 0;
let bodyOffset = cursor?.bodyOffset ?? 0;
if (commentIndex > snapshot.comments.length) {
throw new Error("paperclip_current_wake_comments_cursor_invalid");
}
const chunks: CurrentWakeCommentChunk[] = [];
let remainingChars = MAX_PAGE_BODY_CHARS;
while (
commentIndex < snapshot.comments.length &&
chunks.length < MAX_PAGE_COMMENT_CHUNKS &&
remainingChars > 0
) {
const comment = snapshot.comments[commentIndex]!;
if (bodyOffset > comment.body.length) {
throw new Error("paperclip_current_wake_comments_cursor_invalid");
}
const remainingBody = comment.body.slice(bodyOffset);
const chunkSize = Math.min(
remainingBody.length,
MAX_COMMENT_CHUNK_CHARS,
remainingChars,
);
const bodyChunk = remainingBody.slice(0, chunkSize);
const bodyComplete = bodyOffset + chunkSize >= comment.body.length;
chunks.push({
...comment,
bodyChunk,
bodyOffset,
bodyComplete,
});
remainingChars -= bodyChunk.length;
if (bodyComplete) {
commentIndex += 1;
bodyOffset = 0;
} else {
bodyOffset += chunkSize;
}
}
const complete = commentIndex >= snapshot.comments.length;
const nextCursor = complete
? null
: encodeCursor({
schema: "paperclip.current-wake-comments-cursor.v1",
bindingDigest: binding.bindingDigest,
snapshotDigest: snapshot.snapshotDigest,
commentIndex,
bodyOffset,
});
return {
schema: "paperclip.current-wake-comments-page.v1",
bindingDigest: binding.bindingDigest,
snapshotDigest: snapshot.snapshotDigest,
requestedCount: binding.commentIds.length,
comments: chunks,
nextCursor,
complete,
};
}
export async function readCurrentWakeComments(
db: Db,
binding: CurrentWakeCommentsBinding,
input: unknown,
): Promise<CurrentWakeCommentsPage> {
const request = record(input);
if (
Object.keys(request).some((key) => key !== "cursor") ||
!(
request.cursor === undefined ||
request.cursor === null ||
typeof request.cursor === "string"
)
) {
throw new Error("paperclip_current_wake_comments_input_invalid");
}
const inputCursor = request.cursor ?? null;
return db.transaction(async (tx) => {
const locked = await tx
.select({
run: heartbeatRuns,
issueAssigneeAgentId: issues.assigneeAgentId,
issueExecutionRunId: issues.executionRunId,
})
.from(heartbeatRuns)
.innerJoin(
issues,
and(
eq(issues.id, binding.issueId),
eq(issues.companyId, binding.companyId),
),
)
.where(
and(
eq(heartbeatRuns.id, binding.runId),
eq(heartbeatRuns.companyId, binding.companyId),
eq(heartbeatRuns.agentId, binding.agentId),
eq(heartbeatRuns.nativeIssueId, binding.issueId),
),
)
.for("update")
.limit(1)
.then((rows) => rows[0] ?? null);
if (
!locked ||
locked.run.runtimeMode !== "native" ||
locked.run.status !== "running" ||
locked.issueAssigneeAgentId !== binding.agentId ||
locked.issueExecutionRunId !== binding.runId
) {
throw new Error("paperclip_current_wake_comments_binding_not_authorized");
}
const currentBinding = await resolveCurrentWakeCommentsBinding(
tx as unknown as Db,
{
companyId: binding.companyId,
issueId: binding.issueId,
runId: binding.runId,
agentId: binding.agentId,
},
);
if (
!currentBinding ||
currentBinding.bindingDigest !== binding.bindingDigest
) {
throw new Error("paperclip_current_wake_comments_binding_changed");
}
const snapshot = await currentWakeCommentsSnapshot(
tx as unknown as Db,
binding,
);
const resultJson = record(locked.run.resultJson);
const prior = parseReceipt(resultJson[CURRENT_WAKE_COMMENTS_RECEIPT_KEY]);
if (
prior &&
prior.bindingDigest === binding.bindingDigest &&
prior.snapshotDigest === snapshot.snapshotDigest &&
prior.inputCursor === inputCursor
) {
return prior.result;
}
if (
prior &&
(prior.bindingDigest !== binding.bindingDigest ||
prior.snapshotDigest !== snapshot.snapshotDigest)
) {
if (inputCursor !== null) {
throw new Error("paperclip_current_wake_comments_snapshot_changed");
}
} else if (!prior && inputCursor !== null) {
throw new Error("paperclip_current_wake_comments_cursor_out_of_order");
} else if (prior && prior.nextCursor !== inputCursor) {
throw new Error(
prior.complete
? "paperclip_current_wake_comments_already_complete"
: "paperclip_current_wake_comments_cursor_out_of_order",
);
}
const cursor = decodeCursor(inputCursor, binding, snapshot.snapshotDigest);
const result = buildPage(binding, snapshot, cursor);
const receipt: CurrentWakeCommentsReceipt = {
schema: CURRENT_WAKE_COMMENTS_RECEIPT_SCHEMA,
bindingDigest: binding.bindingDigest,
snapshotDigest: snapshot.snapshotDigest,
inputCursor,
nextCursor: result.nextCursor,
complete: result.complete,
result,
updatedAt: new Date().toISOString(),
};
await tx
.update(heartbeatRuns)
.set({
resultJson: {
...resultJson,
[CURRENT_WAKE_COMMENTS_RECEIPT_KEY]: receipt,
},
updatedAt: new Date(),
})
.where(
and(
eq(heartbeatRuns.id, binding.runId),
eq(heartbeatRuns.status, "running"),
),
);
return result;
});
}
/**
* Terminal fence for a truncated external-chat wake. Success is not allowed
* until every exact accepted comment was read and that completed snapshot is
* still current at this transaction's linearization point.
*/
export async function assertCurrentWakeCommentsRead(
db: Db,
input: {
companyId: string;
issueId: string;
runId: string;
agentId: string;
},
expectedBinding?: CurrentWakeCommentsBinding | null,
): Promise<void> {
await db.transaction(async (tx) => {
const locked = await tx
.select({
resultJson: heartbeatRuns.resultJson,
contextSnapshot: heartbeatRuns.contextSnapshot,
issueAssigneeAgentId: issues.assigneeAgentId,
issueAssigneeUserId: issues.assigneeUserId,
issueExecutionRunId: issues.executionRunId,
})
.from(heartbeatRuns)
.innerJoin(
issues,
and(
eq(issues.id, input.issueId),
eq(issues.companyId, input.companyId),
),
)
.where(
and(
eq(heartbeatRuns.id, input.runId),
eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.agentId, input.agentId),
eq(heartbeatRuns.nativeIssueId, input.issueId),
eq(heartbeatRuns.status, "running"),
),
)
.for("update")
.limit(1)
.then((rows) => rows[0] ?? null);
if (!locked) throw new Error("native_current_wake_comments_binding_changed");
// The run handed its own task to a person: it finishes its turn without
// task authority. Its finalizer preserves the person's task.
const handedOff = nativeRunHandedOffTask(locked.resultJson, { id: input.issueId,
assigneeAgentId: locked.issueAssigneeAgentId, assigneeUserId: locked.issueAssigneeUserId });
if (readNativeReviewAssignmentContext(locked.contextSnapshot)) {
// Accepting a review can release the child's execution lock. The exact
// resolving run may still finish; it does not regain task write access.
const review = await getNativeReviewAssignment(tx as unknown as Db, {
...input, contextSnapshot: locked.contextSnapshot, allowResolvedByRunId: input.runId,
});
if (!review || (review.interaction.status === "pending" && locked.issueExecutionRunId !== input.runId)) {
throw new Error("native_current_wake_comments_binding_changed");
}
} else if (!handedOff && (locked.issueAssigneeAgentId !== input.agentId || locked.issueExecutionRunId !== input.runId)) {
throw new Error("native_current_wake_comments_binding_changed");
}
// After its own hand-off the run no longer resolves a current binding (it
// does not own the task). It still must have read the wake it started with.
const currentBinding = handedOff ? expectedBinding ?? null : await resolveCurrentWakeCommentsBinding(
tx as unknown as Db,
{
companyId: input.companyId,
issueId: input.issueId,
runId: input.runId,
agentId: input.agentId,
},
);
if (expectedBinding !== undefined) {
const matchesExpected =
expectedBinding === null
? currentBinding === null
: currentBinding?.bindingDigest === expectedBinding.bindingDigest;
if (!matchesExpected) {
throw new Error("native_current_wake_comments_binding_changed");
}
}
const binding = currentBinding;
if (!binding) return;
const receipt = parseReceipt(
record(locked.resultJson)[CURRENT_WAKE_COMMENTS_RECEIPT_KEY],
);
if (!receipt || receipt.complete !== true || receipt.nextCursor !== null) {
throw new Error(NATIVE_CURRENT_WAKE_COMMENTS_UNREAD);
}
if (receipt.bindingDigest !== binding.bindingDigest) {
throw new Error(NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ);
}
const snapshot = await currentWakeCommentsSnapshot(
tx as unknown as Db,
binding,
);
if (receipt.snapshotDigest !== snapshot.snapshotDigest) {
throw new Error(NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ);
}
});
}