mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path > - Paperclip manages AI agents doing work for people. > - Agent verification must finish before an accepted task can start. > - A missing AI account default can prevent verification before a provider starts. > - Lifecycle policy treated that owned configuration refusal as a temporary failure and retried each minute. > - The accepted turn remained Queued without explaining the prerequisite. > - Park account configuration failures and reuse the task's existing inline connection card. > - Successful repair rechecks verification and releases the original queued turn under its original authority. ## Linked Issues or Issue Description **What happened?** An OpenCode agent selected the responsible user's OpenRouter default, but that default did not exist. Verification retried unchanged configuration indefinitely and its accepted task stayed Queued. **Expected behavior:** Preserve accepted work, explain the setup prerequisite, and let the appropriate person repair it using the existing task connection UI. Completion must verify the agent before starting the same queued turn. Genuine pause, approval and access gates remain effective. **Reproduction:** Create an OpenCode Runner agent with a responsible-user OpenRouter binding and no personal default; assign a task while verification is pending. The base retries verification and shows an unexplained queue. Related: #18702 handles pending task connection recovery after account repair. This PR handles the earlier agent-verification boundary. #20330 addresses a separate Codex governed-wait timeout and is already merged. ## What Changed - Share owned account-selection failure evidence through a pure library contract. Lifecycle policy parks those configuration failures with fixed, redacted guidance; temporary infrastructure errors retain their retries. - Batch setup-state reads for unstarted queued turns. Distinguish manual repair from automatic setup retries and preserve Stop, started/history state and lifecycle holds. - Reuse the existing `connection_intent` card, account selection and completion APIs. Its controller-only `agentSetup` marker accepts only never-started legacy turns; runtime tokens still require a running run. - Address the card to the lifecycle verifier's account owner, preserving the task's execution principal and private-task visibility. Attach missing cards on admission, verification, startup and full recovery, including retained retries and continuations. Repeated delivery and publication remain bounded. A new queued turn supersedes the setup card of a stopped turn; generic connection cards retain cross-run reuse. - Recheck lifecycle verification after usable-account completion, then release the existing turn through the normal readiness path. Preserve decline, Stop, reassignment and changed setup-owner gates. Fence an in-flight verification failure from overwriting a repair; add no provider wake or recurring worker. - Park owned account refusals during resume as well as initial verification. Bind setup cards to the lifecycle operation, settle obsolete deliveries, and keep explicit Retry guidance accurate. - Reuse the router’s existing lifecycle pin for setup repair without rerouting the task, advancing the pool cursor, or replacing its saved binding. Legacy direct-account cards remain readable. - Hide the incorrect Resume banner during setup, retaining genuine manual/budget pause controls. Document the execution and retry contract. ## Verification - Red: eight original behavior assertions failed on the base, including the real selector's retry decision and the real batched queue projection. Six owned selection cases failed classification. Temporary-error presentation, different task/verifier users, retained retry/continuation recovery and the setup Resume banner also have failing-before/passing-after regressions. - Green: 430 affected backend, lifecycle, shared-contract and UI tests passed before the final review corrections. Subsequent focused runs cover one inline card, real account selection/completion, duplicate delivery/publication, verification before readiness, unchanged run authority, private tasks, no extra provider wake, decline/Stop/reassignment, company isolation, invalid runtime tokens, transient retries and redacted diagnostics. - Final setup/card and generic connection-service suites: 49 tests passed. The stopped-turn case failed before the correction, then passed with real account completion, the stopped source remaining cancelled, and the replacement source remaining queued. - After rebase, all 129 focused assertions passed. The local queue suite’s 88 execution assertions passed repeatedly, but its database-fixture teardown exceeded both 30-second and 120-second hooks. An isolated queue case completed, and the setup/connection suites fully passed. The original queue test and cleanup remain unchanged; broad CI supplies the full-suite result. - Server/UI typechecks, module boundaries, token gates and diff checks passed. `pnpm verify --explain` requested broad CI because the execution-contract document changed; no redundant full local suite was substituted. - Real source acceptance: macOS arm64, Node 26.4.0, isolated embedded PostgreSQL and local board identity. Created an OpenCode/OpenRouter agent and task through the API with no default. Admission retained one unstarted turn and produced the existing inline Connect OpenRouter card. The browser opened its credential form and closed it without entering credentials. The same card/run persisted after server restart and reload, with the setup label and no incorrect Resume. The browser also reproduced Stop followed by a new queued turn retaining the stale card on the prior head. On `12f609c98372fd1e50644028db75cfcc8deb210d`, startup superseded the stale card and retained the same replacement turn, with the original turn still cancelled and no provider execution. - The earlier CI case-search failure also reproduces on exact base `0e3fdebea8257bdff11573081c76b306f12db230` without this PR. Its repair is now in master via #20371; this branch is rebased onto it and contains no API-search changes. - Staging's actual Runtime test returned “Connect an account and choose your personal default” before provider startup. The original turn later started independently on deployed code, then hit a separate workspace-export failure. Automatic file recovery subsequently marked that run successful; its task remains Blocked and the trial’s useful output is unverified. This PR is not deployed; provider authentication through its card and downstream publication remain unverified. No account, credential, default, permission or binding was changed during diagnosis. - Previous revision review: Greptile 5/5 on `12f609c98372fd1e50644028db75cfcc8deb210d`, with all four inline findings addressed and resolved. [Full CI passed](https://github.com/paperclipai/paperclip/actions/runs/42713494508) on that revision: 59 successful checks, one neutral check and three intentional skips, with no pending or failed checks. - Latest revision `976bcae993805a8a2222395bbff9394f6791b424`: resume, router and Retry-copy regressions failed before repair. Green: 92 lifecycle/setup/shared tests, 83 setup/generic-connection/router tests, and 137 UI/shared tests. Server/UI typechecks, module boundaries and token gates passed. [Full CI passed](https://github.com/paperclipai/paperclip/actions/runs/42720731137) on this exact revision: 59 successful checks and three intentional skips, with no pending or failed checks. Greptile reviewed this head at 5/5; all six review findings are addressed and resolved. Real Stop/retry acceptance retained the same replacement turn and current-operation card through two actual server restarts, with no provider execution. ## Risks - Known account configuration failures now require repair or explicit Retry. Temporary failures keep automatic retries; accepted work and authority remain intact. - The projection adds one company-scoped batch read only for unstarted queued turns. Card attachment examines at most 100 active candidates and adds no timer, worker, schema or migration. - The optional stored setup-card marker requires an aware reader during rollback. No run/session/checkpoint identity format changes. Setup presentation grants no access and cannot bypass verification or other gates. ## Model Used - OpenAI GPT-6 (Codex), with reasoning, tool use, code editing and test execution. The exact serving 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 (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 - [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>
287 lines
19 KiB
TypeScript
287 lines
19 KiB
TypeScript
import { isAgentAwaitingSetup, requestAgentLifecycleRecheckInTransaction } from "../modules/agent-lifecycle/index.js";
|
|
import { agents, companies } from "@paperclipai/db";
|
|
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
|
import { connectionIntentService } from "./connection-intents.js";
|
|
import { connectionContinuationPendingResponse, findSatisfiedToolConnection, satisfiedConnectionIntentService } from "./satisfied-connection-intents.js";
|
|
import { isAiConnectionConfigurationFailure } from "./ai-auth-failure.js";
|
|
import { and, eq, isNull, lte, asc, inArray, notInArray, desc, sql } from "drizzle-orm";
|
|
import { connectionIntentDeliveries, issueThreadInteractions, issues, agentWakeupRequests, companyMemberships, heartbeatRuns, chatConversations, chatEndpoints, type Db } from "@paperclipai/db";
|
|
import type { heartbeatService } from "./heartbeat.js";
|
|
import { issueService } from "./issues.js";
|
|
import { issueRecoveryActionService } from "./issue-recovery-actions.js";
|
|
import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
|
|
import { connectionDeliveryWaitingForResponse, connectionIntentWaitingForResponse } from "./idle-work-proof.js";
|
|
type Heartbeat = ReturnType<typeof heartbeatService>;
|
|
|
|
export async function wakeConnectionIntentAfterResolution(
|
|
heartbeat: Pick<Heartbeat, "wakeup">,
|
|
input: {
|
|
loaded: {
|
|
issue: { id: string; assigneeAgentId: string | null; status: string };
|
|
interaction: { id: string; resolvedAt?: string | Date | null; payload?: unknown; addresseeUserId?: string | null };
|
|
};
|
|
status: string;
|
|
actorId: string;
|
|
actorType?: "user" | "system";
|
|
},
|
|
) {
|
|
const agentId = input.loaded.issue.assigneeAgentId;
|
|
if (!agentId || !["in_progress", "in_review"].includes(input.loaded.issue.status)) return;
|
|
const resolvedAt = input.loaded.interaction.resolvedAt;
|
|
const interactionResolvedAt = resolvedAt instanceof Date ? resolvedAt.toISOString() : resolvedAt;
|
|
const payload = input.loaded.interaction.payload;
|
|
// Setup repair resumes the accepted queued turn through lifecycle readiness.
|
|
// It never creates an additional provider turn through the generic wake route.
|
|
if (payload !== null && typeof payload === "object" && "agentSetup" in payload && payload.agentSetup === true) return;
|
|
const repairsProviderAuthentication = payload !== null && typeof payload === "object" && "purpose" in payload && payload.purpose === "ai";
|
|
await heartbeat.wakeup(agentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: {
|
|
issueId: input.loaded.issue.id,
|
|
interactionId: input.loaded.interaction.id,
|
|
interactionKind: "connection_intent",
|
|
interactionStatus: input.status,
|
|
mutation: "interaction",
|
|
},
|
|
idempotencyKey: `connection-intent:${input.loaded.interaction.id}:${input.status}`,
|
|
requestedByActorType: input.actorType ?? "user",
|
|
requestedByActorId: input.actorId,
|
|
contextSnapshot: {
|
|
issueId: input.loaded.issue.id,
|
|
taskId: input.loaded.issue.id,
|
|
interactionId: input.loaded.interaction.id,
|
|
interactionKind: "connection_intent",
|
|
interactionStatus: input.status,
|
|
mutation: "interaction",
|
|
wakeReason: "issue_commented",
|
|
source: "connection_intent.resolved",
|
|
...(input.actorType === "system" && input.loaded.interaction.addresseeUserId
|
|
? { responsibleUserId: input.loaded.interaction.addresseeUserId, connectionIntentResolution: "existing_connection" }
|
|
: {}),
|
|
...(interactionResolvedAt
|
|
? { interactionResolvedAt }
|
|
: {}),
|
|
// Provider authentication repair retains its existing restart fence.
|
|
// Tool access alone asks the harness to refresh tools on recovery.
|
|
...(repairsProviderAuthentication ? { forceFreshSession: true } : { refreshTools: true }),
|
|
},
|
|
issueStateGuard: {
|
|
statuses: ["in_progress", "in_review"],
|
|
assigneeAgentId: agentId,
|
|
},
|
|
});
|
|
}
|
|
|
|
|
|
export function connectionIntentDeliveryService(db: Db, heartbeat: Pick<Heartbeat, "wakeup">) {
|
|
const satisfiedIntents = satisfiedConnectionIntentService(db);
|
|
// Only a repaired AI-authentication failure may reopen a blocked task. An old
|
|
// card must never resume a newer failure, a reassignment, or a manual hold.
|
|
async function restoreAiBlockedTask(loaded: {
|
|
issue: typeof issues.$inferSelect;
|
|
interaction: typeof issueThreadInteractions.$inferSelect;
|
|
}) {
|
|
const publications: ActivityPublication[] = [];
|
|
const restored = await db.transaction(async (tx) => {
|
|
const [issue] = await tx.select().from(issues).where(and(
|
|
eq(issues.id, loaded.issue.id), eq(issues.companyId, loaded.issue.companyId),
|
|
)).for("update");
|
|
if (!issue || issue.status !== "blocked" || issue.assigneeAgentId !== loaded.issue.assigneeAgentId) return null;
|
|
const [latest] = await tx.select().from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.companyId, issue.companyId),
|
|
sql`coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->>'taskId') = ${issue.id}`,
|
|
)).orderBy(desc(heartbeatRuns.createdAt)).limit(1);
|
|
if (latest?.id !== loaded.interaction.sourceRunId || latest.status !== "failed"
|
|
|| !isAiConnectionConfigurationFailure(latest)) return null;
|
|
// Restricted external chat retries require their original chat provenance.
|
|
// Their existing Try again path owns that authorization and delivery.
|
|
const [restrictedChat] = await tx.select({ id: chatConversations.id }).from(chatConversations)
|
|
.innerJoin(chatEndpoints, eq(chatEndpoints.id, chatConversations.endpointId))
|
|
.where(and(eq(chatConversations.companyId, issue.companyId), eq(chatConversations.issueId, issue.id),
|
|
eq(chatEndpoints.externalExecutionPolicy, "restricted"))).limit(1);
|
|
if (restrictedChat) return null;
|
|
const recoveries = issueRecoveryActionService(db);
|
|
const recovery = await recoveries.getActiveForIssue(issue.companyId, issue.id, tx);
|
|
if (!recovery || recovery.cause !== "configuration_incomplete" || recovery.evidence.latestRunId !== latest.id) return null;
|
|
const actorId = loaded.interaction.resolvedByUserId ?? loaded.interaction.addresseeUserId!;
|
|
const updated = await issueService(db).update(issue.id, {
|
|
status: "in_progress", actorUserId: actorId, companyGuard: issue.companyId,
|
|
}, tx, publications);
|
|
await recoveries.resolveActiveForIssue({ companyId: issue.companyId, sourceIssueId: issue.id,
|
|
actionId: recovery.id, status: "resolved", outcome: "restored", resolutionNote: "AI connection restored by the responsible user." }, tx);
|
|
return updated;
|
|
});
|
|
for (const publication of publications) publishActivity(publication);
|
|
if (restored) await logActivity(db, { companyId: restored.companyId, actorType: "user",
|
|
actorId: loaded.interaction.resolvedByUserId ?? loaded.interaction.addresseeUserId!,
|
|
action: "issue.updated", entityType: "issue", entityId: restored.id,
|
|
details: { status: restored.status, _previous: { status: "blocked" }, source: "ai_connection_restored", interactionId: loaded.interaction.id } });
|
|
return restored;
|
|
}
|
|
|
|
async function deliver(interactionId: string) {
|
|
// Deterministic acceptance-test failpoint: preserve committed outcomes across a server restart.
|
|
if (process.env.NODE_ENV === "test" && process.env.PAPERCLIP_TEST_CONNECTION_DELIVERY_HOLD === "1") return;
|
|
const now = new Date();
|
|
// The retry deadline is also the worker lease. A crashed worker is reclaimed.
|
|
const [claimed] = await db.update(connectionIntentDeliveries)
|
|
.set({ nextAttemptAt: new Date(now.getTime() + 60_000) })
|
|
.where(and(eq(connectionIntentDeliveries.interactionId, interactionId), isNull(connectionIntentDeliveries.deliveredAt), lte(connectionIntentDeliveries.nextAttemptAt, now))).returning();
|
|
if (!claimed) return;
|
|
const [loaded] = await db.select({ interaction: issueThreadInteractions, issue: issues })
|
|
.from(issueThreadInteractions).innerJoin(issues, eq(issues.id, issueThreadInteractions.issueId))
|
|
.where(and(eq(issueThreadInteractions.id, interactionId), eq(issueThreadInteractions.companyId, claimed.companyId), eq(issues.companyId, claimed.companyId)));
|
|
let interaction = loaded?.interaction;
|
|
const retiredForAvailableConnection = interaction?.status === "expired"
|
|
&& interaction.result?.outcome === "expired" && "connectionId" in interaction.result
|
|
&& typeof interaction.result.connectionId === "string";
|
|
const payload = interaction?.payload as { requestingAgentId?: string; serviceSlug?: string; purpose?: "ai"; agentSetup?: true; agentSetupOperationId?: string } | undefined;
|
|
if (!loaded || !interaction || (!retiredForAvailableConnection && !["accepted", "rejected"].includes(interaction.status))
|
|
|| ["done", "cancelled"].includes(loaded.issue.status) || loaded.issue.assigneeAgentId !== payload?.requestingAgentId) {
|
|
await db.update(connectionIntentDeliveries).set({ deliveredAt: new Date() }).where(and(eq(connectionIntentDeliveries.interactionId, interactionId),
|
|
eq(connectionIntentDeliveries.companyId, claimed.companyId),
|
|
eq(connectionIntentDeliveries.nextAttemptAt, claimed.nextAttemptAt), isNull(connectionIntentDeliveries.deliveredAt)));
|
|
return;
|
|
}
|
|
const userId = interaction.addresseeUserId;
|
|
if (userId !== "local-board") {
|
|
const [membership] = await db.select().from(companyMemberships).where(and(
|
|
eq(companyMemberships.companyId, claimed.companyId), eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, userId ?? ""), eq(companyMemberships.status, "active"),
|
|
)).limit(1);
|
|
if (!membership?.membershipRole || membership.membershipRole === "viewer") {
|
|
await db.update(connectionIntentDeliveries).set({ deliveredAt: new Date() }).where(and(eq(connectionIntentDeliveries.interactionId, interactionId),
|
|
eq(connectionIntentDeliveries.companyId, claimed.companyId),
|
|
eq(connectionIntentDeliveries.nextAttemptAt, claimed.nextAttemptAt), isNull(connectionIntentDeliveries.deliveredAt)));
|
|
return;
|
|
}
|
|
}
|
|
if (interaction.status === "accepted" && payload?.agentSetup !== true) {
|
|
const ready = await connectionIntentService(db).usableConnectionForAgent({ companyId: claimed.companyId,
|
|
agentId: payload!.requestingAgentId!, responsibleUserId: userId!, serviceSlug: payload!.serviceSlug!, purpose: payload!.purpose, sourceRunId: interaction.sourceRunId,
|
|
agentSetup: payload!.agentSetup, agentSetupOperationId: payload!.agentSetupOperationId });
|
|
if (!ready) return;
|
|
}
|
|
if (payload?.agentSetup === true && payload.purpose === "ai") {
|
|
await db.transaction(async tx => {
|
|
const [task] = await tx.select().from(issues).where(and(eq(issues.id, loaded.issue.id), eq(issues.companyId, claimed.companyId))).for("update");
|
|
const [source] = await tx.select().from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.id, interaction!.sourceRunId!), eq(heartbeatRuns.companyId, claimed.companyId),
|
|
eq(heartbeatRuns.agentId, payload.requestingAgentId!),
|
|
)).for("no key update");
|
|
const [agent] = await tx.select().from(agents).where(and(eq(agents.id, payload.requestingAgentId!), eq(agents.companyId, claimed.companyId))).for("update");
|
|
const [company] = await tx.select({ defaultResponsibleUserId: companies.defaultResponsibleUserId })
|
|
.from(companies).where(eq(companies.id, claimed.companyId));
|
|
const verificationUserId = agent?.lifecycleOperation?.responsibleUserId ?? company?.defaultResponsibleUserId;
|
|
if (interaction!.status === "accepted" && task && !["done", "cancelled"].includes(task.status)
|
|
&& task.assigneeAgentId === payload.requestingAgentId && source?.status === "queued" && !source.startedAt
|
|
&& source.runtimeMode === "legacy" && agent && isAgentAwaitingSetup(agent)
|
|
&& verificationUserId === interaction!.addresseeUserId
|
|
&& (!payload.agentSetupOperationId || agent.lifecycleOperation?.id === payload.agentSetupOperationId)) {
|
|
const ready = await connectionIntentService(db).usableConnectionForAgent({ companyId: claimed.companyId,
|
|
agentId: payload.requestingAgentId!, responsibleUserId: userId!, serviceSlug: payload.serviceSlug!, purpose: "ai",
|
|
sourceRunId: interaction!.sourceRunId, agentSetup: true, agentSetupOperationId: payload.agentSetupOperationId });
|
|
if (!ready) return;
|
|
await requestAgentLifecycleRecheckInTransaction(tx, { companyId: claimed.companyId, agentId: agent.id,
|
|
configurationRepaired: true }, () => notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle));
|
|
}
|
|
// Decline, Stop and stale ownership also settle this delivery without a wake.
|
|
await tx.update(connectionIntentDeliveries).set({ deliveredAt: new Date() }).where(and(
|
|
eq(connectionIntentDeliveries.interactionId, interactionId), eq(connectionIntentDeliveries.companyId, claimed.companyId),
|
|
eq(connectionIntentDeliveries.nextAttemptAt, claimed.nextAttemptAt), isNull(connectionIntentDeliveries.deliveredAt)));
|
|
});
|
|
return;
|
|
}
|
|
if (retiredForAvailableConnection) {
|
|
const publications: ActivityPublication[] = [];
|
|
const current = await db.transaction(async tx => {
|
|
const [task] = await tx.select().from(issues).where(and(eq(issues.id, loaded.issue.id), eq(issues.companyId, claimed.companyId))).for("update");
|
|
const [card] = await tx.select().from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.id, interactionId), eq(issueThreadInteractions.companyId, claimed.companyId),
|
|
eq(issueThreadInteractions.issueId, loaded.issue.id))).for("update");
|
|
if (!task || !card || card.status !== "expired" || card.result?.outcome !== "expired") return null;
|
|
const ready = await findSatisfiedToolConnection(tx as unknown as Db, task, { ...card, status: "pending" });
|
|
if (!ready) return null;
|
|
if (ready.id !== (card.result as { connectionId: string }).connectionId) {
|
|
const previousConnectionId = (card.result as { connectionId: string }).connectionId;
|
|
// This is a system observation of existing access. It grants nothing
|
|
// and never changes or impersonates a human decision.
|
|
const [updated] = await tx.update(issueThreadInteractions).set({ result: { ...card.result,
|
|
connectionId: ready.id }, updatedAt: new Date() }).where(eq(issueThreadInteractions.id, card.id)).returning();
|
|
Object.assign(card, updated);
|
|
await logActivity(tx as unknown as Db, { companyId: claimed.companyId, actorType: "system", actorId: "connection-reconciliation",
|
|
action: "issue.thread_interaction_resolved", entityType: "issue", entityId: task.id,
|
|
details: { interactionId, interactionKind: "connection_intent", status: "expired", connectionId: ready.id,
|
|
previousConnectionId, resolutionSource: "existing_connection_refreshed" } }, publications);
|
|
}
|
|
return { task, card, waiting: await connectionContinuationPendingResponse(tx as unknown as Db, task, card.addresseeUserId, card.sourceRunId) };
|
|
});
|
|
for (const publication of publications) publishActivity(publication);
|
|
if (!current || current.waiting) return;
|
|
loaded.issue = current.task;
|
|
loaded.interaction = current.card;
|
|
interaction = current.card;
|
|
}
|
|
if (interaction.status === "accepted" && payload?.purpose === "ai" && loaded.issue.status === "blocked") {
|
|
const restored = await restoreAiBlockedTask(loaded);
|
|
if (!restored) return;
|
|
loaded.issue = restored;
|
|
}
|
|
if (!["in_progress", "in_review"].includes(loaded.issue.status)) return;
|
|
const durableWake = () => db.select({ id: agentWakeupRequests.id }).from(agentWakeupRequests).where(and(
|
|
eq(agentWakeupRequests.companyId, claimed.companyId),
|
|
eq(agentWakeupRequests.idempotencyKey, `connection-intent:${interactionId}:${interaction.status}`),
|
|
notInArray(agentWakeupRequests.status, ["skipped", "failed", "cancelled"]),
|
|
)).limit(1);
|
|
// Check before dispatch: the previous worker may have crashed after enqueueing.
|
|
if (!(await durableWake()).length) {
|
|
try {
|
|
await wakeConnectionIntentAfterResolution(heartbeat, { loaded, status: interaction.status,
|
|
actorId: retiredForAvailableConnection ? "connection-reconciliation" : interaction.resolvedByUserId ?? interaction.addresseeUserId!,
|
|
...(retiredForAvailableConnection ? { actorType: "system" as const } : {}),
|
|
});
|
|
} catch (error) {
|
|
// The unique wake key also protects overlapping leases. Other failures retry.
|
|
if (!(await durableWake()).length) throw error;
|
|
}
|
|
}
|
|
if ((await durableWake()).length) {
|
|
await db.update(connectionIntentDeliveries).set({ deliveredAt: new Date() }).where(and(eq(connectionIntentDeliveries.interactionId, interactionId),
|
|
eq(connectionIntentDeliveries.companyId, claimed.companyId),
|
|
eq(connectionIntentDeliveries.nextAttemptAt, claimed.nextAttemptAt), isNull(connectionIntentDeliveries.deliveredAt)));
|
|
}
|
|
}
|
|
let maintenanceAt: number | null = null;
|
|
async function hasPending() {
|
|
const [state] = await db.execute<{ active: boolean; present: boolean }>(sql`
|
|
WITH pending_deliveries AS (
|
|
SELECT (${sql.raw(connectionDeliveryWaitingForResponse('d'))}) IS TRUE AS passive
|
|
FROM connection_intent_deliveries d WHERE d.delivered_at IS NULL
|
|
), pending_intents AS (
|
|
SELECT (${sql.raw(connectionIntentWaitingForResponse('card', 'task'))}) IS TRUE AS passive
|
|
FROM issue_thread_interactions card LEFT JOIN issues task ON task.id = card.issue_id AND task.company_id = card.company_id
|
|
WHERE card.kind = 'connection_intent' AND card.status = 'pending'
|
|
AND (task.id IS NULL OR (task.status IN ('in_progress', 'in_review') AND task.assignee_user_id IS NULL))
|
|
) SELECT EXISTS (SELECT 1 FROM pending_deliveries WHERE NOT passive
|
|
UNION ALL SELECT 1 FROM pending_intents WHERE NOT passive) AS active,
|
|
EXISTS (SELECT 1 FROM pending_deliveries UNION ALL SELECT 1 FROM pending_intents) AS present`);
|
|
if (!state || typeof state.active !== "boolean" || typeof state.present !== "boolean") throw new Error("Connection work state is unknown");
|
|
// Passive records need no admitted-work hold or wake alarm. While the
|
|
// process is already awake, keep the existing reconciliation cadence so
|
|
// new access or responses resume work even without a queue notification.
|
|
maintenanceAt = !state.active && state.present ? Date.now() + 5_000 : null;
|
|
return state.active;
|
|
}
|
|
return { deliver, hasPending, nextRunAt: () => maintenanceAt, tryDeliver: async (id: string) => { try { await deliver(id); } catch { /* Persisted delivery remains due after its lease. */ } }, sweepPending: async () => {
|
|
await satisfiedIntents.sweepPending();
|
|
const rows = await db.select().from(connectionIntentDeliveries).where(and(isNull(connectionIntentDeliveries.deliveredAt), lte(connectionIntentDeliveries.nextAttemptAt, new Date()),
|
|
sql`(${sql.raw(connectionDeliveryWaitingForResponse('connection_intent_deliveries'))}) IS NOT TRUE`))
|
|
.orderBy(asc(connectionIntentDeliveries.nextAttemptAt)).limit(50);
|
|
let failed = 0;
|
|
for (const row of rows) { try { await deliver(row.interactionId); } catch { failed += 1; } }
|
|
return { scanned: rows.length, failed };
|
|
}};
|
|
}
|