mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
fix(dot): preserve completion and admission receipts
Normalize accepted completion reports across the control plane and Rust bridge, serialize Dot assignments, replay work admission receipts, and continue subscription polling. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ad1ff9b329
commit
78f4c4f5e6
8 files changed
+227
-30
No files matched your search
@@ -197,17 +197,42 @@ function createBroker(db: Db) {
|
||||
const b = await principalBinding(principal);
|
||||
if (!z.uuid().safeParse(requestId).success) throw fail("Use a stable UUID requestId.");
|
||||
if (!enabled() || !(await this.bindingForAgent(b.companyId, b.agentId))?.subscriptionVerified) throw fail("Dot admission is unavailable.");
|
||||
const key = `dot-work:${b.id}:${b.generation}:${requestId}`;
|
||||
const receipt = async () => (await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, b.companyId), eq(agentWakeupRequests.agentId, b.agentId), eq(agentWakeupRequests.idempotencyKey, key))).limit(1))[0];
|
||||
const replay = (row: typeof agentWakeupRequests.$inferSelect) => {
|
||||
if (row.payload?.issueId !== issueId) throw fail("requestId was reused for another task.");
|
||||
return { status: "requested", runId: row.runId, message: "Normal admission determines when this task can run. Read the mailbox after its event." };
|
||||
};
|
||||
// A retry is a receipt read, including after the task yielded or finished.
|
||||
const old = await receipt();
|
||||
if (old) return replay(old);
|
||||
const [issue] = await db.select().from(issues).where(and(eq(issues.companyId, b.companyId), eq(issues.id, issueId), eq(issues.assigneeAgentId, b.agentId)));
|
||||
if (!issue || !["todo", "in_progress"].includes(issue.status)) throw fail("Request work only for an eligible task assigned to this agent.");
|
||||
const key = `dot-work:${b.id}:${b.generation}:${requestId}`;
|
||||
const [old] = await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, b.companyId), eq(agentWakeupRequests.agentId, b.agentId), eq(agentWakeupRequests.idempotencyKey, key)));
|
||||
if (old && old.payload?.issueId !== issueId) throw fail("requestId was reused for another task.");
|
||||
// The receipt PK reserves this request in the same transaction as its run.
|
||||
// Different issue locks cannot admit the same request concurrently.
|
||||
const hex = hash(key);
|
||||
const receiptId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-8${hex.slice(13, 16)}-${((parseInt(hex[16]!, 16) & 3) | 8).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
||||
const { heartbeatService } = await import("./heartbeat.js");
|
||||
const run = await heartbeatService(db).wakeup(b.agentId, { source: "assignment", triggerDetail: "system", reason: "issue_assigned",
|
||||
payload: { issueId, dotRequestId: requestId }, contextSnapshot: { issueId }, idempotencyKey: key, requestedByActorType: "agent", requestedByActorId: b.agentId });
|
||||
const [reserved] = await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, b.companyId), eq(agentWakeupRequests.agentId, b.agentId), eq(agentWakeupRequests.idempotencyKey, key)));
|
||||
if (reserved && reserved.payload?.issueId !== issueId) throw fail("requestId was reused for another task.");
|
||||
return { status: "requested", runId: run?.id ?? null, message: "Normal admission determines when this task can run. Read the mailbox after its event." };
|
||||
try {
|
||||
const run = await heartbeatService(db).wakeup(b.agentId, { source: "assignment", triggerDetail: "system", reason: "issue_assigned",
|
||||
payload: { issueId, dotRequestId: requestId }, contextSnapshot: { issueId }, idempotencyKey: key, requestedByActorType: "agent", requestedByActorId: b.agentId,
|
||||
allowRunCoalescing: false, durableDotRequest: { id: receiptId, companyId: b.companyId, agentId: b.agentId, issueId, requestId, idempotencyKey: key, requestedAt: new Date() } });
|
||||
const reserved = await receipt();
|
||||
return reserved ? replay(reserved) : { status: "requested", runId: run?.id ?? null, message: "Normal admission determines when this task can run. Read the mailbox after its event." };
|
||||
} catch (error) {
|
||||
// A concurrent reservation can lose the PK race on a different task.
|
||||
// Replay only that durable receipt; do not mask unrelated failures.
|
||||
let cause: unknown = error;
|
||||
for (let depth = 0; depth < 5 && cause && typeof cause === "object"; depth++) {
|
||||
if ((cause as { code?: string }).code === "23505") {
|
||||
const reserved = await receipt();
|
||||
if (reserved?.id === receiptId) return replay(reserved);
|
||||
break;
|
||||
}
|
||||
cause = (cause as { cause?: unknown }).cause;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async operationStatus(principal: McpPrincipal, assignmentId: string, requestId: string) {
|
||||
await authorizeAssignment(principal, assignmentId, true);
|
||||
|
||||
@@ -3779,6 +3779,16 @@ interface WakeupOptions {
|
||||
/** Exact failed run selected by an authenticated board Retry request. */
|
||||
failedRunId?: string | null;
|
||||
durableChatRequest?: DurableChatWakeupRequest;
|
||||
/** Server-owned Dot admission receipt; never accepted from API payloads. */
|
||||
durableDotRequest?: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
issueId: string;
|
||||
requestId: string;
|
||||
idempotencyKey: string;
|
||||
requestedAt: Date;
|
||||
};
|
||||
source?: "timer" | "assignment" | "on_demand" | "automation";
|
||||
triggerDetail?: "manual" | "ping" | "callback" | "system";
|
||||
reason?: string | null;
|
||||
@@ -16861,9 +16871,11 @@ export function heartbeatService(
|
||||
enabled: asBoolean(heartbeat.enabled, false),
|
||||
intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)),
|
||||
wakeOnDemand: isHeartbeatWakeOnDemandEnabled(agent),
|
||||
maxConcurrentRuns: normalizeMaxConcurrentRuns(
|
||||
heartbeat.maxConcurrentRuns,
|
||||
),
|
||||
// A Dot binding has one external turn. Competing assignments must retain
|
||||
// their queue position instead of claiming a second run that cannot bind.
|
||||
maxConcurrentRuns: agent.adapterType === "paperclip_runner" &&
|
||||
parseObject(agent.adapterConfig).provider === "openai_dot"
|
||||
? 1 : normalizeMaxConcurrentRuns(heartbeat.maxConcurrentRuns),
|
||||
skipTimerWhenNoActionableWork: asBoolean(
|
||||
heartbeat.skipTimerWhenNoActionableWork ??
|
||||
heartbeat.requireActionableTimerWork ??
|
||||
@@ -27176,18 +27188,35 @@ export function heartbeatService(
|
||||
if (durableRequest?.failedRunRetry) {
|
||||
opts = { ...opts, allowRunCoalescing: false };
|
||||
}
|
||||
const durableReceiptFields = durableRequest
|
||||
? { id: durableRequest.id, requestedAt: durableRequest.requestedAt }
|
||||
const dotRequest = opts.durableDotRequest;
|
||||
if (dotRequest && (durableRequest || dotRequest.agentId !== agentId ||
|
||||
dotRequest.companyId !== agent.companyId || dotRequest.issueId !== issueId ||
|
||||
dotRequest.requestId !== payload?.dotRequestId || source !== "assignment" ||
|
||||
opts.requestedByActorType !== "agent" || opts.requestedByActorId !== agentId ||
|
||||
agent.adapterType !== "paperclip_runner" || parseObject(agent.adapterConfig).provider !== "openai_dot" ||
|
||||
opts.idempotencyKey !== dotRequest.idempotencyKey)) {
|
||||
throw conflict("Dot work request does not match its admission authority.");
|
||||
}
|
||||
const receiptRequest = durableRequest ?? dotRequest;
|
||||
const durableReceiptFields = receiptRequest
|
||||
? { id: receiptRequest.id, requestedAt: receiptRequest.requestedAt }
|
||||
: {};
|
||||
const existingDurableReceipt = async (queryDb: Db) => {
|
||||
if (!durableRequest) return null;
|
||||
if (!receiptRequest) return null;
|
||||
const receipt = await queryDb
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, durableRequest.id))
|
||||
.where(eq(agentWakeupRequests.id, receiptRequest.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (receipt) assertDurableChatWakeupReceipt(durableRequest, receipt);
|
||||
if (receipt && durableRequest) assertDurableChatWakeupReceipt(durableRequest, receipt);
|
||||
if (receipt && dotRequest && (receipt.companyId !== dotRequest.companyId ||
|
||||
receipt.agentId !== dotRequest.agentId || receipt.source !== "assignment" ||
|
||||
receipt.requestedByActorType !== "agent" || receipt.requestedByActorId !== dotRequest.agentId ||
|
||||
receipt.idempotencyKey !== dotRequest.idempotencyKey || receipt.payload?.issueId !== dotRequest.issueId ||
|
||||
receipt.payload?.dotRequestId !== dotRequest.requestId)) {
|
||||
throw conflict("requestId was reused for another task.");
|
||||
}
|
||||
return receipt;
|
||||
};
|
||||
const priorReceipt = await existingDurableReceipt(db);
|
||||
@@ -27216,7 +27245,7 @@ export function heartbeatService(
|
||||
// retain distinct durable receipts even when the same gate blocks them.
|
||||
const coalesceExecutionWait =
|
||||
opts.requestedByActorType === "system" &&
|
||||
!durableRequest &&
|
||||
!receiptRequest &&
|
||||
!wakeCommentId &&
|
||||
queuedCommentIdsFromRunContext(enrichedContextSnapshot).length === 0 &&
|
||||
!isInteractionResolutionWakePayload(payload ?? {}) &&
|
||||
@@ -28603,10 +28632,10 @@ export function heartbeatService(
|
||||
wakeupRequestId: activeExecutionRun.wakeupRequestId,
|
||||
},
|
||||
allowRunCoalescing: isConversation(issue) ? false : opts.allowRunCoalescing,
|
||||
durableReceipt: durableRequest
|
||||
durableReceipt: receiptRequest
|
||||
? {
|
||||
id: durableRequest.id,
|
||||
requestedAt: durableRequest.requestedAt,
|
||||
id: receiptRequest.id,
|
||||
requestedAt: receiptRequest.requestedAt,
|
||||
}
|
||||
: undefined,
|
||||
reason,
|
||||
|
||||
Reference in new issue
Block a user