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

+5
View File
@@ -34,6 +34,11 @@ eligible member; otherwise the operator must explicitly reset the session.
Credential `ai_session_epoch` changes on reconnect or manual rotation. Only
verified runtime refresh write-back preserves it. Session fingerprints use the
epoch while authentication failure attribution still uses the token generation.
Adopting a valid account preserves a session only when the complete effective
configuration matches a prior fingerprint. Core can bridge binding-only agent
revisions (up to 20) and unchanged legacy token identities at epoch zero. Changes
to other settings, explicit resets, and credential replacement retain their
existing reset behavior; no fingerprint category is exempted.
Native recovery retains concrete routing evidence and can finish after the
router flag or plugin is disabled or uninstalled; it revalidates underlying
account access and never makes a new allocation.
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { aiConnectionSessionCompatibilityInputs, managedAiSessionIdentityCompatible, aiConnectionOnlyConfigRevision, aiConnectionRevisionMetadata } from "../services/ai-connection-session.js";
import { buildEffectiveRunSessionConfigMetadata, resolveTaskSessionConfigFreshness } from "../services/heartbeat.js";
const binding = { mode: "shared" as const, provider: "openai" as const, method: "subscription" as const, connectionId: "account", grantId: "grant" };
const pool = { mode: "router", connectionId: "pool" };
const sessionIdentity = "grant:alice:0", credentialIdentity = "grant:alice:token";
const config = { provider: "codex", model: "gpt-5.6-sol", env: { OPENAI_API_KEY: "fixture-token" }, managedAiConnection: { ...binding, identity: credentialIdentity, sessionIdentity } };
const base = { adapterType: "paperclip_runner", effectiveAdapterConfig: config, agentRuntimeConfig: { aiConnection: pool }, agentConfigRevision: null as unknown, issueOverrides: null as unknown, workspaceConfig: {}, environment: {}, environmentEnv: null, projectEnv: null, routineEnv: null, runtimeSkills: [] };
type MetadataInput = Parameters<typeof buildEffectiveRunSessionConfigMetadata>[0];
type CompatibilityInput = Parameters<typeof aiConnectionSessionCompatibilityInputs>[0];
const revision = (id: string, beforeBinding: unknown, afterBinding: unknown, extra = {}) => ({
id, changedKeys: ["runtimeConfig"], createdAt: new Date(`2026-10-02T00:00:0${id.slice(-1)}Z`),
beforeConfig: { name: "Agent", runtimeConfig: { aiConnection: beforeBinding }, adapterConfig: { model: "gpt-5.6-sol" } },
afterConfig: { name: "Agent", runtimeConfig: { aiConnection: afterBinding }, adapterConfig: { model: "gpt-5.6-sol" }, ...extra },
});
async function decision(previous: Partial<MetadataInput>, next: Partial<MetadataInput> = {}, compatibility: Partial<CompatibilityInput> = {}, wakeResetReason?: string) {
const before = await buildEffectiveRunSessionConfigMetadata({ ...base, ...previous });
const afterInput = { ...base, ...next };
const after = await buildEffectiveRunSessionConfigMetadata(afterInput);
const candidates = aiConnectionSessionCompatibilityInputs({ ...afterInput, originalIssueOverrides: null, binding, router: true, storedIdentity: sessionIdentity, sessionIdentity, credentialIdentity, revisions: [], ...compatibility });
const alternatives = await Promise.all(candidates.map(candidate => buildEffectiveRunSessionConfigMetadata({ ...afterInput, ...candidate })));
return resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: String(afterInput.effectiveAdapterConfig.model), taskSessionParams: { __paperclipConfiguredModel: "gpt-5.6-sol", __paperclipConfigFingerprint: before.fingerprint, __paperclipConfigFingerprintVersion: before.version, __paperclipConfigCategoryFingerprints: before.categoryFingerprints }, configMetadata: after, compatibleConfigMetadata: alternatives, wakeResetReason });
}
describe("AI pool session adoption", () => {
it.each([binding, { ...binding, mode: "delegated" }, { mode: "responsible_user", provider: "openai", method: "api_key" }])("preserves the exact existing configuration through a compatible binding change: %j", async aiConnection => {
expect((await decision({ agentRuntimeConfig: { aiConnection } })).reset).toBe(false);
});
it("bridges only account-binding edits in revision history", async () => {
const first = revision("revision-1", null, binding);
const adoption = revision("revision-2", binding, pool);
expect(aiConnectionOnlyConfigRevision(adoption)).toBe(true);
expect((await decision({ agentRuntimeConfig: { aiConnection: binding }, agentConfigRevision: aiConnectionRevisionMetadata(first) }, { agentConfigRevision: aiConnectionRevisionMetadata(adoption) }, { revisions: [adoption, first] })).reset).toBe(false);
});
it("handles consecutive binding edits without erasing another configuration revision", async () => {
const first = revision("revision-1", null, binding);
const adoption = revision("revision-2", binding, { mode: "router", connectionId: "previous-pool" });
const switchPool = revision("revision-3", { mode: "router", connectionId: "previous-pool" }, pool);
expect((await decision({ agentRuntimeConfig: { aiConnection: binding }, agentConfigRevision: aiConnectionRevisionMetadata(first) }, { agentConfigRevision: aiConnectionRevisionMetadata(switchPool) }, { revisions: [switchPool, adoption, first] })).reset).toBe(false);
});
it("retains resets when the same edit also changes unrelated configuration", async () => {
const first = revision("revision-1", null, binding);
const edit = revision("revision-2", binding, pool, { name: "Renamed agent" });
expect(aiConnectionOnlyConfigRevision(edit)).toBe(false);
expect((await decision({ agentRuntimeConfig: { aiConnection: binding }, agentConfigRevision: aiConnectionRevisionMetadata(first) }, { agentConfigRevision: aiConnectionRevisionMetadata(edit) }, { revisions: [edit, first] })).reset).toBe(true);
});
it("does not bridge a stale revision over a newer runtime settings change", async () => {
const first = revision("revision-1", null, binding);
const adoption = revision("revision-2", binding, pool);
expect((await decision({ agentRuntimeConfig: { aiConnection: binding }, agentConfigRevision: aiConnectionRevisionMetadata(first) }, { agentRuntimeConfig: { aiConnection: pool, heartbeat: { enabled: false } }, agentConfigRevision: aiConnectionRevisionMetadata(adoption) }, { revisions: [adoption, first] })).reset).toBe(true);
});
it.each([
{ effectiveAdapterConfig: { ...config, model: "gpt-5.6-terra" } },
{ effectiveAdapterConfig: { ...config, provider: "acpx", acpxAgent: "codex" } },
{ effectiveAdapterConfig: { ...config, env: { ...config.env, OTHER: "changed" } } },
{ agentRuntimeConfig: { aiConnection: pool, heartbeat: { maxConcurrentRuns: 2 } } },
{ workspaceConfig: { effectiveMode: "isolated_workspace" } },
])("preserves normal resets for effective settings changes: %j", async next => {
expect((await decision({ agentRuntimeConfig: { aiConnection: binding } }, next)).reset).toBe(true);
});
it("preserves an explicit session reset even when a compatibility fingerprint matches", async () => {
expect((await decision({ agentRuntimeConfig: { aiConnection: binding } }, {}, {}, "explicit session reset")).reset).toBe(true);
});
it("migrates a legacy token fingerprint only for the unchanged credential at epoch zero", async () => {
const managed = { ...config.managedAiConnection } as Record<string, unknown>; delete managed.sessionIdentity;
expect((await decision({ agentRuntimeConfig: { aiConnection: binding }, effectiveAdapterConfig: { ...config, managedAiConnection: managed } }, {}, { storedIdentity: credentialIdentity })).reset).toBe(false);
});
it("does not treat a changed token or a manual replacement of the same bytes as legacy-compatible", () => {
expect(managedAiSessionIdentityCompatible("grant:alice:old-token", sessionIdentity, credentialIdentity)).toBe(false);
expect(managedAiSessionIdentityCompatible(credentialIdentity, "grant:alice:1", credentialIdentity)).toBe(false);
expect(managedAiSessionIdentityCompatible(sessionIdentity, "grant:alice:1", credentialIdentity)).toBe(false);
expect(managedAiSessionIdentityCompatible(sessionIdentity, sessionIdentity, "grant:alice:refreshed-token")).toBe(true);
});
it("rejects compatibility candidates for a different account or responsible user", () => {
const input = { ...base, originalIssueOverrides: null, binding, router: true, sessionIdentity, credentialIdentity, revisions: [] };
expect(aiConnectionSessionCompatibilityInputs({ ...input, storedIdentity: "other-grant:alice:0" })).toEqual([]);
expect(aiConnectionSessionCompatibilityInputs({ ...input, storedIdentity: "grant:bob:0" })).toEqual([]);
});
});
@@ -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) {