Merge current master into PAP-10341 branch

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-11 02:13:32 +00:00
commit 451a6ecf24
10 files changed
+340 -22

No files matched your search

@@ -55,6 +55,54 @@ describe("codex managed home", () => {
}
});
it("still throws on EEXIST when a raced-in auth symlink points elsewhere", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-"));
const sharedCodexHome = path.join(root, "shared-codex-home");
const paperclipHome = path.join(root, "paperclip-home");
const managedCodexHome = path.join(
paperclipHome,
"instances",
"default",
"companies",
"company-1",
"codex-home",
);
const sharedAuth = path.join(sharedCodexHome, "auth.json");
const wrongAuth = path.join(sharedCodexHome, "other-auth.json");
const managedAuth = path.join(managedCodexHome, "auth.json");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(sharedAuth, '{"token":"shared"}\n', "utf8");
await fs.writeFile(wrongAuth, '{"token":"other"}\n', "utf8");
const originalSymlink = fs.symlink.bind(fs);
vi.spyOn(fs, "symlink").mockImplementationOnce(async (_source, target, type) => {
await originalSymlink(wrongAuth, target, type);
const error = new Error("file already exists") as NodeJS.ErrnoException;
error.code = "EEXIST";
throw error;
});
try {
await expect(
prepareManagedCodexHome(
{
CODEX_HOME: sharedCodexHome,
PAPERCLIP_HOME: paperclipHome,
PAPERCLIP_INSTANCE_ID: "default",
},
async () => {},
"company-1",
),
).rejects.toMatchObject({ code: "EEXIST" });
expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true);
expect(await fs.readlink(managedAuth)).toBe(wrongAuth);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
// Regression for #5028: older Paperclip versions copied auth.json into the
// managed home instead of symlinking. After upgrading to the symlink-based
// logic, the stale regular file at the target stayed in place and every
@@ -51,8 +51,9 @@ import { DEFAULT_GEMINI_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js
import {
describeGeminiFailure,
detectGeminiAuthRequired,
isGeminiTransientNetworkError,
isGeminiTurnLimitResult,
isGeminiUnknownSessionError,
isGeminiSessionUnrecoverableError,
parseGeminiJsonl,
} from "./parse.js";
import { firstNonEmptyLine } from "./utils.js";
@@ -569,6 +570,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
});
const networkUnavailable = isGeminiTransientNetworkError(attempt.proc.stdout, attempt.proc.stderr);
if (attempt.proc.timedOut) {
return {
@@ -576,7 +578,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
signal: attempt.proc.signal,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
errorCode: authMeta.requiresAuth ? "gemini_auth_required" : null,
errorCode: authMeta.requiresAuth
? "gemini_auth_required"
: networkUnavailable
? "gemini_network_unavailable"
: null,
clearSession: clearSessionOnMissingSession,
};
}
@@ -632,6 +638,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? "gemini_auth_required"
: failed && clearSessionForTurnLimit
? "max_turns_exhausted"
: failed && networkUnavailable
? "gemini_network_unavailable"
: null,
usage: attempt.parsed.usage,
sessionId: resolvedSessionId,
@@ -655,7 +663,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
sessionId &&
!initial.proc.timedOut &&
(initial.proc.exitCode ?? 0) !== 0 &&
isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
isGeminiSessionUnrecoverableError(initial.proc.stdout, initial.proc.stderr)
) {
await onLog(
"stdout",
@@ -3,7 +3,8 @@ export { listGeminiSkills, syncGeminiSkills } from "./skills.js";
export { testEnvironment } from "./test.js";
export {
parseGeminiJsonl,
isGeminiUnknownSessionError,
isGeminiSessionUnrecoverableError,
isGeminiTransientNetworkError,
describeGeminiFailure,
detectGeminiAuthRequired,
isGeminiTurnLimitResult,
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { parseGeminiJsonl } from "./parse.js";
import {
isGeminiTransientNetworkError,
isGeminiSessionUnrecoverableError,
parseGeminiJsonl,
} from "./parse.js";
describe("parseGeminiJsonl", () => {
it("collects assistant text from message events with string content", () => {
@@ -131,3 +135,69 @@ describe("parseGeminiJsonl", () => {
expect(result.errorMessage).toBe("boom");
});
});
describe("isGeminiSessionUnrecoverableError", () => {
it("matches 'unknown session'", () => {
expect(isGeminiSessionUnrecoverableError("", "Error: unknown session 'abc-123'")).toBe(true);
});
it("matches 'session ... not found'", () => {
expect(isGeminiSessionUnrecoverableError("", "Resumed session abc-123 not found on disk")).toBe(true);
});
it("matches 'exceeds the maximum number of tokens' (compression overflow)", () => {
const stderr =
'_ApiError: {"error":{"code":400,"message":"The input token count exceeds the maximum number of tokens allowed 1048576","status":"INVALID_ARGUMENT"}} at ChatCompressionService.compress';
expect(isGeminiSessionUnrecoverableError("", stderr)).toBe(true);
});
it("matches 'input token count exceeds'", () => {
expect(
isGeminiSessionUnrecoverableError("", "input token count exceeds maximum"),
).toBe(true);
});
it("does not match unrelated stderr", () => {
expect(isGeminiSessionUnrecoverableError("", "Some other error")).toBe(false);
});
it("does not match transient network errors (those go to isGeminiTransientNetworkError)", () => {
expect(
isGeminiSessionUnrecoverableError(
"",
"_GaxiosError: getaddrinfo ENOTFOUND oauth2.googleapis.com",
),
).toBe(false);
});
});
describe("isGeminiTransientNetworkError", () => {
it("matches DNS failure on oauth2.googleapis.com", () => {
const stderr =
"_GaxiosError: request to https://oauth2.googleapis.com/token failed, reason: getaddrinfo ENOTFOUND oauth2.googleapis.com";
expect(isGeminiTransientNetworkError("", stderr)).toBe(true);
});
it("matches EAI_AGAIN", () => {
expect(
isGeminiTransientNetworkError("", "Error: getaddrinfo EAI_AGAIN sts.googleapis.com"),
).toBe(true);
});
it("matches _UserRefreshClient ENOTFOUND", () => {
const stderr =
"at _UserRefreshClient.refreshTokenNoCache (.../google-auth-library/...)\n" +
" caused by: ENOTFOUND oauth2.googleapis.com";
expect(isGeminiTransientNetworkError("", stderr)).toBe(true);
});
it("does not match unrelated stderr", () => {
expect(isGeminiTransientNetworkError("", "Some other error")).toBe(false);
});
it("does not match unknown-session errors (those go to isGeminiSessionUnrecoverableError)", () => {
expect(
isGeminiTransientNetworkError("", "Error: unknown session 'abc-123'"),
).toBe(false);
});
});
@@ -199,14 +199,26 @@ export function parseGeminiJsonl(stdout: string) {
};
}
export function isGeminiUnknownSessionError(stdout: string, stderr: string): boolean {
export function isGeminiSessionUnrecoverableError(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume/i.test(
return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume|exceeds\s+the\s+maximum\s+number\s+of\s+tokens|input\s+token\s+count\s+exceeds/i.test(
haystack,
);
}
export function isGeminiTransientNetworkError(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
return /ENOTFOUND\s+oauth2\.googleapis\.com|ENOTFOUND\s+sts\.googleapis\.com|EAI_AGAIN|_GaxiosError.*ENOTFOUND|_UserRefreshClient.*ENOTFOUND/i.test(
haystack,
);
}
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});
@@ -7,7 +7,7 @@ import {
} from "@paperclipai/adapter-cursor-local/server";
import {
sessionCodec as geminiSessionCodec,
isGeminiUnknownSessionError,
isGeminiSessionUnrecoverableError,
} from "@paperclipai/adapter-gemini-local/server";
import {
sessionCodec as opencodeSessionCodec,
@@ -220,19 +220,19 @@ describe("cursor resume recovery detection", () => {
describe("gemini resume recovery detection", () => {
it("detects unknown session errors from gemini output", () => {
expect(
isGeminiUnknownSessionError(
isGeminiSessionUnrecoverableError(
"",
"unknown session id abc",
),
).toBe(true);
expect(
isGeminiUnknownSessionError(
isGeminiSessionUnrecoverableError(
"",
"checkpoint latest not found",
),
).toBe(true);
expect(
isGeminiUnknownSessionError(
isGeminiSessionUnrecoverableError(
"{\"type\":\"result\",\"subtype\":\"success\"}",
"",
),
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import {
isGeminiTurnLimitResult,
isGeminiUnknownSessionError,
isGeminiSessionUnrecoverableError,
parseGeminiJsonl,
} from "@paperclipai/adapter-gemini-local/server";
import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui";
@@ -76,8 +76,8 @@ describe("gemini_local parser", () => {
describe("gemini_local stale session detection", () => {
it("treats missing session messages as an unknown session error", () => {
expect(isGeminiUnknownSessionError("", "unknown session id abc")).toBe(true);
expect(isGeminiUnknownSessionError("", "checkpoint latest not found")).toBe(true);
expect(isGeminiSessionUnrecoverableError("", "unknown session id abc")).toBe(true);
expect(isGeminiSessionUnrecoverableError("", "checkpoint latest not found")).toBe(true);
});
});
@@ -21,6 +21,9 @@ import {
shouldDeferFollowupWakeForSameIssue,
stripHostWorkspaceProvisionForLowTrustSandbox,
stripWorkspaceRuntimeFromExecutionRunConfig,
shouldResetTaskSessionForModelChange,
stripConfiguredModelFromSessionParams,
normalizeSessionParams,
shouldResetTaskSessionForWake,
type ResolvedWorkspaceForRun,
} from "../services/heartbeat.ts";
@@ -926,6 +929,113 @@ describe("shouldDeferFollowupWakeForSameIssue", () => {
});
});
describe("shouldResetTaskSessionForModelChange", () => {
it("resets when configured model differs from persisted session model", () => {
expect(
shouldResetTaskSessionForModelChange({
configuredModel: "gpt-5.4-mini",
taskSessionParams: {
sessionId: "thread-1",
__paperclipConfiguredModel: "opencode/mimo-v2-pro-free",
},
}),
).toBe(true);
});
it("does not reset when models match", () => {
expect(
shouldResetTaskSessionForModelChange({
configuredModel: "gpt-5.4-mini",
taskSessionParams: {
sessionId: "thread-1",
__paperclipConfiguredModel: "gpt-5.4-mini",
},
}),
).toBe(false);
});
it("does not reset when persisted session model is missing", () => {
expect(
shouldResetTaskSessionForModelChange({
configuredModel: "gpt-5.4-mini",
taskSessionParams: {
sessionId: "thread-1",
},
}),
).toBe(false);
});
it("does not reset when configured model is missing", () => {
expect(
shouldResetTaskSessionForModelChange({
configuredModel: null,
taskSessionParams: {
sessionId: "thread-1",
__paperclipConfiguredModel: "gpt-5.4-mini",
},
}),
).toBe(false);
});
it("does not reset when task session params are missing", () => {
expect(
shouldResetTaskSessionForModelChange({
configuredModel: "gpt-5.4-mini",
taskSessionParams: null,
}),
).toBe(false);
});
});
describe("stripConfiguredModelFromSessionParams", () => {
it("removes the internal model key from persisted session params", () => {
expect(
stripConfiguredModelFromSessionParams({
sessionId: "thread-1",
__paperclipConfiguredModel: "gpt-5.4-mini",
}),
).toEqual({ sessionId: "thread-1" });
});
it("returns null when session params are missing", () => {
expect(stripConfiguredModelFromSessionParams(null)).toBeNull();
expect(stripConfiguredModelFromSessionParams(undefined)).toBeNull();
});
it("returns a copy without mutating the input", () => {
const input = { sessionId: "thread-1", __paperclipConfiguredModel: "gpt-5.4-mini" };
const result = stripConfiguredModelFromSessionParams(input);
expect(result).not.toBe(input);
expect(input.__paperclipConfiguredModel).toBe("gpt-5.4-mini");
});
it("returns an empty object when only the internal model key is present (caller must normalize)", () => {
const stripped = stripConfiguredModelFromSessionParams({
__paperclipConfiguredModel: "gpt-5.4-mini",
});
expect(stripped).toEqual({});
// Callers that forward params to adapters must normalize {} back to null so
// the pre-PR null contract is preserved (adapters distinguishing {} from null).
expect(normalizeSessionParams(stripped)).toBeNull();
});
});
describe("normalizeSessionParams", () => {
it("collapses an empty object to null", () => {
expect(normalizeSessionParams({})).toBeNull();
});
it("returns null for null or undefined inputs", () => {
expect(normalizeSessionParams(null)).toBeNull();
expect(normalizeSessionParams(undefined)).toBeNull();
});
it("preserves a non-empty object", () => {
const params = { sessionId: "thread-1" };
expect(normalizeSessionParams(params)).toBe(params);
});
});
describe("deriveTaskKeyWithHeartbeatFallback", () => {
it("returns explicit taskKey when present", () => {
expect(deriveTaskKeyWithHeartbeatFallback({ taskKey: "issue-123" }, null)).toBe("issue-123");
+70 -8
View File
@@ -2131,6 +2131,49 @@ export function shouldDeferFollowupWakeForSameIssue(input: {
return false;
}
const SESSION_CONFIGURED_MODEL_KEY = "__paperclipConfiguredModel";
function readConfiguredModelFromAdapterConfig(
adapterConfig: Record<string, unknown> | null | undefined,
) {
return readNonEmptyString(adapterConfig?.model);
}
function attachConfiguredModelToSessionParams(
sessionParams: Record<string, unknown> | null | undefined,
configuredModel: string | null,
) {
if (!configuredModel) return sessionParams ?? null;
const next = { ...(sessionParams ?? {}) };
next[SESSION_CONFIGURED_MODEL_KEY] = configuredModel;
return next;
}
function readConfiguredModelFromSessionParams(
sessionParams: Record<string, unknown> | null | undefined,
) {
return readNonEmptyString(sessionParams?.[SESSION_CONFIGURED_MODEL_KEY]);
}
export function shouldResetTaskSessionForModelChange(input: {
configuredModel: string | null;
taskSessionParams: Record<string, unknown> | null | undefined;
}) {
const { configuredModel, taskSessionParams } = input;
if (!configuredModel || !taskSessionParams) return false;
const sessionModel = readConfiguredModelFromSessionParams(taskSessionParams);
return !!sessionModel && sessionModel !== configuredModel;
}
export function stripConfiguredModelFromSessionParams(
sessionParams: Record<string, unknown> | null | undefined,
) {
if (!sessionParams) return null;
const next = { ...sessionParams };
delete next[SESSION_CONFIGURED_MODEL_KEY];
return next;
}
function shouldAutoCheckoutIssueForWake(input: {
contextSnapshot: Record<string, unknown> | null | undefined;
issueStatus: string | null;
@@ -2846,7 +2889,7 @@ function getAdapterSessionCodec(adapterType: string) {
return adapter.sessionCodec ?? defaultSessionCodec;
}
function normalizeSessionParams(params: Record<string, unknown> | null | undefined) {
export function normalizeSessionParams(params: Record<string, unknown> | null | undefined) {
if (!params) return null;
return Object.keys(params).length > 0 ? params : null;
}
@@ -7833,11 +7876,27 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
: null,
});
const config = parseObject(agent.adapterConfig);
const configuredModel = readConfiguredModelFromAdapterConfig(config);
const taskSession = taskKey
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
: null;
const resetTaskSession = shouldResetTaskSessionForWake(context);
const sessionResetReason = describeSessionResetReason(context);
const taskSessionDecodedParams = normalizeSessionParams(
sessionCodec.deserialize(taskSession?.sessionParamsJson ?? null),
);
const modelChangedSinceTaskSession = shouldResetTaskSessionForModelChange({
configuredModel,
taskSessionParams: taskSessionDecodedParams,
});
const resetTaskSession = shouldResetTaskSessionForWake(context) || modelChangedSinceTaskSession;
const wakeSessionResetReason = describeSessionResetReason(context);
const taskSessionConfiguredModel = readConfiguredModelFromSessionParams(taskSessionDecodedParams);
const modelSessionResetReason = modelChangedSinceTaskSession && taskSessionConfiguredModel
? `configured model changed from "${taskSessionConfiguredModel}" to "${configuredModel}"`
: null;
const sessionResetReason = [modelSessionResetReason, wakeSessionResetReason]
.filter((value): value is string => Boolean(value))
.join("; ") || null;
const taskSessionForRun = resetTaskSession ? null : taskSession;
const explicitResumeSessionParams = normalizeResumeParamsForAdapter(
agent.adapterType,
@@ -7855,9 +7914,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
: null) ??
normalizeResumeParamsForAdapter(
agent.adapterType,
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
stripConfiguredModelFromSessionParams(
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
),
);
const config = parseObject(agent.adapterConfig);
const resolvedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({
projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings,
@@ -8490,7 +8550,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
: runtimeSessionDisplayId;
let runtimeSessionIdForAdapter =
readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback;
let runtimeSessionParamsForAdapter = runtimeSessionParams;
let runtimeSessionParamsForAdapter = normalizeSessionParams(
stripConfiguredModelFromSessionParams(runtimeSessionParams),
);
const sessionCompaction = await evaluateSessionCompaction({
agent,
@@ -9159,7 +9221,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
agentId: agent.id,
adapterType: agent.adapterType,
taskKey,
sessionParamsJson: nextSessionState.params,
sessionParamsJson: attachConfiguredModelToSessionParams(nextSessionState.params, configuredModel),
sessionDisplayId: nextSessionState.displayId,
lastRunId: finalizedRun.id,
lastError: outcome === "succeeded" ? null : (adapterResult.errorMessage ?? "run_failed"),
@@ -9242,7 +9304,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
agentId: agent.id,
adapterType: agent.adapterType,
taskKey,
sessionParamsJson: previousSessionParams,
sessionParamsJson: attachConfiguredModelToSessionParams(previousSessionParams, configuredModel),
sessionDisplayId: previousSessionDisplayId,
lastRunId: failedRun.id,
lastError: message,