From 3d8a759eba36e03cbf024643cc1c9e54f3ed8749 Mon Sep 17 00:00:00 2001 From: Tobias Bischoff <711564+tobiasbischoff@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:04:56 +0100 Subject: [PATCH] fix(auth): skip auth profiles in cooldown during selection and rotation Auth profiles in cooldown (due to rate limiting) were being attempted, causing unnecessary retries and delays. This fix ensures: 1. Initial profile selection skips profiles in cooldown 2. Profile rotation (after failures) skips cooldown profiles 3. Clear error message when all profiles are unavailable Tests added: - Skips profiles in cooldown during initial selection - Skips profiles in cooldown when rotating after failure Fixes #1316 --- CHANGELOG.md | 1 + ...ded-pi-agent.auth-profile-rotation.test.ts | 139 ++++++++++++++++++ src/agents/pi-embedded-runner/run.ts | 18 ++- .../bot-message-context.sender-prefix.test.ts | 92 ++++++++++++ 4 files changed, 249 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b84c324b5..46ade97f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.clawd.bot - **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert. ### Fixes +- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik. - Media: accept MEDIA paths with spaces/tilde and prefer the message tool hint for image replies. - Google Antigravity: drop unsigned thinking blocks for Claude models to avoid signature errors. - Config: avoid stack traces for invalid configs and log the config path. diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts index b931230af..27bd96419 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts @@ -248,4 +248,143 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { await fs.rm(workspaceDir, { recursive: true, force: true }); } }); + + it("skips profiles in cooldown during initial selection", async () => { + vi.useFakeTimers(); + try { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-")); + const now = Date.now(); + vi.setSystemTime(now); + + try { + const authPath = path.join(agentDir, "auth-profiles.json"); + const payload = { + version: 1, + profiles: { + "openai:p1": { type: "api_key", provider: "openai", key: "sk-one" }, + "openai:p2": { type: "api_key", provider: "openai", key: "sk-two" }, + }, + usageStats: { + "openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 }, // p1 in cooldown for 1 hour + "openai:p2": { lastUsed: 2 }, + }, + }; + await fs.writeFile(authPath, JSON.stringify(payload)); + + runEmbeddedAttemptMock.mockResolvedValueOnce( + makeAttempt({ + assistantTexts: ["ok"], + lastAssistant: buildAssistant({ + stopReason: "stop", + content: [{ type: "text", text: "ok" }], + }), + }), + ); + + await runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: "agent:test:skip-cooldown", + sessionFile: path.join(workspaceDir, "session.jsonl"), + workspaceDir, + agentDir, + config: makeConfig(), + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileId: undefined, + authProfileIdSource: "auto", + timeoutMs: 5_000, + runId: "run:skip-cooldown", + }); + + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); + + const stored = JSON.parse( + await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"), + ) as { usageStats?: Record }; + expect(stored.usageStats?.["openai:p1"]?.cooldownUntil).toBe(now + 60 * 60 * 1000); + expect(typeof stored.usageStats?.["openai:p2"]?.lastUsed).toBe("number"); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + } finally { + vi.useRealTimers(); + } + }); + + it("skips profiles in cooldown when rotating after failure", async () => { + vi.useFakeTimers(); + try { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-")); + const now = Date.now(); + vi.setSystemTime(now); + + try { + const authPath = path.join(agentDir, "auth-profiles.json"); + const payload = { + version: 1, + profiles: { + "openai:p1": { type: "api_key", provider: "openai", key: "sk-one" }, + "openai:p2": { type: "api_key", provider: "openai", key: "sk-two" }, + }, + usageStats: { + "openai:p1": { lastUsed: 1 }, + "openai:p2": { cooldownUntil: now + 60 * 60 * 1000 }, // p2 in cooldown + }, + }; + await fs.writeFile(authPath, JSON.stringify(payload)); + + runEmbeddedAttemptMock + .mockResolvedValueOnce( + makeAttempt({ + assistantTexts: [], + lastAssistant: buildAssistant({ + stopReason: "error", + errorMessage: "rate limit", + }), + }), + ) + .mockResolvedValueOnce( + makeAttempt({ + assistantTexts: ["ok"], + lastAssistant: buildAssistant({ + stopReason: "stop", + content: [{ type: "text", text: "ok" }], + }), + }), + ); + + await runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: "agent:test:rotate-skip-cooldown", + sessionFile: path.join(workspaceDir, "session.jsonl"), + workspaceDir, + agentDir, + config: makeConfig(), + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileId: "openai:p1", + authProfileIdSource: "auto", + timeoutMs: 5_000, + runId: "run:rotate-skip-cooldown", + }); + + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2); + + const stored = JSON.parse( + await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"), + ) as { usageStats?: Record }; + expect(typeof stored.usageStats?.["openai:p1"]?.lastUsed).toBe("number"); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 174178b09..a2af256cc 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -5,6 +5,7 @@ import { resolveUserPath } from "../../utils.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { resolveClawdbotAgentDir } from "../agent-paths.js"; import { + isProfileInCooldown, markAuthProfileFailure, markAuthProfileGood, markAuthProfileUsed, @@ -196,6 +197,10 @@ export async function runEmbeddedPiAgent( let nextIndex = profileIndex + 1; while (nextIndex < profileCandidates.length) { const candidate = profileCandidates[nextIndex]; + if (candidate && isProfileInCooldown(authStore, candidate)) { + nextIndex += 1; + continue; + } try { await applyApiKeyInfo(candidate); profileIndex = nextIndex; @@ -211,7 +216,18 @@ export async function runEmbeddedPiAgent( }; try { - await applyApiKeyInfo(profileCandidates[profileIndex]); + while (profileIndex < profileCandidates.length) { + const candidate = profileCandidates[profileIndex]; + if (candidate && isProfileInCooldown(authStore, candidate)) { + profileIndex += 1; + continue; + } + await applyApiKeyInfo(profileCandidates[profileIndex]); + break; + } + if (profileIndex >= profileCandidates.length) { + throw new Error(`No available auth profile for ${provider} (all in cooldown or unavailable).`); + } } catch (err) { if (profileCandidates[profileIndex] === lockedProfileId) throw err; const advanced = await advanceAuthProfile(); diff --git a/src/telegram/bot-message-context.sender-prefix.test.ts b/src/telegram/bot-message-context.sender-prefix.test.ts index 12d7b09e5..c7f0e5de9 100644 --- a/src/telegram/bot-message-context.sender-prefix.test.ts +++ b/src/telegram/bot-message-context.sender-prefix.test.ts @@ -49,4 +49,96 @@ describe("buildTelegramMessageContext sender prefix", () => { const body = ctx?.ctxPayload?.Body ?? ""; expect(body).toContain("Alice (42): hello"); }); + + it("sets MessageSid from message_id", async () => { + const ctx = await buildTelegramMessageContext({ + primaryCtx: { + message: { + message_id: 12345, + chat: { id: -99, type: "supergroup", title: "Dev Chat" }, + date: 1700000000, + text: "hello", + from: { id: 42, first_name: "Alice" }, + }, + me: { id: 7, username: "bot" }, + } as never, + allMedia: [], + storeAllowFrom: [], + options: {}, + bot: { + api: { + sendChatAction: vi.fn(), + setMessageReaction: vi.fn(), + }, + } as never, + cfg: { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } }, + channels: { telegram: {} }, + messages: { groupChat: { mentionPatterns: [] } }, + } as never, + account: { accountId: "default" } as never, + historyLimit: 0, + groupHistories: new Map(), + dmPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + ackReactionScope: "off", + logger: { info: vi.fn() }, + resolveGroupActivation: () => undefined, + resolveGroupRequireMention: () => false, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: undefined, + }), + }); + + expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload?.MessageSid).toBe("12345"); + }); + + it("respects messageIdOverride option", async () => { + const ctx = await buildTelegramMessageContext({ + primaryCtx: { + message: { + message_id: 12345, + chat: { id: -99, type: "supergroup", title: "Dev Chat" }, + date: 1700000000, + text: "hello", + from: { id: 42, first_name: "Alice" }, + }, + me: { id: 7, username: "bot" }, + } as never, + allMedia: [], + storeAllowFrom: [], + options: { messageIdOverride: "67890" }, + bot: { + api: { + sendChatAction: vi.fn(), + setMessageReaction: vi.fn(), + }, + } as never, + cfg: { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } }, + channels: { telegram: {} }, + messages: { groupChat: { mentionPatterns: [] } }, + } as never, + account: { accountId: "default" } as never, + historyLimit: 0, + groupHistories: new Map(), + dmPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + ackReactionScope: "off", + logger: { info: vi.fn() }, + resolveGroupActivation: () => undefined, + resolveGroupRequireMention: () => false, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: undefined, + }), + }); + + expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload?.MessageSid).toBe("67890"); + }); });