Files
PaperClipAI/server/src/services/connection-intent-delivery.ts
T
DottaandPaperclip b09f17fbfd fix(agents): repair queued setup through task connection cards (#20361)
## 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>
2026-11-09 17:49:34 -06:00

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 };
}};
}