Files
PaperClipAI/server/src/services/dot-assignment-follow-up.ts
T
DottaandPaperclip 556daf9a99 feat(dot): run cloud assignments in managed sandboxes (#15691)
## Thinking Path

> - Paperclip manages agents and governs their work.
> - The Runner owns execution and task authority.
> - Dot executes at OpenAI and uses Paperclip through MCP.
> - Dot previously required a self-hosted controller.
> - Cloud must admit Dot work in a managed sandbox and keep protocol
traffic in the tenant.
> - This pull request adds managed Dot execution and dedicated pairing
routes.
> - The same Runner path can later host sandboxed workspace tools.

## Linked Issues or Issue Description

**Agent or provider**

OpenAI Dot, through the existing dedicated MCP adapter. Related changes:
#15402, #15414, and #15581.

**Why this adapter is useful**

Cloud users need to assign work to Dot and let Dot request work while
idle. Neither path should execute agent commands on the control-plane
host or require an idle sandbox.

**How the agent is invoked**

The tenant serves OAuth, signed mailbox events, and scoped tools. Normal
admission creates a managed sandbox for each assignment. A verified Rust
Runner owns that assignment and relays its tools and results.

## What Changed

- Enable the external-agent invitation in Cloud behind the standalone
Dot flag.
- Launch Dot through the existing managed process launcher and
authenticated PRP transport.
- Verify the artifact digest and Dot capability before dispatch. Read
provider checkpoints from the execution target for recovery.
- Reject local execution in Cloud and reject managed workspace access
for this release.
- Add dedicated public Dot pairing aliases. Preserve the personal
assistant consent paths.
- Keep Dot events in the tenant when a personal Cloud broker is
configured.
- Use the app-configured heartbeat for Dot-initiated work. Report
terminal admission failures and fence stranded offers before new
admission.
- Offer normal comments through the active assignment's mailbox. Keep
their wakes deferred until a successful history receipt contains the
exact IDs; preserve unread comments through finish, batching and
recovery without replaying consumed input.
- Require a sandbox process fingerprint before dispatch and allow a
bounded five-minute controller reconnect gap. Preserve remote process
birth without inspecting a colliding controller PID.
- Recognize authenticated remote shutdown receipts even without an OS
exit code.
- Document dedicated pairing aliases and teach route coverage to inspect
literal path arrays.
- Add opt-in private Daytona qualification and focused contract tests.

## Verification

- Workspace typecheck, production build, and UI token gates passed.
- Focused onboarding, OAuth, managed environment, artifact metadata, and
terminal-fence tests passed. One environment-test timeout passed on an
isolated repeat.
- A private Daytona fixture passed with the real Rust Runner, signed
readiness, assigned app tools, document writes, duplicate receipts,
authority fences, completion, and sandbox deletion.
- A real OpenAI Dot completed both an operator-assigned task and a
Dot-initiated task in managed Daytona sandboxes. Both leases were
released.
- A real accepted Dot assignment survived a controller gap longer than
one minute with the same sandbox process, Runner, session, and
assignment. Dot consumed the normal task-comment follow-up and completed
exactly once; the lease was released.
- A real cancellation fenced an accepted assignment, released its lease,
and received Dot's acknowledgement without claiming a global provider
stop.
- Final local checks: 74 Dot execution, OAuth onboarding and API
contract tests after integrating the current agent-lifecycle changes, 9
real Rust driver tests, 21 remote-runtime tests plus the 3
detached-process cleanup tests, and 17 lifecycle tests passed. The final
live Daytona fixture passed and deleted its sandbox.
- A final real Dot follow-up stayed pending until its exact comment
appeared in a successful history receipt. Dot completed the original
managed assignment once; its lease was released.
- The broad local suite crossed a rebase and was stopped; its failures
are not a qualification of this final source. Focused repeats passed.
The final head passed all CI gates: workspace types, build, general and
serialized test suites, Rust and Runner verification, all browser
shards, and the canary dry run. Greptile is 5/5 with both findings
resolved.

## Risks

- Deploy the tenant app with its current Linux Runner artifact and the
corresponding front-door protocol routes together.
- This branch has not been deployed to a Cloud tenant. A deployed tenant
still needs an OAuth and assignment smoke test.
- Dot provider usage and cost are unmetered. Revocation fences Paperclip
tools and does not prove a global OpenAI stop.
- Missing or changed recovery identity fails closed. No replacement Dot
thread is invented.
- No schema migration is added. Managed Dot workspace access remains
unavailable.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, tool use, code execution,
and browser testing. The exact runtime model identifier and
context-window size are not exposed in this session.

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-09 12:58:35 -05:00

104 lines
7.9 KiB
TypeScript

import { and, eq, gt, gte, inArray, isNull, sql } from "drizzle-orm";
import { dotAgentBindings, dotMailboxItems, dotRunnerAssignments, dotRunnerOperations, agentWakeupRequests, heartbeatRuns, issueComments, type Db } from "@paperclipai/db";
import { queuedCommentIdsFromWakePayload, withQueuedCommentIdsInWakePayload } from "./issue-queued-comment-queue.js";
/** Call inside the issue admission transaction, while its execution lock is held.
* The caller preserves explicit fresh-session and durable actor receipt policy.
*/
export async function publishActiveDotComment(db: Db, input: {
companyId: string; agentId: string; bindingId: string;
runId: string; issueId: string; commentId: string;
}): Promise<false | { assignmentId: string; consumed: boolean }> {
// Mailbox writers and cursor readers share the binding lock. An event is a
// reference to task input; it never grants tools or steers the provider.
const [binding] = await db.select().from(dotAgentBindings).where(and(
eq(dotAgentBindings.id, input.bindingId), eq(dotAgentBindings.companyId, input.companyId),
eq(dotAgentBindings.agentId, input.agentId), eq(dotAgentBindings.status, "ready"),
isNull(dotAgentBindings.revokedAt))).for("update");
if (!binding) return false;
const [assignment] = await db.select({ id: dotRunnerAssignments.id }).from(dotRunnerAssignments)
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.id, dotRunnerAssignments.runId),
eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.agentId, input.agentId),
eq(heartbeatRuns.nativeIssueId, input.issueId), eq(heartbeatRuns.status, "running"),
eq(heartbeatRuns.runtimeMode, "native"), sql`${heartbeatRuns.nativeSessionId}::text = ${dotRunnerAssignments.normalizedSessionId}`))
.innerJoin(issueComments, and(eq(issueComments.id, input.commentId),
eq(issueComments.companyId, input.companyId), eq(issueComments.issueId, input.issueId), isNull(issueComments.deletedAt),
gte(issueComments.createdAt, dotRunnerAssignments.createdAt),
sql`(${issueComments.authorAgentId} is null or ${issueComments.authorAgentId} <> ${input.agentId})`))
.where(and(eq(dotRunnerAssignments.companyId, input.companyId), eq(dotRunnerAssignments.agentId, input.agentId),
eq(dotRunnerAssignments.bindingId, binding.id), eq(dotRunnerAssignments.bindingGeneration, binding.generation),
eq(dotRunnerAssignments.runId, input.runId), eq(dotRunnerAssignments.status, "accepted"),
gt(dotRunnerAssignments.expiresAt, new Date()))).limit(1);
if (!assignment) return false;
// Same key as the event scanner: either path may discover the comment first.
await db.insert(dotMailboxItems).values({ companyId: input.companyId, bindingId: binding.id,
bindingGeneration: binding.generation, assignmentId: assignment.id, kind: "follow_up",
sourceEventId: `dot-follow-up:${assignment.id}:${input.commentId}`,
references: { assignmentId: assignment.id, commentId: input.commentId } }).onConflictDoNothing();
const [item] = await db.select({ references: dotMailboxItems.references }).from(dotMailboxItems).where(and(
eq(dotMailboxItems.bindingId, binding.id), eq(dotMailboxItems.bindingGeneration, binding.generation),
eq(dotMailboxItems.sourceEventId, `dot-follow-up:${assignment.id}:${input.commentId}`)));
return { assignmentId: assignment.id, consumed: item?.references.consumed === true };
}
/** A successful history receipt exposes exact comment IDs to Dot. Merely
* writing a mailbox item, delivering its webhook, or reading its reference
* does not consume a comment. Keep its wake deferred until this boundary.
*/
export async function consumeDotHistoryReceipt(db: Db, operation: typeof dotRunnerOperations.$inferSelect): Promise<void> {
const input = operation.command.input as Record<string, unknown> | undefined;
const result = operation.outcome?.result as { comments?: Array<{ id?: unknown }> } | undefined;
if (operation.status !== "completed" || operation.command.action !== "tool" || input?.name !== "get_task_history"
|| operation.outcome?.status !== "completed" || operation.outcome?.isError === true || !Array.isArray(result?.comments)) return;
const ids = result.comments.flatMap(comment => typeof comment?.id === "string" ? [comment.id] : []);
if (!ids.length) return;
await db.transaction(async tx => {
const [source] = await tx.select().from(dotRunnerAssignments).where(and(
eq(dotRunnerAssignments.id, operation.assignmentId), eq(dotRunnerAssignments.companyId, operation.companyId)));
if (!source) return;
const [binding] = await tx.select().from(dotAgentBindings).where(and(
eq(dotAgentBindings.id, source.bindingId), eq(dotAgentBindings.companyId, source.companyId),
eq(dotAgentBindings.agentId, source.agentId), eq(dotAgentBindings.generation, source.bindingGeneration),
eq(dotAgentBindings.status, "ready"), isNull(dotAgentBindings.revokedAt))).for("update");
if (!binding) return;
const [current] = await tx.select({ issueId: heartbeatRuns.nativeIssueId })
.from(dotRunnerAssignments).innerJoin(heartbeatRuns, and(eq(heartbeatRuns.id, dotRunnerAssignments.runId),
eq(heartbeatRuns.companyId, source.companyId), eq(heartbeatRuns.agentId, source.agentId),
eq(heartbeatRuns.status, "running"), eq(heartbeatRuns.runtimeMode, "native"),
sql`${heartbeatRuns.nativeSessionId}::text = ${dotRunnerAssignments.normalizedSessionId}`))
.where(and(eq(dotRunnerAssignments.id, source.id), eq(dotRunnerAssignments.status, "accepted"),
gt(dotRunnerAssignments.expiresAt, new Date())));
if (!current?.issueId) return; // A receipt read after completion cannot eat queued work.
const comments = await tx.select({ id: issueComments.id }).from(issueComments).where(and(
eq(issueComments.companyId, source.companyId), eq(issueComments.issueId, current.issueId),
inArray(issueComments.id, ids), isNull(issueComments.deletedAt), gte(issueComments.createdAt, source.createdAt),
sql`(${issueComments.authorAgentId} is null or ${issueComments.authorAgentId} <> ${source.agentId})`));
if (!comments.length) return;
// Materialize consumption even if the comment wake has not yet been admitted.
// Admission, event scanning and receipt reads all serialize on this binding.
for (const comment of comments) await tx.insert(dotMailboxItems).values({
companyId: source.companyId, bindingId: binding.id, bindingGeneration: binding.generation,
assignmentId: source.id, kind: "follow_up", sourceEventId: `dot-follow-up:${source.id}:${comment.id}`,
references: { assignmentId: source.id, commentId: comment.id, consumed: true },
}).onConflictDoUpdate({ target: [dotMailboxItems.bindingId, dotMailboxItems.bindingGeneration, dotMailboxItems.sourceEventId],
set: { references: sql`${dotMailboxItems.references} || '{"consumed":true}'::jsonb` } });
const now = new Date();
const wakes = await tx.select().from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.companyId, source.companyId), eq(agentWakeupRequests.agentId, source.agentId),
isNull(agentWakeupRequests.runId), eq(agentWakeupRequests.status, "deferred_issue_execution"),
sql`${agentWakeupRequests.payload}->>'dotAssignmentFollowUp' = ${source.id}`)).for("update");
const consumed = new Set(comments.map(comment => comment.id));
for (const wake of wakes) {
const queued = queuedCommentIdsFromWakePayload(wake.payload);
const remaining = queued.filter(id => !consumed.has(id));
if (!queued.length || remaining.length === queued.length) continue;
await tx.update(agentWakeupRequests).set({
...(remaining.length ? { payload: withQueuedCommentIdsInWakePayload(wake.payload, remaining) }
: { status: "coalesced", runId: source.runId, finishedAt: now }),
updatedAt: now,
}).where(and(eq(agentWakeupRequests.id, wake.id), eq(agentWakeupRequests.companyId, source.companyId),
eq(agentWakeupRequests.status, "deferred_issue_execution")));
}
});
}