fix(ai): preserve compatible sessions when adopting pools

Match complete prior fingerprints across binding-only revisions and unchanged legacy identities. Retain credential epoch, effective settings, and explicit reset boundaries.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-02 17:06:45 -05:00
1 parent ee9a0e2b12
commit d134e3902a
4 files changed
+210 -10

No files matched your search

@@ -0,0 +1,90 @@
import { isDeepStrictEqual } from "node:util";
import type { AiConnectionBinding } from "@paperclipai/shared";
type ConfigRevision = {
id: string;
changedKeys: string[];
createdAt: Date;
beforeConfig: Record<string, unknown>;
afterConfig: Record<string, unknown>;
};
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : {};
}
export function managedAiSessionIdentityCompatible(
stored: unknown,
sessionIdentity: string,
credentialIdentity: string,
): boolean {
// Legacy token identities can migrate only before any manual replacement.
// Replacing even the same credential bytes increments the epoch.
return stored === sessionIdentity || (sessionIdentity.endsWith(":0") && stored === credentialIdentity);
}
export function aiConnectionOnlyConfigRevision(revision: ConfigRevision): boolean {
const withoutBinding = (snapshot: Record<string, unknown>) => {
const runtimeConfig = { ...record(snapshot.runtimeConfig) };
delete runtimeConfig.aiConnection;
return { ...snapshot, runtimeConfig };
};
return !isDeepStrictEqual(record(revision.beforeConfig.runtimeConfig).aiConnection, record(revision.afterConfig.runtimeConfig).aiConnection)
&& isDeepStrictEqual(withoutBinding(revision.beforeConfig), withoutBinding(revision.afterConfig));
}
export function aiConnectionRevisionMetadata(revision: ConfigRevision | undefined) {
return revision ? { id: revision.id, changedKeys: revision.changedKeys, configRevisionAt: revision.createdAt.toISOString() } : null;
}
/** Exact alternative fingerprints, never a category-level exemption. The caller
* has already authorized the selected account and prepared its credential. */
export function aiConnectionSessionCompatibilityInputs(input: {
effectiveAdapterConfig: Record<string, unknown>;
agentRuntimeConfig: unknown;
agentConfigRevision: unknown;
issueOverrides: unknown;
originalIssueOverrides: unknown;
binding: AiConnectionBinding;
router: boolean;
storedIdentity: unknown;
sessionIdentity: string;
credentialIdentity: string;
revisions: readonly ConfigRevision[];
}) {
if (!managedAiSessionIdentityCompatible(input.storedIdentity, input.sessionIdentity, input.credentialIdentity)) return [];
const configs = [input.effectiveAdapterConfig];
if (input.storedIdentity === input.credentialIdentity && input.sessionIdentity.endsWith(":0")) {
const managed = { ...record(input.effectiveAdapterConfig.managedAiConnection) };
delete managed.sessionIdentity;
managed.identity = input.credentialIdentity;
configs.push({ ...input.effectiveAdapterConfig, managedAiConnection: managed });
}
const variants = [{ agentRuntimeConfig: input.agentRuntimeConfig, agentConfigRevision: input.agentConfigRevision }];
if (input.router) {
// Also support bindings saved before revision history was introduced, and
// responsible-user defaults that resolve to this same authorized grant.
const bindings = [input.binding, ...("grantId" in input.binding ? [
{ ...input.binding, mode: "shared" }, { ...input.binding, mode: "delegated" },
] : []), ...(["subscription", "api_key"] as const).map(method => ({ mode: "responsible_user", provider: input.binding.provider, method }))];
for (const binding of bindings) {
variants.push({ agentRuntimeConfig: { ...record(input.agentRuntimeConfig), aiConnection: binding }, agentConfigRevision: input.agentConfigRevision });
}
let expectedRuntimeConfig = record(input.agentRuntimeConfig);
for (let index = 0; index < Math.min(20, input.revisions.length); index++) {
const revision = input.revisions[index]!;
if (index === 0 && revision.id !== record(input.agentConfigRevision).id) break;
if (!isDeepStrictEqual(record(revision.afterConfig.runtimeConfig), expectedRuntimeConfig)) break;
if (!aiConnectionOnlyConfigRevision(revision)) break;
// An incomplete bounded history is not sufficient to waive a revision.
const previous = input.revisions[index + 1];
if (!previous && input.revisions.length >= 21) break;
variants.push({ agentRuntimeConfig: record(revision.beforeConfig.runtimeConfig), agentConfigRevision: aiConnectionRevisionMetadata(previous) });
expectedRuntimeConfig = record(revision.beforeConfig.runtimeConfig);
}
}
const overrides = input.router && !isDeepStrictEqual(input.issueOverrides, input.originalIssueOverrides)
? [input.issueOverrides, input.originalIssueOverrides] : [input.issueOverrides];
return variants.flatMap(variant => configs.flatMap(effectiveAdapterConfig => overrides.map(issueOverrides => ({ ...variant, effectiveAdapterConfig, issueOverrides }))));
}
+34 -10
View File
@@ -36,6 +36,7 @@ import { connectionIntentService } from "./connection-intents.js";
import { managedAiSessionFingerprintConfig, prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings, isAiConnectionBusy, AI_AUTH_ENV_KEYS } from "./ai-connection-runtime.js";
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, type AiConnectionRouterSelection } from "@paperclipai/shared";
import { aiConnectionRouterService, AiConnectionPoolExhausted, applyAiConnectionRouterTaskSettings } from "./ai-connection-router.js";
import { aiConnectionSessionCompatibilityInputs, managedAiSessionIdentityCompatible } from "./ai-connection-session.js";
import { executionBlockerPredicate, getExecutionBlocker } from "./execution-blocker.js";
import { CONVERSATION_CONTINUATION_POLICY, claimedAdapterType, runUsedConversationAdapter, hasConversationContinuationPolicy, isConversationAdapter } from "./conversation-continuation.js";
import { recordExecutionWait } from "./execution-wait.js";
@@ -6958,6 +6959,7 @@ export function resolveTaskSessionConfigFreshness(input: {
configuredModel: string | null;
taskSessionParams: Record<string, unknown> | null | undefined;
configMetadata: EffectiveRunSessionConfigMetadata | null;
compatibleConfigMetadata?: readonly EffectiveRunSessionConfigMetadata[];
wakeResetReason?: string | null;
preserveLegacySessionWithoutConfigMetadata?: boolean;
}): TaskSessionConfigFreshnessDecision {
@@ -7005,7 +7007,8 @@ export function resolveTaskSessionConfigFreshness(input: {
);
} else if (
storedConfig &&
storedConfig.fingerprint !== input.configMetadata.fingerprint
storedConfig.fingerprint !== input.configMetadata.fingerprint &&
!input.compatibleConfigMetadata?.some(candidate => candidate.version === storedConfig.version && candidate.fingerprint === storedConfig.fingerprint)
) {
changedCategories = changedEffectiveRunSessionConfigCategories({
previous: storedConfig.categoryFingerprints,
@@ -20905,6 +20908,7 @@ export function heartbeatService(
const persistedRouterPoolId = routerHasPersistedInput ? readNonEmptyString(parseObject(context.aiRouterSelection).poolId) : null;
const requestedAiBinding = persistedRouterPoolId ? { mode: "router" as const, connectionId: persistedRouterPoolId } : agent.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(agent.runtimeConfig.aiConnection) : undefined;
let aiBinding = requestedAiBinding?.mode === "router" ? undefined : requestedAiBinding;
const originalAiIssueOverrides = issueAssigneeOverrides;
if (requestedAiBinding?.mode === "router") {
try {
context.aiRouterTaskKey = readNonEmptyString(context.aiRouterTaskKey) ?? taskKey ?? run.id;
@@ -21700,9 +21704,11 @@ export function heartbeatService(
});
}
const savedAiAccount = parseObject(run.contextSnapshot?.aiConnection);
if (persistedNativeExecutionInput && (savedAiAccount.sessionIdentity
? savedAiAccount.sessionIdentity !== managedAiRuntime.sessionIdentity
: savedAiAccount.identity !== managedAiRuntime.identity)) {
if (persistedNativeExecutionInput && !managedAiSessionIdentityCompatible(
savedAiAccount.sessionIdentity ?? savedAiAccount.identity,
managedAiRuntime.sessionIdentity,
managedAiRuntime.identity,
)) {
throw new ConfigurationIncompleteFailure("The AI account changed while this native run was suspended. Start a new execution.", { configurationIncomplete: { reason: "ai_connection_changed", actionUrl: `/agents/${agent.id}/runtime` } });
}
Object.assign(resolvedConfig, managedAiRuntime.config);
@@ -21774,8 +21780,7 @@ export function heartbeatService(
agent.companyId,
agent.id,
);
const sessionConfigMetadata =
await buildEffectiveRunSessionConfigMetadata({
const sessionConfigMetadataInput = {
adapterType: agent.adapterType,
effectiveAdapterConfig: runtimeConfig,
managedAiHome: managedAiRuntime?.home,
@@ -21839,7 +21844,24 @@ export function heartbeatService(
latestAgentConfigRevision.createdAt.toISOString(),
}
: null,
};
const sessionConfigMetadata = await buildEffectiveRunSessionConfigMetadata(sessionConfigMetadataInput);
let compatibleConfigMetadata: EffectiveRunSessionConfigMetadata[] = [];
if (managedAiRuntime && aiBinding && taskSession &&
readConfigFingerprintFromSessionParams(taskSession.sessionParamsJson)?.fingerprint !== sessionConfigMetadata.fingerprint) {
const revisions = requestedAiBinding?.mode === "router"
? await db.select().from(agentConfigRevisions).where(and(eq(agentConfigRevisions.companyId, agent.companyId), eq(agentConfigRevisions.agentId, agent.id))).orderBy(desc(agentConfigRevisions.createdAt), desc(agentConfigRevisions.id)).limit(21)
: [];
const candidates = aiConnectionSessionCompatibilityInputs({
effectiveAdapterConfig: runtimeConfig, agentRuntimeConfig: agent.runtimeConfig,
agentConfigRevision: sessionConfigMetadataInput.agentConfigRevision,
issueOverrides: issueAssigneeOverrides, originalIssueOverrides: originalAiIssueOverrides,
binding: aiBinding, router: requestedAiBinding?.mode === "router",
storedIdentity: taskSession.sessionParamsJson?.paperclipAiCredentialIdentity,
sessionIdentity: managedAiRuntime.sessionIdentity, credentialIdentity: managedAiRuntime.identity, revisions,
});
compatibleConfigMetadata = await Promise.all(candidates.map(candidate => buildEffectiveRunSessionConfigMetadata({ ...sessionConfigMetadataInput, ...candidate })));
}
const configuredModel =
readConfiguredModelFromAdapterConfig(runtimeConfig);
const wakeSessionResetReason = describeSessionResetReason(context);
@@ -21849,6 +21871,7 @@ export function heartbeatService(
taskSessionParams:
taskSession?.sessionParamsJson ?? taskSessionDecodedParams,
configMetadata: sessionConfigMetadata,
compatibleConfigMetadata,
wakeResetReason: wakeSessionResetReason,
preserveLegacySessionWithoutConfigMetadata:
acceptedPlanContinuationWake && !acceptedPlanWakeRoutingDecision,
@@ -23088,10 +23111,11 @@ export function heartbeatService(
delete context.paperclipPreviousSessionId;
}
const taskSessionCredentialCompatible = isTaskSessionCredentialCompatible(
taskSession?.sessionParamsJson,
managedAiRuntime?.sessionIdentity,
) || Boolean(managedAiRuntime && isTaskSessionCredentialCompatible(taskSession?.sessionParamsJson, managedAiRuntime.identity));
const taskSessionCredentialCompatible = !managedAiRuntime || managedAiSessionIdentityCompatible(
taskSession?.sessionParamsJson?.paperclipAiCredentialIdentity,
managedAiRuntime.sessionIdentity,
managedAiRuntime.identity,
);
if (managedAiRuntime) {
sessionConfigMetadata.aiCredentialIdentity = managedAiRuntime.sessionIdentity;
if (!taskSessionCredentialCompatible) {