mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02: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
@@ -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 }))));
|
||||
}
|
||||
@@ -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