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
This commit is contained in:
@@ -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.
|
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||||
|
|
||||||
### Fixes
|
### 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.
|
- 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.
|
- 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.
|
- Config: avoid stack traces for invalid configs and log the config path.
|
||||||
|
|||||||
@@ -248,4 +248,143 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
|||||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
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<string, { lastUsed?: number; cooldownUntil?: number }> };
|
||||||
|
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<string, { lastUsed?: number }> };
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { resolveUserPath } from "../../utils.js";
|
|||||||
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
|
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
|
||||||
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
||||||
import {
|
import {
|
||||||
|
isProfileInCooldown,
|
||||||
markAuthProfileFailure,
|
markAuthProfileFailure,
|
||||||
markAuthProfileGood,
|
markAuthProfileGood,
|
||||||
markAuthProfileUsed,
|
markAuthProfileUsed,
|
||||||
@@ -196,6 +197,10 @@ export async function runEmbeddedPiAgent(
|
|||||||
let nextIndex = profileIndex + 1;
|
let nextIndex = profileIndex + 1;
|
||||||
while (nextIndex < profileCandidates.length) {
|
while (nextIndex < profileCandidates.length) {
|
||||||
const candidate = profileCandidates[nextIndex];
|
const candidate = profileCandidates[nextIndex];
|
||||||
|
if (candidate && isProfileInCooldown(authStore, candidate)) {
|
||||||
|
nextIndex += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await applyApiKeyInfo(candidate);
|
await applyApiKeyInfo(candidate);
|
||||||
profileIndex = nextIndex;
|
profileIndex = nextIndex;
|
||||||
@@ -211,7 +216,18 @@ export async function runEmbeddedPiAgent(
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
if (profileCandidates[profileIndex] === lockedProfileId) throw err;
|
if (profileCandidates[profileIndex] === lockedProfileId) throw err;
|
||||||
const advanced = await advanceAuthProfile();
|
const advanced = await advanceAuthProfile();
|
||||||
|
|||||||
@@ -49,4 +49,96 @@ describe("buildTelegramMessageContext sender prefix", () => {
|
|||||||
const body = ctx?.ctxPayload?.Body ?? "";
|
const body = ctx?.ctxPayload?.Body ?? "";
|
||||||
expect(body).toContain("Alice (42): hello");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user