mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
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:
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 }))));
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user