@code-yeongyu/senpi 2026.10.16-3 → 2026.10.16-4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/bundle/chunks/{chunk-I5ZJEXTM.js → chunk-2NO5ZOYE.js} +6 -6
- package/dist/bundle/chunks/{chunk-4R4PRTF5.js → chunk-CJRE5LLR.js} +1 -1
- package/dist/bundle/chunks/{chunk-X5VCHHXR.js → chunk-GY5O7NIQ.js} +1 -1
- package/dist/bundle/chunks/{chunk-AVALXFDW.js → chunk-JD4CV3GH.js} +1 -1
- package/dist/bundle/chunks/{chunk-SSEGIXRL.js → chunk-KUQ3K33L.js} +1 -1
- package/dist/bundle/chunks/{chunk-FMYLLJBQ.js → chunk-KWC3MCLP.js} +1 -1
- package/dist/bundle/chunks/chunk-MLL7AGVM.js +3 -0
- package/dist/bundle/chunks/chunk-PAJKEG2L.js +8 -0
- package/dist/bundle/chunks/{chunk-DPRV3FR7.js → chunk-PWHAOWNH.js} +1 -1
- package/dist/bundle/chunks/{chunk-HJPX7PBL.js → chunk-XCRGEMY2.js} +125 -118
- package/dist/bundle/chunks/{cli-main-JVPFJGP5.js → cli-main-I6JF2GPS.js} +1 -1
- package/dist/bundle/chunks/{help-fast-path-SVCMHCYG.js → help-fast-path-APSHUDY6.js} +1 -1
- package/dist/bundle/chunks/{host-session-command-46PTZGIB.js → host-session-command-BUJUJHDA.js} +1 -1
- package/dist/bundle/chunks/{interactive-mode-BROZRY34.js → interactive-mode-4Q3TIXOZ.js} +1 -1
- package/dist/bundle/chunks/{multi-session-host-SMQR5SCJ.js → multi-session-host-XEZB2XVR.js} +5 -4
- package/dist/bundle/chunks/{package-manager-cli-2QFJY2AS.js → package-manager-cli-YWZHOZOY.js} +1 -1
- package/dist/bundle/chunks/{rotation-stream-QX7YSKDU.js → rotation-stream-HY3JPQCX.js} +1 -1
- package/dist/bundle/chunks/{rpc-mode-Z45T3NZO.js → rpc-mode-SLWXVXXE.js} +1 -1
- package/dist/bundle/chunks/{schedule-command-2RHSWBQO.js → schedule-command-JRWZGEDU.js} +1 -1
- package/dist/bundle/chunks/{session-control-endpoint-H7FFWF2A.js → session-control-endpoint-E2ZSEWXW.js} +1 -1
- package/dist/bundle/chunks/session-picker-5IZY7E3G.js +2 -0
- package/dist/bundle/chunks/session-worker.js +243 -235
- package/dist/bundle/cli.js +2 -2
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/bundle/runtime-manifest.json +1 -1
- package/dist/core/agent-session-runtime.d.ts +15 -0
- package/dist/core/agent-session-runtime.js +53 -2
- package/dist/core/agent-session.d.ts +12 -0
- package/dist/core/agent-session.js +42 -1
- package/dist/core/context-files.d.ts +21 -0
- package/dist/core/context-files.js +175 -0
- package/dist/core/diagnostics.d.ts +1 -0
- package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +26 -3
- package/dist/core/extensions/builtin/anthropic-subscription/account-management.d.ts +5 -0
- package/dist/core/extensions/builtin/anthropic-subscription/account-management.js +40 -0
- package/dist/core/extensions/builtin/anthropic-subscription/accounts.d.ts +3 -0
- package/dist/core/extensions/builtin/anthropic-subscription/accounts.js +1 -1
- package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +1 -1
- package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +1 -0
- package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +10 -5
- package/dist/core/extensions/builtin/anthropic-subscription/failover.js +158 -17
- package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.d.ts +13 -0
- package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.js +89 -0
- package/dist/core/extensions/builtin/permission-system/always-scope.d.ts +34 -0
- package/dist/core/extensions/builtin/permission-system/always-scope.js +77 -0
- package/dist/core/extensions/builtin/permission-system/index.js +3 -1
- package/dist/core/extensions/builtin/permission-system/parsers.d.ts +1 -1
- package/dist/core/extensions/builtin/permission-system/parsers.js +24 -3
- package/dist/core/extensions/builtin/permission-system/prompt.js +5 -1
- package/dist/core/extensions/builtin/permission-system/service.js +1 -1
- package/dist/core/extensions/builtin/permission-system/types.d.ts +7 -0
- package/dist/core/package-manager.js +4 -32
- package/dist/core/project-trust.d.ts +8 -0
- package/dist/core/project-trust.js +12 -0
- package/dist/core/resource-loader.d.ts +5 -7
- package/dist/core/resource-loader.js +11 -84
- package/dist/core/retry-fallback/circuit-probes.d.ts +1 -0
- package/dist/core/retry-fallback/circuit-probes.js +3 -0
- package/dist/core/retry-fallback/circuit.d.ts +5 -0
- package/dist/core/retry-fallback/circuit.js +27 -4
- package/dist/core/retry-fallback/controller.d.ts +9 -4
- package/dist/core/retry-fallback/controller.js +21 -4
- package/dist/core/settings-manager.d.ts +15 -0
- package/dist/core/settings-manager.js +21 -1
- package/dist/core/skills-prompt.d.ts +18 -0
- package/dist/core/skills-prompt.js +131 -0
- package/dist/core/skills.d.ts +1 -9
- package/dist/core/skills.js +1 -59
- package/dist/core/trust-manager.d.ts +13 -4
- package/dist/core/trust-manager.js +52 -21
- package/dist/main.js +23 -4
- package/dist/modes/interactive/interactive-mode.js +10 -3
- package/dist/modes/rpc/abort-grace.d.ts +9 -0
- package/dist/modes/rpc/abort-grace.js +11 -0
- package/dist/modes/rpc/effect-commands.d.ts +0 -2
- package/dist/modes/rpc/effect-commands.js +2 -3
- package/dist/modes/rpc/host-handover-parked.d.ts +6 -0
- package/dist/modes/rpc/host-handover-parked.js +1 -0
- package/dist/modes/rpc/host-handover-reopen.js +11 -2
- package/dist/modes/rpc/rpc-client.d.ts +28 -2
- package/dist/modes/rpc/rpc-client.js +24 -11
- package/dist/modes/rpc/rpc-request-deadline.d.ts +12 -0
- package/dist/modes/rpc/rpc-request-deadline.js +15 -0
- package/dist/modes/rpc/session-command-router.js +7 -4
- package/dist/modes/rpc/session-registry-attach.js +6 -1
- package/dist/modes/rpc/session-registry-claims.d.ts +22 -2
- package/dist/modes/rpc/session-registry-claims.js +68 -0
- package/dist/modes/rpc/session-registry-opening.d.ts +23 -0
- package/dist/modes/rpc/session-registry-opening.js +57 -0
- package/dist/modes/rpc/session-registry.d.ts +1 -0
- package/dist/modes/rpc/session-registry.js +48 -60
- package/docs/rpc.md +6 -2
- package/docs/settings.md +13 -0
- package/package.json +6 -6
- package/dist/bundle/chunks/chunk-6MEETY7N.js +0 -8
- package/dist/bundle/chunks/chunk-TEGEPQIA.js +0 -3
- package/dist/bundle/chunks/session-picker-2O2BNBVE.js +0 -2
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, sep } from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { canonicalizePath, resolvePath } from "../utils/paths.js";
|
|
5
|
+
import { stripBom } from "../utils/text.js";
|
|
6
|
+
import { findGitPaths } from "./footer-data-provider.js";
|
|
7
|
+
export const DEFAULT_CONTEXT_FILE_MAX_BYTES = 64 * 1024;
|
|
8
|
+
export const DEFAULT_CONTEXT_FILES_MAX_TOTAL_BYTES = 128 * 1024;
|
|
9
|
+
const MIN_USEFUL_BYTES = 1024;
|
|
10
|
+
function loadContextFileFromDir(dir) {
|
|
11
|
+
const candidates = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
|
|
12
|
+
for (const filename of candidates) {
|
|
13
|
+
const filePath = join(dir, filename);
|
|
14
|
+
if (existsSync(filePath)) {
|
|
15
|
+
try {
|
|
16
|
+
if (!statSync(filePath).isFile()) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
path: filePath,
|
|
21
|
+
content: stripBom(readFileSync(filePath, "utf-8")),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The main repo's context file that a nested linked worktree's own copy shadows: both
|
|
33
|
+
* occupy the same logical repository scope, so loading both applies that context twice. Returns
|
|
34
|
+
* undefined when nothing is shadowed, leaving normal ancestor inheritance alone.
|
|
35
|
+
*
|
|
36
|
+
* Returned canonicalized (realpath), because `git worktree add` writes the `.git`
|
|
37
|
+
* file's `gitdir:` target in realpath form while cwd may still be symlinked
|
|
38
|
+
* (macOS `/tmp` -> `/private/tmp`).
|
|
39
|
+
*/
|
|
40
|
+
function findShadowedContextFile(cwd) {
|
|
41
|
+
const gitPaths = findGitPaths(cwd);
|
|
42
|
+
if (!gitPaths)
|
|
43
|
+
return undefined;
|
|
44
|
+
const commonGitDir = canonicalizePath(gitPaths.commonGitDir);
|
|
45
|
+
const worktreeRoot = canonicalizePath(gitPaths.repoDir);
|
|
46
|
+
const mainRepoRoot = dirname(commonGitDir);
|
|
47
|
+
// False for an ordinary repo, where the two are the same dir, and for a sibling
|
|
48
|
+
// worktree (`git worktree add ../feat`), whose main repo is not an ancestor.
|
|
49
|
+
if (!worktreeRoot.startsWith(`${mainRepoRoot}${sep}`))
|
|
50
|
+
return undefined;
|
|
51
|
+
// dirname of the common git dir is the main worktree root only when that dir is
|
|
52
|
+
// itself checked out from the same repo. In a bare layout (`proj/.bare` +
|
|
53
|
+
// `proj/main`) it is just the directory holding `.bare`, which tracks nothing; a
|
|
54
|
+
// submodule's gitdir has no `commondir`, so it lands under `.git/modules`.
|
|
55
|
+
if (canonicalizePath(join(mainRepoRoot, ".git")) !== commonGitDir)
|
|
56
|
+
return undefined;
|
|
57
|
+
const worktreeContextFile = loadContextFileFromDir(worktreeRoot);
|
|
58
|
+
return worktreeContextFile ? join(mainRepoRoot, basename(worktreeContextFile.path)) : undefined;
|
|
59
|
+
}
|
|
60
|
+
function kib(bytes) {
|
|
61
|
+
const value = bytes / 1024;
|
|
62
|
+
return Number.isInteger(value) ? `${value} KiB` : `${value.toFixed(1)} KiB`;
|
|
63
|
+
}
|
|
64
|
+
function utf8Prefix(content, maxBytes) {
|
|
65
|
+
if (maxBytes <= 0)
|
|
66
|
+
return "";
|
|
67
|
+
const bytes = Buffer.from(content, "utf8");
|
|
68
|
+
let end = Math.min(maxBytes, bytes.length);
|
|
69
|
+
while (end > 0 && end < bytes.length && ((bytes[end] ?? 0) & 0xc0) === 0x80)
|
|
70
|
+
end--;
|
|
71
|
+
const newline = bytes.lastIndexOf(10, end - 1);
|
|
72
|
+
if (newline >= maxBytes * 0.75)
|
|
73
|
+
end = newline + 1;
|
|
74
|
+
return bytes.subarray(0, end).toString("utf8");
|
|
75
|
+
}
|
|
76
|
+
function positiveLimit(value, fallback) {
|
|
77
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Bound what the context files add to every request. Files are given budget nearest-first
|
|
81
|
+
* (the cwd's file before its ancestors, the global file last), so the most specific
|
|
82
|
+
* instructions survive; every cut or skipped file is named in a warning.
|
|
83
|
+
*/
|
|
84
|
+
function applyContextFileLimits(files, limits) {
|
|
85
|
+
const perFile = positiveLimit(limits.maxBytesPerFile, DEFAULT_CONTEXT_FILE_MAX_BYTES);
|
|
86
|
+
const total = positiveLimit(limits.maxTotalBytes, DEFAULT_CONTEXT_FILES_MAX_TOTAL_BYTES);
|
|
87
|
+
const limitText = `limit ${kib(perFile)} per file, ${kib(total)} total; raise contextFiles.maxBytesPerFile / contextFiles.maxTotalBytes in settings to load more`;
|
|
88
|
+
const kept = [];
|
|
89
|
+
const diagnostics = [];
|
|
90
|
+
let remaining = total;
|
|
91
|
+
for (const file of [...files].reverse()) {
|
|
92
|
+
const size = Buffer.byteLength(file.content, "utf8");
|
|
93
|
+
const allowed = Math.min(perFile, remaining);
|
|
94
|
+
if (size <= allowed) {
|
|
95
|
+
kept.unshift(file);
|
|
96
|
+
remaining -= size;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (allowed < MIN_USEFUL_BYTES) {
|
|
100
|
+
const reason = perFile < MIN_USEFUL_BYTES ? "the per-file limit is too small" : "nearer context files used the budget";
|
|
101
|
+
diagnostics.push({
|
|
102
|
+
type: "warning",
|
|
103
|
+
category: "size-limit",
|
|
104
|
+
path: file.path,
|
|
105
|
+
message: `Context file (${kib(size)}) was not loaded: ${reason} (${limitText}).`,
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const note = `\n\n[This file was truncated from ${kib(size)}.]`;
|
|
110
|
+
const contentBudget = allowed - Buffer.byteLength(note);
|
|
111
|
+
let prefix = utf8Prefix(file.content, contentBudget);
|
|
112
|
+
let content;
|
|
113
|
+
while (true) {
|
|
114
|
+
let openFence;
|
|
115
|
+
for (const match of prefix.matchAll(/^[ \t]*(`{3,}|~{3,})([^\n]*)$/gm)) {
|
|
116
|
+
const marker = match[1];
|
|
117
|
+
if (!marker)
|
|
118
|
+
continue;
|
|
119
|
+
if (openFence === undefined)
|
|
120
|
+
openFence = marker;
|
|
121
|
+
else if (marker[0] === openFence[0] && marker.length >= openFence.length && match[2]?.trim() === "") {
|
|
122
|
+
openFence = undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const closingFence = openFence ? `${prefix.endsWith("\n") ? "" : "\n"}${openFence}\n` : "";
|
|
126
|
+
content = `${prefix}${closingFence}${note}`;
|
|
127
|
+
if (Buffer.byteLength(content) <= allowed)
|
|
128
|
+
break;
|
|
129
|
+
// A longer closing marker can expose another opener when the prefix shrinks.
|
|
130
|
+
prefix = utf8Prefix(prefix, Math.max(0, contentBudget - Buffer.byteLength(closingFence)));
|
|
131
|
+
}
|
|
132
|
+
const loaded = Buffer.byteLength(prefix, "utf8");
|
|
133
|
+
diagnostics.push({
|
|
134
|
+
type: "warning",
|
|
135
|
+
category: "size-limit",
|
|
136
|
+
path: file.path,
|
|
137
|
+
message: `Context file is ${kib(size)}; only the first ${kib(loaded)} was loaded (${limitText}).`,
|
|
138
|
+
});
|
|
139
|
+
kept.unshift({
|
|
140
|
+
path: file.path,
|
|
141
|
+
content,
|
|
142
|
+
});
|
|
143
|
+
remaining -= Buffer.byteLength(content);
|
|
144
|
+
}
|
|
145
|
+
return Object.assign(kept, { diagnostics });
|
|
146
|
+
}
|
|
147
|
+
export function loadProjectContextFiles(options) {
|
|
148
|
+
const resolvedCwd = resolvePath(options.cwd);
|
|
149
|
+
const resolvedAgentDir = resolvePath(options.agentDir);
|
|
150
|
+
const contextFiles = [];
|
|
151
|
+
const seenPaths = new Set();
|
|
152
|
+
const globalContext = loadContextFileFromDir(resolvedAgentDir);
|
|
153
|
+
if (globalContext) {
|
|
154
|
+
contextFiles.push(globalContext);
|
|
155
|
+
seenPaths.add(globalContext.path);
|
|
156
|
+
}
|
|
157
|
+
const ancestorContextFiles = [];
|
|
158
|
+
const shadowedContextFile = findShadowedContextFile(resolvedCwd);
|
|
159
|
+
let currentDir = resolvedCwd;
|
|
160
|
+
while (true) {
|
|
161
|
+
const contextFile = loadContextFileFromDir(currentDir);
|
|
162
|
+
const isShadowed = shadowedContextFile !== undefined && canonicalizePath(contextFile?.path ?? "") === shadowedContextFile;
|
|
163
|
+
if (contextFile && !isShadowed && !seenPaths.has(contextFile.path)) {
|
|
164
|
+
ancestorContextFiles.unshift(contextFile);
|
|
165
|
+
seenPaths.add(contextFile.path);
|
|
166
|
+
}
|
|
167
|
+
const parentDir = dirname(currentDir);
|
|
168
|
+
if (parentDir === currentDir)
|
|
169
|
+
break;
|
|
170
|
+
currentDir = parentDir;
|
|
171
|
+
}
|
|
172
|
+
contextFiles.push(...ancestorContextFiles);
|
|
173
|
+
return applyContextFileLimits(contextFiles, options);
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=context-files.js.map
|
|
@@ -3,7 +3,7 @@ import { describeModelBlocks } from "../../../credential-pool/model-scope.js";
|
|
|
3
3
|
import { accountDisplayNameCommand, promptAccountDisplayName } from "../account-display-name.js";
|
|
4
4
|
import { createExtensionLoginInteraction, LOGIN_CANCELLED_MESSAGE } from "../oauth-login-interaction.js";
|
|
5
5
|
import { emitProviderAccountsChanged } from "./account-events.js";
|
|
6
|
-
import { ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, pinProviderAccount, removeProviderAccount } from "./account-management.js";
|
|
6
|
+
import { ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, pinProviderAccount, removeProviderAccount, unblockProviderAccount, } from "./account-management.js";
|
|
7
7
|
import { emptyCredential, listAccounts } from "./accounts.js";
|
|
8
8
|
import { selectAccount } from "./affinity.js";
|
|
9
9
|
import { loadAnthropicSubscriptionProviderSettingsFromDisk, } from "./settings.js";
|
|
@@ -65,7 +65,7 @@ export function registerClaudeAccountCommand(pi, deps = {}) {
|
|
|
65
65
|
});
|
|
66
66
|
pi.registerCommand("claude-account", {
|
|
67
67
|
description: "List and manage Anthropic Subscription accounts.",
|
|
68
|
-
argumentHint: "[add | remove <id> | pin <id> | unpin | rename <id> <display name...> | clear-name <id>]",
|
|
68
|
+
argumentHint: "[add | remove <id> | pin <id> | unpin | unblock <id> | rename <id> <display name...> | clear-name <id>]",
|
|
69
69
|
requiresArguments: false,
|
|
70
70
|
handler: async (rawArgs, ctx) => {
|
|
71
71
|
if (await accountDisplayNameCommand(ctx, ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, rawArgs))
|
|
@@ -92,7 +92,11 @@ export function registerClaudeAccountCommand(pi, deps = {}) {
|
|
|
92
92
|
await unpinAccount(ctx);
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
if (action === "unblock") {
|
|
96
|
+
await unblockNamedAccount(ctx, args[1], environment);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
ctx.ui.notify("Usage: /claude-account [add | remove <id> | pin <id> | unpin | unblock <id> | rename <id> <display name...> | clear-name <id>]", "error");
|
|
96
100
|
},
|
|
97
101
|
});
|
|
98
102
|
}
|
|
@@ -194,6 +198,25 @@ async function pinNamedAccount(ctx, name, environment) {
|
|
|
194
198
|
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
195
199
|
}
|
|
196
200
|
}
|
|
201
|
+
async function unblockNamedAccount(ctx, name, environment) {
|
|
202
|
+
if (!name) {
|
|
203
|
+
ctx.ui.notify("Usage: /claude-account unblock <name>", "error");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (!readAccounts(ctx, environment).accounts.some((account) => account.name === name)) {
|
|
207
|
+
return accountError(ctx, name);
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const env = new Proxy({}, { get: (_target, key) => environment(String(key)) });
|
|
211
|
+
const lifted = await unblockProviderAccount(ctx.modelRegistry.authStorage, ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, name, env);
|
|
212
|
+
ctx.ui.notify(lifted
|
|
213
|
+
? `Unblocked Anthropic Subscription account: ${name}. The next turn may use it.`
|
|
214
|
+
: `Anthropic Subscription account '${name}' has no usage block to lift.`, "info");
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
197
220
|
async function unpinAccount(ctx) {
|
|
198
221
|
const credential = asCredential(ctx.modelRegistry.authStorage.get(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID));
|
|
199
222
|
if (!credential?.pinned) {
|
|
@@ -11,4 +11,9 @@ export type ProviderAccountSummary = {
|
|
|
11
11
|
export declare function getProviderAccounts(storage: AuthStorage, provider: string, env?: NodeJS.ProcessEnv): ProviderAccountSummary[];
|
|
12
12
|
export declare function pinProviderAccount(storage: AuthStorage, provider: string, name: string | null, env?: NodeJS.ProcessEnv): Promise<void>;
|
|
13
13
|
export declare function removeProviderAccount(storage: AuthStorage, provider: string, name: string): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Lifts a usage or rate-limit block the user knows is wrong (extra usage bought, a misread reset).
|
|
16
|
+
* Auth blocks stay: only a login or the re-probe can lift those. Returns whether a block was lifted.
|
|
17
|
+
*/
|
|
18
|
+
export declare function unblockProviderAccount(storage: AuthStorage, provider: string, name: string, env?: NodeJS.ProcessEnv): Promise<boolean>;
|
|
14
19
|
//# sourceMappingURL=account-management.d.ts.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { accountDisplayName } from "@earendil-works/pi-ai/auth/pool/slots";
|
|
2
2
|
import { emitProviderAccountsChanged } from "./account-events.js";
|
|
3
3
|
import { emptyCredential, listAccounts, pinAccount, removeAccount, } from "./accounts.js";
|
|
4
|
+
import { forgetSessionBlocksFor } from "./failover.js";
|
|
4
5
|
export const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
|
|
5
6
|
export function getProviderAccounts(storage, provider, env = process.env) {
|
|
6
7
|
assertManagedProvider(provider);
|
|
@@ -50,6 +51,45 @@ export async function removeProviderAccount(storage, provider, name) {
|
|
|
50
51
|
});
|
|
51
52
|
emitProviderAccountsChanged(provider);
|
|
52
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Lifts a usage or rate-limit block the user knows is wrong (extra usage bought, a misread reset).
|
|
56
|
+
* Auth blocks stay: only a login or the re-probe can lift those. Returns whether a block was lifted.
|
|
57
|
+
*/
|
|
58
|
+
export async function unblockProviderAccount(storage, provider, name, env = process.env) {
|
|
59
|
+
assertManagedProvider(provider);
|
|
60
|
+
let lifted = false;
|
|
61
|
+
await storage.modify(provider, async (current) => {
|
|
62
|
+
const credential = credentialFrom(current);
|
|
63
|
+
const account = listAccounts(credential, (key) => env[key]).find((candidate) => candidate.name === name);
|
|
64
|
+
if (!account)
|
|
65
|
+
throw new Error(`Provider account not found: ${name}`);
|
|
66
|
+
if (account.blockedUntil === undefined || account.blockReason === "auth_error")
|
|
67
|
+
return current;
|
|
68
|
+
lifted = true;
|
|
69
|
+
const state = credential.slotState?.[name];
|
|
70
|
+
return {
|
|
71
|
+
...credential,
|
|
72
|
+
...(state === undefined
|
|
73
|
+
? {}
|
|
74
|
+
: {
|
|
75
|
+
slotState: {
|
|
76
|
+
...credential.slotState,
|
|
77
|
+
[name]: state.modelBlocks ? { modelBlocks: state.modelBlocks } : {},
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
accounts: (credential.accounts ?? []).map((candidate) => {
|
|
81
|
+
if (candidate.name !== name)
|
|
82
|
+
return candidate;
|
|
83
|
+
const { blockedUntil: _blockedUntil, blockReason: _blockReason, blockedAt: _blockedAt, ...rest } = candidate;
|
|
84
|
+
return rest;
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
const sessionScoped = forgetSessionBlocksFor(name);
|
|
89
|
+
if (lifted || sessionScoped)
|
|
90
|
+
emitProviderAccountsChanged(provider);
|
|
91
|
+
return lifted || sessionScoped;
|
|
92
|
+
}
|
|
53
93
|
function assertManagedProvider(provider) {
|
|
54
94
|
if (provider !== ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
|
|
55
95
|
throw new Error(`Provider account management is unavailable for: ${provider}`);
|
|
@@ -10,6 +10,8 @@ export type AccountSlot = {
|
|
|
10
10
|
source: "login" | "import" | "env";
|
|
11
11
|
blockedUntil?: number;
|
|
12
12
|
blockReason?: string;
|
|
13
|
+
/** When the current block was stored, so a turn that started earlier never clears it (senpi#3462). */
|
|
14
|
+
blockedAt?: number;
|
|
13
15
|
/** Rate limits that bind one model family on this account, not the account (senpi#2555). */
|
|
14
16
|
modelBlocks?: ModelBlocks;
|
|
15
17
|
/**
|
|
@@ -23,6 +25,7 @@ export type AccountSlot = {
|
|
|
23
25
|
export type SlotState = Record<string, {
|
|
24
26
|
blockedUntil?: number;
|
|
25
27
|
blockReason?: string;
|
|
28
|
+
blockedAt?: number;
|
|
26
29
|
modelBlocks?: ModelBlocks;
|
|
27
30
|
}>;
|
|
28
31
|
export type AnthropicSubscriptionCredential = OAuthCredential & {
|
|
@@ -57,7 +57,7 @@ export function upsertAccount(credential, slot) {
|
|
|
57
57
|
const existing = storedSlots(credential).find((candidate) => candidate.name === slot.name);
|
|
58
58
|
if (!existing)
|
|
59
59
|
return { ...credential, accounts: [...storedSlots(credential), slot] };
|
|
60
|
-
const { blockedUntil: _blockedUntil, blockReason: _blockReason, modelBlocks: _modelBlocks, authRecoveryGrant: _authRecoveryGrant, ...identity } = existing;
|
|
60
|
+
const { blockedUntil: _blockedUntil, blockReason: _blockReason, blockedAt: _blockedAt, modelBlocks: _modelBlocks, authRecoveryGrant: _authRecoveryGrant, ...identity } = existing;
|
|
61
61
|
const refreshed = {
|
|
62
62
|
...identity,
|
|
63
63
|
access: slot.access,
|
|
@@ -47,7 +47,7 @@ export function clearExpiredBlocks(accounts, now = Date.now()) {
|
|
|
47
47
|
return accounts.map((account) => {
|
|
48
48
|
let available = account;
|
|
49
49
|
if (account.blockReason !== undefined && (account.blockedUntil === undefined || account.blockedUntil <= now)) {
|
|
50
|
-
const { blockedUntil: _blockedUntil, blockReason: _blockReason, ...rest } = account;
|
|
50
|
+
const { blockedUntil: _blockedUntil, blockReason: _blockReason, blockedAt: _blockedAt, ...rest } = account;
|
|
51
51
|
available = rest;
|
|
52
52
|
}
|
|
53
53
|
if (available.modelBlocks === undefined)
|
|
@@ -228,6 +228,7 @@ export async function* queryWithAuthLane(input) {
|
|
|
228
228
|
});
|
|
229
229
|
},
|
|
230
230
|
classify: classifySdkError,
|
|
231
|
+
...(input.sessionId === undefined ? {} : { sessionKey: input.sessionId }),
|
|
231
232
|
onLongContextRefused: () => markLongContextRefused(),
|
|
232
233
|
store: pool.store,
|
|
233
234
|
providerId: ANTHROPIC_SUBSCRIPTION_PROVIDER_ID,
|
|
@@ -3,7 +3,7 @@ import type { AccountSlot } from "./accounts.ts";
|
|
|
3
3
|
import type { SdkErrorClassification } from "./errors.ts";
|
|
4
4
|
export declare const MAX_RATE_LIMIT_BLOCK_MS: number;
|
|
5
5
|
export declare const DEFAULT_RATE_LIMIT_BLOCK_MS = 60000;
|
|
6
|
-
/** Same-account retries per turn for a transient failure (overload, network, server error)
|
|
6
|
+
/** Same-account retries per turn for a transient failure (overload, network, server error); it never rotates. */
|
|
7
7
|
export declare const TRANSIENT_RETRIES_PER_TURN = 2;
|
|
8
8
|
export declare const TRANSIENT_RETRY_DELAY_MS = 1000;
|
|
9
9
|
export declare const TURN_RETRY_SUPPRESSION_PREFIX = "senpi:no-turn-retry:";
|
|
@@ -33,7 +33,11 @@ export type FailoverOptions<TEvent> = {
|
|
|
33
33
|
signal?: AbortSignal;
|
|
34
34
|
/** Records a refused 1M window so the retry, built by `runAttempt`, requests the standard one. */
|
|
35
35
|
onLongContextRefused?: () => void;
|
|
36
|
+
/** The session this turn belongs to: blocks that bind only this session are remembered under it. */
|
|
37
|
+
sessionKey?: string;
|
|
36
38
|
};
|
|
39
|
+
/** `/claude-account unblock` lifts the account's session-scoped blocks in every session too. Returns whether one was lifted. */
|
|
40
|
+
export declare function forgetSessionBlocksFor(name: string): boolean;
|
|
37
41
|
export declare class ClassifiedSdkError extends Error {
|
|
38
42
|
readonly classification: SdkErrorClassification;
|
|
39
43
|
readonly original: unknown;
|
|
@@ -41,10 +45,11 @@ export declare class ClassifiedSdkError extends Error {
|
|
|
41
45
|
constructor(classification: SdkErrorClassification, original: unknown, suppressTurnRetry: boolean);
|
|
42
46
|
}
|
|
43
47
|
/**
|
|
44
|
-
* Runs one attempt per account, plus at most
|
|
45
|
-
* for a transient failure
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
+
* Runs one attempt per account for account-level failures (a limit, billing, auth), plus at most
|
|
49
|
+
* TRANSIENT_RETRIES_PER_TURN same-account retries for a transient failure, after which the turn fails on
|
|
50
|
+
* that account instead of rotating (and one retry on newer stored credentials). A retry is transparent only
|
|
51
|
+
* before a text, thinking, or tool-call event reaches the caller; post-delta failures are marked so
|
|
52
|
+
* AgentSession never replays the partial turn.
|
|
48
53
|
*/
|
|
49
54
|
export declare function runFailover<TEvent>(options: FailoverOptions<TEvent>): AsyncGenerator<TEvent>;
|
|
50
55
|
//# sourceMappingURL=failover.d.ts.map
|
|
@@ -1,13 +1,95 @@
|
|
|
1
1
|
import { AUTH_BLOCK_REPROBE_MS } from "../../../credential-pool/auth-reprobe.js";
|
|
2
|
-
import { activeModelBlockUntil, mergeModelBlocks, modelBlockKey, withModelBlock, } from "../../../credential-pool/model-scope.js";
|
|
2
|
+
import { activeModelBlockUntil, mergeModelBlocks, modelBlockKey, pruneModelBlocks, withModelBlock, } from "../../../credential-pool/model-scope.js";
|
|
3
3
|
import { usageLimitResetMs } from "../../../credential-pool/reset-time.js";
|
|
4
4
|
import { clearExpiredBlocks } from "./affinity.js";
|
|
5
|
+
import { isConfirmedAccountUsageLimit, usageLimitBlockMs } from "./usage-limit-block.js";
|
|
5
6
|
export const MAX_RATE_LIMIT_BLOCK_MS = 48 * 60 * 60 * 1_000;
|
|
6
7
|
export const DEFAULT_RATE_LIMIT_BLOCK_MS = 60_000;
|
|
7
|
-
/** Same-account retries per turn for a transient failure (overload, network, server error)
|
|
8
|
+
/** Same-account retries per turn for a transient failure (overload, network, server error); it never rotates. */
|
|
8
9
|
export const TRANSIENT_RETRIES_PER_TURN = 2;
|
|
9
10
|
export const TRANSIENT_RETRY_DELAY_MS = 1_000;
|
|
10
11
|
export const TURN_RETRY_SUPPRESSION_PREFIX = "senpi:no-turn-retry:";
|
|
12
|
+
/**
|
|
13
|
+
* Blocks that bind only the session that hit them, in memory for the process: a plain rate limit and a
|
|
14
|
+
* one-model limit. Another session on the same account keeps using it and finds out on its own request.
|
|
15
|
+
*/
|
|
16
|
+
const sessionBlocks = new Map();
|
|
17
|
+
/**
|
|
18
|
+
* Only a verdict that the account itself cannot serve anyone is shared through the credential store: a
|
|
19
|
+
* confirmed account-level usage limit, a billing or organization refusal, or a rejected token. Moving
|
|
20
|
+
* every session off the account is right then, since leaving them would retry a dead account. A plain
|
|
21
|
+
* rate limit, an overload or a one-model limit is not that verdict, so it never moves other sessions
|
|
22
|
+
* (senpi#3466).
|
|
23
|
+
*/
|
|
24
|
+
function isSharedVerdict(classification, error) {
|
|
25
|
+
if (classification.kind === "auth_error" ||
|
|
26
|
+
classification.kind === "billing" ||
|
|
27
|
+
classification.kind === "org_not_allowed")
|
|
28
|
+
return true;
|
|
29
|
+
return (classification.kind === "rate_limit" &&
|
|
30
|
+
classification.modelFamily === undefined &&
|
|
31
|
+
isConfirmedAccountUsageLimit(errorText(error)));
|
|
32
|
+
}
|
|
33
|
+
function rememberSessionBlock(sessionKey, account) {
|
|
34
|
+
if (sessionKey === undefined)
|
|
35
|
+
return;
|
|
36
|
+
const blocks = sessionBlocks.get(sessionKey) ?? new Map();
|
|
37
|
+
blocks.set(account.name, {
|
|
38
|
+
blockedUntil: account.blockedUntil,
|
|
39
|
+
blockReason: account.blockReason,
|
|
40
|
+
blockedAt: account.blockedAt,
|
|
41
|
+
modelBlocks: account.modelBlocks,
|
|
42
|
+
});
|
|
43
|
+
sessionBlocks.set(sessionKey, blocks);
|
|
44
|
+
}
|
|
45
|
+
/** Drops elapsed session blocks for every session, so sessions that ended leave nothing behind. */
|
|
46
|
+
function pruneSessionBlocks(now) {
|
|
47
|
+
for (const [sessionKey, blocks] of sessionBlocks) {
|
|
48
|
+
for (const [name, block] of blocks) {
|
|
49
|
+
if ((block.blockedUntil ?? 0) <= now && pruneModelBlocks(block.modelBlocks, now) === undefined)
|
|
50
|
+
blocks.delete(name);
|
|
51
|
+
}
|
|
52
|
+
if (blocks.size === 0)
|
|
53
|
+
sessionBlocks.delete(sessionKey);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function withSessionBlocks(accounts, sessionKey, now) {
|
|
57
|
+
pruneSessionBlocks(now);
|
|
58
|
+
const blocks = sessionKey === undefined ? undefined : sessionBlocks.get(sessionKey);
|
|
59
|
+
if (blocks === undefined)
|
|
60
|
+
return [...accounts];
|
|
61
|
+
return accounts.map((account) => {
|
|
62
|
+
const own = blocks.get(account.name);
|
|
63
|
+
if (own === undefined)
|
|
64
|
+
return account;
|
|
65
|
+
const later = (own.blockedUntil ?? 0) > (account.blockedUntil ?? 0);
|
|
66
|
+
return {
|
|
67
|
+
...account,
|
|
68
|
+
...(later ? { blockedUntil: own.blockedUntil, blockReason: own.blockReason, blockedAt: own.blockedAt } : {}),
|
|
69
|
+
modelBlocks: mergeModelBlocks(account.modelBlocks, own.modelBlocks, 0),
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function forgetSessionBlock(sessionKey, name) {
|
|
74
|
+
if (sessionKey === undefined)
|
|
75
|
+
return;
|
|
76
|
+
const blocks = sessionBlocks.get(sessionKey);
|
|
77
|
+
if (!blocks?.delete(name))
|
|
78
|
+
return;
|
|
79
|
+
if (blocks.size === 0)
|
|
80
|
+
sessionBlocks.delete(sessionKey);
|
|
81
|
+
}
|
|
82
|
+
/** `/claude-account unblock` lifts the account's session-scoped blocks in every session too. Returns whether one was lifted. */
|
|
83
|
+
export function forgetSessionBlocksFor(name) {
|
|
84
|
+
let lifted = false;
|
|
85
|
+
for (const [sessionKey, blocks] of [...sessionBlocks]) {
|
|
86
|
+
if (!blocks.has(name))
|
|
87
|
+
continue;
|
|
88
|
+
lifted = true;
|
|
89
|
+
forgetSessionBlock(sessionKey, name);
|
|
90
|
+
}
|
|
91
|
+
return lifted;
|
|
92
|
+
}
|
|
11
93
|
export class ClassifiedSdkError extends Error {
|
|
12
94
|
constructor(classification, original, suppressTurnRetry) {
|
|
13
95
|
const detail = original instanceof Error ? original.message : String(original);
|
|
@@ -53,7 +135,7 @@ function retryAfterMs(error) {
|
|
|
53
135
|
}
|
|
54
136
|
function blockedAccount(account, classification, now, attempt, baseBlockMs, error, model) {
|
|
55
137
|
if (classification.kind === "auth_error") {
|
|
56
|
-
return { ...account, blockedUntil: now + AUTH_BLOCK_REPROBE_MS, blockReason: "auth_error" };
|
|
138
|
+
return { ...account, blockedUntil: now + AUTH_BLOCK_REPROBE_MS, blockReason: "auth_error", blockedAt: now };
|
|
57
139
|
}
|
|
58
140
|
const fallback = Math.min(MAX_RATE_LIMIT_BLOCK_MS, baseBlockMs * 2 ** attempt);
|
|
59
141
|
if (classification.modelFamily !== undefined && model !== undefined) {
|
|
@@ -64,8 +146,45 @@ function blockedAccount(account, classification, now, attempt, baseBlockMs, erro
|
|
|
64
146
|
const key = modelBlockKey(classification.modelFamily, model);
|
|
65
147
|
return { ...account, modelBlocks: withModelBlock(account.modelBlocks, key, now + duration, now) };
|
|
66
148
|
}
|
|
67
|
-
|
|
68
|
-
|
|
149
|
+
// A spent account is out until its limit resets, capped at the limit's own window; a fixed
|
|
150
|
+
// short block sent a pinned account back to the same limit every minute (senpi#3462).
|
|
151
|
+
const hinted = retryAfterMs(error) ?? usageLimitBlockMs(errorText(error), account.name, now);
|
|
152
|
+
const duration = Math.min(MAX_RATE_LIMIT_BLOCK_MS, hinted ?? fallback);
|
|
153
|
+
return { ...account, blockedUntil: now + duration, blockReason: classification.kind, blockedAt: now };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* An account that just served a turn is not spent: a limit block stored before the turn started (a
|
|
157
|
+
* misread reset, a stale failure) is cleared so it cannot hold a working account out. A block stored
|
|
158
|
+
* after the turn started is newer evidence than this success and stays. Auth blocks keep their re-probe.
|
|
159
|
+
*/
|
|
160
|
+
async function clearBlockAfterSuccess(store, providerId, name, startedAt) {
|
|
161
|
+
const clearable = (slot) => slot?.blockedUntil !== undefined && slot.blockReason !== "auth_error" && (slot.blockedAt ?? 0) <= startedAt;
|
|
162
|
+
const stored = await store.read(providerId);
|
|
163
|
+
if (stored?.type !== "oauth")
|
|
164
|
+
return;
|
|
165
|
+
const read = stored;
|
|
166
|
+
const slot = read.accounts?.find((account) => account.name === name) ?? read.slotState?.[name];
|
|
167
|
+
if (!clearable(slot))
|
|
168
|
+
return;
|
|
169
|
+
await store.modify(providerId, async (current) => {
|
|
170
|
+
if (current?.type !== "oauth")
|
|
171
|
+
return current;
|
|
172
|
+
const credential = current;
|
|
173
|
+
const state = credential.slotState?.[name];
|
|
174
|
+
const slotState = state === undefined || !clearable(state)
|
|
175
|
+
? credential.slotState
|
|
176
|
+
: { ...credential.slotState, [name]: { ...(state.modelBlocks ? { modelBlocks: state.modelBlocks } : {}) } };
|
|
177
|
+
return {
|
|
178
|
+
...credential,
|
|
179
|
+
...(slotState === undefined ? {} : { slotState }),
|
|
180
|
+
accounts: (credential.accounts ?? []).map((account) => {
|
|
181
|
+
if (account.name !== name || !clearable(account))
|
|
182
|
+
return account;
|
|
183
|
+
const { blockedUntil: _blockedUntil, blockReason: _blockReason, blockedAt: _blockedAt, ...rest } = account;
|
|
184
|
+
return rest;
|
|
185
|
+
}),
|
|
186
|
+
};
|
|
187
|
+
});
|
|
69
188
|
}
|
|
70
189
|
function isTransient(classification) {
|
|
71
190
|
return classification.kind === "overloaded" || classification.kind === "other";
|
|
@@ -111,6 +230,7 @@ async function persistBlock(store, providerId, account, now) {
|
|
|
111
230
|
[account.name]: {
|
|
112
231
|
blockedUntil: account.blockedUntil,
|
|
113
232
|
blockReason: account.blockReason,
|
|
233
|
+
...(account.blockedAt === undefined ? {} : { blockedAt: account.blockedAt }),
|
|
114
234
|
// Union with what a concurrent request stored meanwhile, later expiry winning.
|
|
115
235
|
modelBlocks: mergeModelBlocks(credential.slotState?.[account.name]?.modelBlocks, account.modelBlocks, now),
|
|
116
236
|
},
|
|
@@ -129,6 +249,7 @@ async function persistBlock(store, providerId, account, now) {
|
|
|
129
249
|
...existing,
|
|
130
250
|
blockedUntil: account.blockedUntil,
|
|
131
251
|
blockReason: account.blockReason,
|
|
252
|
+
...(account.blockedAt === undefined ? {} : { blockedAt: account.blockedAt }),
|
|
132
253
|
modelBlocks: mergeModelBlocks(existing.modelBlocks, account.modelBlocks, now),
|
|
133
254
|
}
|
|
134
255
|
: existing);
|
|
@@ -142,15 +263,16 @@ function usable(account, now, model) {
|
|
|
142
263
|
activeModelBlockUntil(account.modelBlocks, model, now) === undefined);
|
|
143
264
|
}
|
|
144
265
|
/**
|
|
145
|
-
* Runs one attempt per account, plus at most
|
|
146
|
-
* for a transient failure
|
|
147
|
-
*
|
|
148
|
-
*
|
|
266
|
+
* Runs one attempt per account for account-level failures (a limit, billing, auth), plus at most
|
|
267
|
+
* TRANSIENT_RETRIES_PER_TURN same-account retries for a transient failure, after which the turn fails on
|
|
268
|
+
* that account instead of rotating (and one retry on newer stored credentials). A retry is transparent only
|
|
269
|
+
* before a text, thinking, or tool-call event reaches the caller; post-delta failures are marked so
|
|
270
|
+
* AgentSession never replays the partial turn.
|
|
149
271
|
*/
|
|
150
272
|
export async function* runFailover(options) {
|
|
151
273
|
const now = options.now ?? Date.now;
|
|
152
274
|
const baseBlockMs = options.baseBlockMs ?? DEFAULT_RATE_LIMIT_BLOCK_MS;
|
|
153
|
-
let accounts = clearExpiredBlocks(options.accounts, now());
|
|
275
|
+
let accounts = clearExpiredBlocks(withSessionBlocks(options.accounts, options.sessionKey, now()), now());
|
|
154
276
|
let lastError;
|
|
155
277
|
const retriedOnStoredMaterial = new Set();
|
|
156
278
|
let transientRetriesUsed = 0;
|
|
@@ -163,6 +285,8 @@ export async function* runFailover(options) {
|
|
|
163
285
|
retryAccountName = undefined;
|
|
164
286
|
const account = { ...(retrySlot ?? options.selectFn(accounts)) };
|
|
165
287
|
let visibleDeltaEmitted = false;
|
|
288
|
+
let completed = false;
|
|
289
|
+
const startedAt = now();
|
|
166
290
|
try {
|
|
167
291
|
const attemptStream = await options.runAttempt(account);
|
|
168
292
|
for await (const event of attemptStream) {
|
|
@@ -172,7 +296,7 @@ export async function* runFailover(options) {
|
|
|
172
296
|
visibleDeltaEmitted ||= (options.isVisibleDelta ?? defaultIsVisibleDelta)(event);
|
|
173
297
|
yield event;
|
|
174
298
|
}
|
|
175
|
-
|
|
299
|
+
completed = true;
|
|
176
300
|
}
|
|
177
301
|
catch (error) {
|
|
178
302
|
const classification = options.classify(error);
|
|
@@ -191,11 +315,14 @@ export async function* runFailover(options) {
|
|
|
191
315
|
attempt--;
|
|
192
316
|
continue;
|
|
193
317
|
}
|
|
194
|
-
// A transient failure says nothing about the account
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
318
|
+
// A transient failure says nothing about the account: it is retried in place a bounded
|
|
319
|
+
// number of times, then the turn fails on this account and the session's own retry and
|
|
320
|
+
// model fallback take over. It never blocks or rotates the account: moving to another
|
|
321
|
+
// account writes the whole conversation to a cold prompt cache, and a persisted block would
|
|
322
|
+
// move every other session off a healthy account too (senpi#2891, senpi#3466).
|
|
323
|
+
if (isTransient(classification)) {
|
|
324
|
+
if (visibleDeltaEmitted || transientRetriesUsed >= TRANSIENT_RETRIES_PER_TURN)
|
|
325
|
+
throw classified;
|
|
199
326
|
const delayMs = TRANSIENT_RETRY_DELAY_MS * 2 ** transientRetriesUsed;
|
|
200
327
|
transientRetriesUsed += 1;
|
|
201
328
|
try {
|
|
@@ -209,7 +336,12 @@ export async function* runFailover(options) {
|
|
|
209
336
|
continue;
|
|
210
337
|
}
|
|
211
338
|
const blocked = blockedAccount(account, classification, now(), attempt, baseBlockMs, error, options.model);
|
|
212
|
-
|
|
339
|
+
// With no session to hold it, a block is shared as before: dropping it would re-send every
|
|
340
|
+
// sessionless request to the spent account until its window resets.
|
|
341
|
+
const shared = isSharedVerdict(classification, error) || options.sessionKey === undefined;
|
|
342
|
+
if (!shared)
|
|
343
|
+
rememberSessionBlock(options.sessionKey, blocked);
|
|
344
|
+
const superseded = shared ? await persistBlock(options.store, options.providerId, blocked, now()) : undefined;
|
|
213
345
|
if (superseded &&
|
|
214
346
|
!visibleDeltaEmitted &&
|
|
215
347
|
usable(superseded, now(), options.model) &&
|
|
@@ -239,6 +371,15 @@ export async function* runFailover(options) {
|
|
|
239
371
|
if (visibleDeltaEmitted)
|
|
240
372
|
throw classified;
|
|
241
373
|
}
|
|
374
|
+
if (completed) {
|
|
375
|
+
forgetSessionBlock(options.sessionKey, account.name);
|
|
376
|
+
// Outside the attempt's try: clearing is best-effort hygiene after a finished turn, so a store
|
|
377
|
+
// failure is logged and the block stays for the next success; it never fails or replays the turn.
|
|
378
|
+
await clearBlockAfterSuccess(options.store, options.providerId, account.name, startedAt).catch((error) => {
|
|
379
|
+
process.stderr.write(`senpi anthropic-subscription: could not clear a stored block on ${account.name}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
380
|
+
});
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
242
383
|
}
|
|
243
384
|
throw lastError ?? new Error("Anthropic Subscription failover exhausted without an attempt");
|
|
244
385
|
}
|