mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
## 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>
104 lines
7.9 KiB
TypeScript
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")));
|
|
}
|
|
});
|
|
}
|