Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions packages/adapter-utils/src/session-compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface SessionCompactionPolicy {
maxSessionRuns: number;
maxRawInputTokens: number;
maxSessionAgeHours: number;
maxConsecutiveAdapterFailed: number;
}

export type NativeContextManagement = "confirmed" | "likely" | "unknown" | "none";
Expand All @@ -25,6 +26,7 @@ const DEFAULT_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = {
maxSessionRuns: 200,
maxRawInputTokens: 2_000_000,
maxSessionAgeHours: 72,
maxConsecutiveAdapterFailed: 0,
};

// Adapters with native context management still participate in session resume,
Expand All @@ -34,6 +36,7 @@ const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = {
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 0,
maxConsecutiveAdapterFailed: 0,
};

export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([
Expand All @@ -57,7 +60,10 @@ export const ADAPTER_SESSION_MANAGEMENT: Record<string, AdapterSessionManagement
claude_local: {
supportsSessionResume: true,
nativeContextManagement: "confirmed",
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
defaultSessionCompaction: {
...ADAPTER_MANAGED_SESSION_POLICY,
maxConsecutiveAdapterFailed: 2,
},
},
codex_local: {
supportsSessionResume: true,
Expand Down Expand Up @@ -146,11 +152,13 @@ export function readSessionCompactionOverride(runtimeConfig: unknown): Partial<S
const maxSessionRuns = readNumber(compaction.maxSessionRuns);
const maxRawInputTokens = readNumber(compaction.maxRawInputTokens);
const maxSessionAgeHours = readNumber(compaction.maxSessionAgeHours);
const maxConsecutiveAdapterFailed = readNumber(compaction.maxConsecutiveAdapterFailed);

if (enabled !== undefined) explicit.enabled = enabled;
if (maxSessionRuns !== undefined) explicit.maxSessionRuns = maxSessionRuns;
if (maxRawInputTokens !== undefined) explicit.maxRawInputTokens = maxRawInputTokens;
if (maxSessionAgeHours !== undefined) explicit.maxSessionAgeHours = maxSessionAgeHours;
if (maxConsecutiveAdapterFailed !== undefined) explicit.maxConsecutiveAdapterFailed = maxConsecutiveAdapterFailed;

return explicit;
}
Expand All @@ -174,6 +182,7 @@ export function resolveSessionCompactionPolicy(
maxSessionRuns: explicitOverride.maxSessionRuns ?? basePolicy.maxSessionRuns,
maxRawInputTokens: explicitOverride.maxRawInputTokens ?? basePolicy.maxRawInputTokens,
maxSessionAgeHours: explicitOverride.maxSessionAgeHours ?? basePolicy.maxSessionAgeHours,
maxConsecutiveAdapterFailed: explicitOverride.maxConsecutiveAdapterFailed ?? basePolicy.maxConsecutiveAdapterFailed,
},
adapterSessionManagement,
explicitOverride,
Expand All @@ -187,7 +196,12 @@ export function resolveSessionCompactionPolicy(

export function hasSessionCompactionThresholds(policy: Pick<
SessionCompactionPolicy,
"maxSessionRuns" | "maxRawInputTokens" | "maxSessionAgeHours"
"maxSessionRuns" | "maxRawInputTokens" | "maxSessionAgeHours" | "maxConsecutiveAdapterFailed"
>) {
return policy.maxSessionRuns > 0 || policy.maxRawInputTokens > 0 || policy.maxSessionAgeHours > 0;
return (
policy.maxSessionRuns > 0 ||
policy.maxRawInputTokens > 0 ||
policy.maxSessionAgeHours > 0 ||
policy.maxConsecutiveAdapterFailed > 0
);
}
22 changes: 22 additions & 0 deletions packages/adapters/claude-local/src/server/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
}

// Connection refused or other transient upstream errors: rotate session and retry
if (
sessionId &&
!initial.proc.timedOut &&
(initial.proc.exitCode ?? 0) !== 0 &&
isClaudeTransientUpstreamError({
parsed: initial.parsed,
stdout: initial.proc.stdout,
stderr: initial.proc.stderr,
errorMessage: initial.parsed
? describeClaudeFailure(initial.parsed) ?? `Claude exited with code ${initial.proc.exitCode ?? -1}`
: parseFallbackErrorMessage(initial.proc),
})
) {
await onLog(
"stdout",
`[paperclip] Claude connection failed with transient error; rotating session and retrying.\n`,
);
const retry = await runAttempt(null);
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
}

return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
} finally {
if (paperclipBridge) {
Expand Down
18 changes: 18 additions & 0 deletions packages/adapters/claude-local/src/server/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,24 @@ describe("isClaudeTransientUpstreamError", () => {
).toBe(false);
});

it("classifies connection refused / ECONNREFUSED as transient", () => {
expect(
isClaudeTransientUpstreamError({
stderr: "Error: connect ECONNREFUSED 127.0.0.1:3456",
}),
).toBe(true);
expect(
isClaudeTransientUpstreamError({
stderr: "Connection refused while trying to reach Claude Code server",
}),
).toBe(true);
expect(
isClaudeTransientUpstreamError({
errorMessage: "Claude Code CLI connection refused",
}),
).toBe(true);
});

it("does not classify deterministic validation errors as transient", () => {
expect(
isClaudeTransientUpstreamError({
Expand Down
2 changes: 1 addition & 1 deletion packages/adapters/claude-local/src/server/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const CLAUDE_AUTH_REQUIRED_RE = /(?:not\s+logged\s+in|please\s+log\s+in|please\s
const URL_RE = /(https?:\/\/[^\s'"`<>()[\]{};,!?]+[^\s'"`<>()[\]{};,!.?:]+)/gi;

const CLAUDE_TRANSIENT_UPSTREAM_RE =
/(?:rate[-\s]?limit(?:ed)?|rate_limit_error|too\s+many\s+requests|\b429\b|overloaded(?:_error)?|server\s+overloaded|service\s+unavailable|\b503\b|\b529\b|high\s+demand|try\s+again\s+later|temporarily\s+unavailable|throttl(?:ed|ing)|throttlingexception|servicequotaexceededexception|out\s+of\s+extra\s+usage|extra\s+usage\b|claude\s+usage\s+limit\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached|usage\s+limit\s+reached|usage\s+cap\s+reached)/i;
/(?:rate[-\s]?limit(?:ed)?|rate_limit_error|too\s+many\s+requests|\b429\b|overloaded(?:_error)?|server\s+overloaded|service\s+unavailable|\b503\b|\b529\b|high\s+demand|try\s+again\s+later|temporarily\s+unavailable|throttl(?:ed|ing)|throttlingexception|servicequotaexceededexception|out\s+of\s+extra\s+usage|extra\s+usage\b|claude\s+usage\s+limit\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached|usage\s+limit\s+reached|usage\s+cap\s+reached|connection\s+refused|econnrefused|connect\s+error)/i;
const CLAUDE_EXTRA_USAGE_RESET_RE =
/(?:out\s+of\s+extra\s+usage|extra\s+usage|usage\s+limit\s+reached|usage\s+cap\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached|claude\s+usage\s+limit\s+reached)[\s\S]{0,80}?\bresets?\s+(?:at\s+)?([^\n()]+?)(?:\s*\(([^)]+)\))?(?:[.!]|\n|$)/i;

Expand Down
190 changes: 190 additions & 0 deletions server/src/__tests__/heartbeat-session-compaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { describe, expect, it } from "vitest";
import type { agents } from "@paperclipai/db";
import {
parseSessionCompactionPolicy,
evaluateSessionCompactionFromRuns,
type SessionCompactionDecision,
} from "../services/heartbeat.ts";

function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown> = {}) {
return {
id: "agent-1",
companyId: "company-1",
projectId: null,
goalId: null,
name: "Agent",
role: "engineer",
title: null,
icon: null,
status: "running",
reportsTo: null,
capabilities: null,
adapterType,
adapterConfig: {},
runtimeConfig,
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
permissions: {},
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
} as unknown as typeof agents.$inferSelect;
}

function buildRun(overrides: {
id?: string;
createdAt?: Date;
resultJson?: Record<string, unknown> | null;
errorCode?: string | null;
error?: string | null;
} = {}) {
return {
id: overrides.id ?? "run-1",
createdAt: overrides.createdAt ?? new Date("2026-06-30T12:00:00.000Z"),
usageJson: null,
error: overrides.error ?? null,
resultSummary: null,
resultResult: null,
resultMessage: null,
resultError: null,
resultTotalCostUsd: null,
resultCostUsd: null,
resultCostUsdCamel: null,
resultJson: overrides.resultJson ?? null,
errorCode: overrides.errorCode ?? null,
};
}

describe("parseSessionCompactionPolicy maxConsecutiveAdapterFailed", () => {
it("defaults claude_local to 2 consecutive adapter_failed rotations", () => {
expect(parseSessionCompactionPolicy(buildAgent("claude_local"))).toMatchObject({
maxConsecutiveAdapterFailed: 2,
});
});

it("defaults other adapters to 0", () => {
expect(parseSessionCompactionPolicy(buildAgent("codex_local"))).toMatchObject({
maxConsecutiveAdapterFailed: 0,
});
expect(parseSessionCompactionPolicy(buildAgent("cursor"))).toMatchObject({
maxConsecutiveAdapterFailed: 0,
});
});

it("allows agent runtime config override", () => {
expect(
parseSessionCompactionPolicy(
buildAgent("claude_local", {
heartbeat: {
sessionCompaction: {
maxConsecutiveAdapterFailed: 5,
},
},
}),
),
).toMatchObject({
maxConsecutiveAdapterFailed: 5,
});
});
});

function evaluateFromRuns(
agent: ReturnType<typeof buildAgent>,
runs: ReturnType<typeof buildRun>[],
): SessionCompactionDecision {
const policy = parseSessionCompactionPolicy(agent);
return evaluateSessionCompactionFromRuns({
agent,
sessionId: "session-1",
issueId: "issue-1",
policy,
runs,
oldestRun: runs[runs.length - 1] ?? null,
});
}

describe("evaluateSessionCompaction consecutive adapter_failed rule", () => {
it("does not rotate when threshold is 0", () => {
const agent = buildAgent("codex_local");
const decision = evaluateFromRuns(agent, [
buildRun({ resultJson: { stopReason: "adapter_failed" }, errorCode: "adapter_failed" }),
buildRun({ resultJson: { stopReason: "adapter_failed" }, errorCode: "adapter_failed" }),
buildRun({ resultJson: { stopReason: "adapter_failed" }, errorCode: "adapter_failed" }),
]);
expect(decision.rotate).toBe(false);
});

it("does not rotate when there are fewer consecutive adapter_failed runs than threshold", () => {
const agent = buildAgent("claude_local");
const decision = evaluateFromRuns(agent, [
buildRun({ resultJson: { stopReason: "adapter_failed" }, errorCode: "adapter_failed" }),
]);
expect(decision.rotate).toBe(false);
});

it("rotates when 2 consecutive adapter_failed runs occur for claude_local", () => {
const agent = buildAgent("claude_local");
const decision = evaluateFromRuns(agent, [
buildRun({
id: "run-2",
createdAt: new Date("2026-06-30T12:01:00.000Z"),
resultJson: { stopReason: "adapter_failed" },
errorCode: "adapter_failed",
}),
buildRun({
id: "run-1",
createdAt: new Date("2026-06-30T12:00:00.000Z"),
resultJson: { stopReason: "adapter_failed" },
errorCode: "adapter_failed",
}),
]);
expect(decision.rotate).toBe(true);
expect(decision.reason).toContain("2 consecutive adapter_failed");
expect(decision.previousRunId).toBe("run-2");
});

it("rotates when consecutive runs include transient_upstream error family", () => {
const agent = buildAgent("claude_local");
const decision = evaluateFromRuns(agent, [
buildRun({
resultJson: { stopReason: "adapter_failed", errorFamily: "transient_upstream" },
errorCode: "claude_transient_upstream",
}),
buildRun({
resultJson: { stopReason: "adapter_failed", errorFamily: "transient_upstream" },
errorCode: "claude_transient_upstream",
}),
]);
expect(decision.rotate).toBe(true);
});

it("stops counting at the first non-adapter-failed run", () => {
const agent = buildAgent("claude_local");
const decision = evaluateFromRuns(agent, [
buildRun({ resultJson: { stopReason: "adapter_failed" }, errorCode: "adapter_failed" }),
buildRun({ resultJson: { stopReason: "completed" }, errorCode: null }),
buildRun({ resultJson: { stopReason: "adapter_failed" }, errorCode: "adapter_failed" }),
]);
expect(decision.rotate).toBe(false);
});

it("does not rotate when the latest run succeeded", () => {
const agent = buildAgent("claude_local");
const decision = evaluateFromRuns(agent, [
buildRun({ resultJson: { stopReason: "completed" }, errorCode: null }),
buildRun({ resultJson: { stopReason: "adapter_failed" }, errorCode: "adapter_failed" }),
buildRun({ resultJson: { stopReason: "adapter_failed" }, errorCode: "adapter_failed" }),
]);
expect(decision.rotate).toBe(false);
});

it("falls back to errorCode when stopReason is missing", () => {
const agent = buildAgent("claude_local");
const decision = evaluateFromRuns(agent, [
buildRun({ resultJson: {}, errorCode: "adapter_failed" }),
buildRun({ resultJson: {}, errorCode: "adapter_failed" }),
]);
expect(decision.rotate).toBe(true);
});
});
6 changes: 6 additions & 0 deletions server/src/__tests__/heartbeat-workspace-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,12 +527,14 @@ describe("parseSessionCompactionPolicy", () => {
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 0,
maxConsecutiveAdapterFailed: 0,
});
expect(parseSessionCompactionPolicy(buildAgent("claude_local"))).toEqual({
enabled: true,
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 0,
maxConsecutiveAdapterFailed: 2,
});
});

Expand All @@ -542,12 +544,14 @@ describe("parseSessionCompactionPolicy", () => {
maxSessionRuns: 200,
maxRawInputTokens: 2_000_000,
maxSessionAgeHours: 72,
maxConsecutiveAdapterFailed: 0,
});
expect(parseSessionCompactionPolicy(buildAgent("opencode_local"))).toEqual({
enabled: true,
maxSessionRuns: 200,
maxRawInputTokens: 2_000_000,
maxSessionAgeHours: 72,
maxConsecutiveAdapterFailed: 0,
});
});

Expand All @@ -559,6 +563,7 @@ describe("parseSessionCompactionPolicy", () => {
sessionCompaction: {
maxSessionRuns: 25,
maxRawInputTokens: 500_000,
maxConsecutiveAdapterFailed: 3,
},
},
}),
Expand All @@ -568,6 +573,7 @@ describe("parseSessionCompactionPolicy", () => {
maxSessionRuns: 25,
maxRawInputTokens: 500_000,
maxSessionAgeHours: 0,
maxConsecutiveAdapterFailed: 3,
});
});
});
Loading