mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## 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>
843 lines
26 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
}
|