mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## Thinking Path > - Paperclip manages agents, task continuations, integrations, and their execution resources. > - Automatic idle sleep needs durable evidence that accepted work and provider cleanup are settled. > - Response-driven questions, passive board escalations, and completed connection receipts currently look like unfinished work. > - Legacy ephemeral leases and terminal image sessions need actual deletion proof before their history can become idle. > - This PR adds narrow evidence-based exceptions and read-only provider verification while preserving queues, outcomes, and ownership fences. > - Idle instances can sleep without losing responses, automatic reconciliation, or cleanup obligations. ## Linked Issues or Issue Description **What happened?** Instances with no executable work can remain awake because pending human responses, passive recovery records, outbound integration configuration, or completed delivery history are counted as background work. Older failed ephemeral leases and finished setup sessions also lack the evidence needed to distinguish retained resources from deleted allocations. **Expected behavior** Permit idle sleep only for proved response-driven waits and settled history. Continue blocking real queued work, inbound integrations without a wake contract, unfinished accounting/retries, retained resources, and uncertain provider cleanup. **Steps to reproduce** Finish an issue's producing run, leave a pending question or confirmation, and request an owned scheduled idle report. Separately retain a processed connection receipt or a failed ephemeral lease whose provider allocation has already been deleted. The old checks keep those instances awake even after executable work has finished. Related public groundwork: #15522, #15599, #15761, #15897, #15907, and #16325. Searched existing public idle-sleep PRs; this addresses additional blockers in the current predicates. ## What Changed - Recognize supported response-driven waits and passive board recoveries without changing task state, response audiences, or continuation policies. Bind passive issue generation into routine-only alarms too. Keep automatically polled merge-confirmation cards awake. - Treat processed events, delivered intents, disabled drafts, and outbound MCP configuration as history/configuration where proved safe. Preserve pending deliveries, downstream wake requests, and inbound GitHub bindings. - Permit only pristine Slack setup drafts with a matching disabled channel connection, no provisioning registration, and no prior provider identity/activity. A saved slash-command name remains inert configuration. - Repair missed routine execution-issue status hooks in bounded awake-only maintenance. Lock and re-read exact owners, apply existing completion/transient-failure rules, and preserve normal continuation after an issue resumes. - Allow human-only connection setup to wait only when no reusable active connection of its purpose exists; available access reblocks sleep for reconciliation. The delivery worker shares this proof, releases its local idle hold, and retains maintenance only while already awake. Passive-only ticks pause before admission during an owned drain, allowing slow backups to finish; new producer notifications still reconcile and invalidate the backup. - Add optional `environmentInspectLease` SDK discovery/dispatch and read-only Daytona/fake-provider implementations. Old workers remain conservative. Daytona status inspection uses a fresh provider GET without reconnecting or poisoning unused-worker eligibility. - Bind historical credential reads to an unrotated initial secret version and its immutable resolved-value hash; reject ambiguous histories and ambient Daytona credential/API-endpoint fallbacks before querying provider state. - Verify bounded rotating batches of historical resources on already-awake Cloud instances, outside readiness. Store host-owned deletion evidence bound to exact row identity/version and reject concurrent ownership changes. Preserve failure outcomes, cleanup/export intents, and run continuation authority. - Preserve cancellation provenance, defer contended routine rows individually, and cap actual inspection RPCs across caller timeouts and worker exits. Add database, SDK, provider, configuration-scope, and race regression coverage and update idle protocol documentation. ## Verification - `pnpm -r typecheck` and `pnpm build` passed on `24fd1c0f5ec`; server typecheck/build passed again after the final coordinator-only fix in `60f23e6e4b6`. - Final server regressions: 258 sleep-safety/connection cases and all 17 coordinator cases passed. Earlier focused checks also covered 331 combined idle/cleanup cases, 34 custom-image cases, 39 credential/cleanup cases, 39 connection-history cases, and 80 existing routine cases. Counts overlap and are not additive. - SDK: 153 passed. Daytona: 406 passed with 14 existing opt-in skips. The provider implementation also typechecks against the locked SDK used by the Cloud wrapper. - Dependency boundaries were exercised with real child workers holding provider inspection replies beyond caller timeouts, a real PostgreSQL row lock delaying one routine owner, and real connection creation → queue notification → worker hold release → resumption after access is enabled without another notification. A controlled six-second backup goes through the real coordinator, task drain, work signals, and idle-safety generation checks; maintenance permits completion, while a new committed transaction invalidates it. - Full local `pnpm test:run` completed its general-server phase with 21,122 passing, 29 failing, and 109 skipped tests; later phases did not run. That long run spanned source edits and is not a final-revision pass. The affected Slack draft assertion passes on current source. Restoring `/usr/sbin` for `lsof` produced 233 passing tests and two Git-fixture failures across the other five files (3 existing skips). Both remaining assertions pass with command-scoped `init.defaultBranch=master`; this host defaults to main and leaves both branches in their fixture. No source/global Git setting changed for that comparison. Original failure logs are retained. - A parallel typecheck/build attempt raced generated Runner outputs and failed an import. Running the build sequentially passed with no source change. - Local database checks use the maintained test-constructor seam with native PostgreSQL 16.14 because bundled PostgreSQL 18 is unreliable on this host. No database assertions were skipped in the focused runs. An interrupted combined run was superseded by the separate passing coordinator and safety/connection runs; no owned test database processes remain running. - Apex reviewed final head `60f23e6e4b615a08591e19d86443a6c35c091ce1`: **5/5**, zero unresolved threads. All current-head CI gates are green, with expected policy skips. The branch is conflict-free and GitHub reports a clean merge state. - Current-head CI initially failed `plan-task-fixture.spec.ts:66` during fixture deletion: the final company DELETE hit `activity_log_company_id_companies_id_fk` after the feature assertions completed. The deletion path is unchanged here; the final diff only changes drain-gated passive maintenance. The preceding revision passed this shard. Failure evidence is retained in [the original CI job](https://github.com/paperclipai/paperclip/actions/runs/39006936642/job/117321244396); the targeted retry passed on the unchanged commit ([retry job](https://github.com/paperclipai/paperclip/actions/runs/39006936642/job/117322535013)). This shows recovery, not that the underlying company-deletion race is resolved. - These checks do not claim staging/production deployment or live provider cleanup proof. Provider tests simulate responses. Full Cloud staging verification requires a compatible Core/private-plugin composition after the Core revision is on master. ## Risks Sleep eligibility changes require the existing owned drain, final generation validation, local-work checks, and plugin drain. Unsupported states fail closed. Queued agent input and routine execution remain blocked. Outbound MCP exemptions exclude inbound GitHub grants; other unsupported inbound integrations remain awake. The inspection RPC is optional and requires a matching live worker method. Its actual outstanding calls are capped at two across workers even after the caller times out. Historical lease inspection requires its recorded plugin/config; setup inspection requires an unchanged recorded config fingerprint. Not-found under replacement account credentials is not historical deletion proof: ambiguous/rotated secret histories defer verification, and initial-version reads must match their recorded value hashes. Missing provenance (including legacy image sessions without a recorded config fingerprint), live/stopped resources, provider authorization failures, and concurrent changes defer verification. Routine repair replays existing completion/transient-failure behavior and timestamps at reconciliation time; it does not execute or discard deferred comments. No migration or destructive provider operation is added. The new marker does not grant run-resume authority. Full Cloud staging validation requires a compatible Core/private-plugin composition; no production deployment is included. ## Model Used OpenAI GPT-6 Codex, with repository/tool access and code execution. Exact model identifier and context-window size are not exposed by 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 relevant tests locally; passing focused checks and full-run limitations are documented above - [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>
250 lines
16 KiB
TypeScript
250 lines
16 KiB
TypeScript
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;
|
|
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" } | 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") {
|
|
const ready = await connectionIntentService(db).usableConnectionForAgent({ companyId: claimed.companyId,
|
|
agentId: payload!.requestingAgentId!, responsibleUserId: userId!, serviceSlug: payload!.serviceSlug!, purpose: payload!.purpose });
|
|
if (!ready) 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 };
|
|
}};
|
|
}
|