create-byan-agent 2.9.4 → 2.9.5
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/install/bin/byan-cleanup.js +156 -0
- package/install/bin/byan-kanban.js +159 -0
- package/install/bin/byan-ledger.js +45 -0
- package/install/lib/cleanup/detector.js +154 -0
- package/install/lib/cleanup/executor.js +72 -0
- package/install/lib/subagent-generator.js +208 -0
- package/install/lib/token-ledger.js +131 -0
- package/install/templates/.claude/agents/bmad-bmad-master.md +14 -0
- package/install/templates/.claude/agents/bmad-bmb-agent-builder.md +14 -0
- package/install/templates/.claude/agents/bmad-bmb-module-builder.md +14 -0
- package/install/templates/.claude/agents/bmad-bmb-workflow-builder.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-analyst.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-architect.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-dev.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-pm.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-quick-flow-solo-dev.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-quinn.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-sm.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-tech-writer.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-ux-designer.md +14 -0
- package/install/templates/.claude/agents/bmad-byan-v2.md +14 -0
- package/install/templates/.claude/agents/bmad-byan.md +152 -0
- package/install/templates/.claude/agents/bmad-carmack.md +14 -0
- package/install/templates/.claude/agents/bmad-cis-brainstorming-coach.md +14 -0
- package/install/templates/.claude/agents/bmad-cis-creative-problem-solver.md +14 -0
- package/install/templates/.claude/agents/bmad-cis-design-thinking-coach.md +14 -0
- package/install/templates/.claude/agents/bmad-cis-innovation-strategist.md +14 -0
- package/install/templates/.claude/agents/bmad-cis-presentation-master.md +14 -0
- package/install/templates/.claude/agents/bmad-cis-storyteller.md +14 -0
- package/install/templates/.claude/agents/bmad-claude.md +26 -0
- package/install/templates/.claude/agents/bmad-codex.md +26 -0
- package/install/templates/.claude/agents/bmad-compliance.md +68 -0
- package/install/templates/.claude/agents/bmad-drawio.md +25 -0
- package/install/templates/.claude/agents/bmad-expert-merise-agile.md +54 -0
- package/install/templates/.claude/agents/bmad-fact-checker.md +14 -0
- package/install/templates/.claude/agents/bmad-forgeron.md +14 -0
- package/install/templates/.claude/agents/bmad-hermes.md +59 -0
- package/install/templates/.claude/agents/bmad-marc.md +25 -0
- package/install/templates/.claude/agents/bmad-patnote.md +26 -0
- package/install/templates/.claude/agents/bmad-rachid.md +25 -0
- package/install/templates/.claude/agents/bmad-tao.md +14 -0
- package/install/templates/.claude/agents/bmad-tea-tea.md +14 -0
- package/install/templates/.claude/agents/bmad-yanstaller.md +47 -0
- package/install/templates/.claude/hooks/fact-check-absolutes.js +185 -0
- package/install/templates/.claude/hooks/fd-phase-guard.js +87 -0
- package/install/templates/.claude/hooks/fd-response-check.js +92 -0
- package/install/templates/.claude/hooks/lib/failure-detector.js +14 -0
- package/install/templates/.claude/hooks/pre-compact-save.js +148 -0
- package/install/templates/.claude/hooks/tool-failure-guard.js +6 -0
- package/install/templates/.claude/hooks/tool-transparency.js +4 -0
- package/install/templates/.claude/settings.json +23 -0
- package/install/templates/.claude/skills/byan-byan/SKILL.md +115 -163
- package/install/templates/.claude/skills/byan-orchestrate/SKILL.md +100 -0
- package/install/templates/.githooks/pre-commit +75 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/copilot.js +148 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/fd-state.js +163 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/kanban.js +226 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/peer-review.js +187 -0
- package/install/templates/_byan/mcp/byan-mcp-server/server.js +463 -0
- package/install/templates/detector.js +154 -0
- package/package.json +6 -7
- package/src/loadbalancer/capability-matrix.js +157 -0
- package/src/loadbalancer/config.js +141 -0
- package/src/loadbalancer/graceful-degradation.js +212 -0
- package/src/loadbalancer/health-probe.js +151 -0
- package/src/loadbalancer/hooks/claude-hooks.js +53 -0
- package/src/loadbalancer/hooks/copilot-hooks.js +74 -0
- package/src/loadbalancer/index.js +81 -0
- package/src/loadbalancer/loadbalancer.default.yaml +65 -0
- package/src/loadbalancer/loadbalancer.js +324 -0
- package/src/loadbalancer/mcp-server.js +304 -0
- package/src/loadbalancer/metrics.js +146 -0
- package/src/loadbalancer/native/claude-integration.js +64 -0
- package/src/loadbalancer/native/copilot-integration.js +59 -0
- package/src/loadbalancer/pressure-score.js +102 -0
- package/src/loadbalancer/providers/base-provider.js +80 -0
- package/src/loadbalancer/providers/byan-api-provider.js +132 -0
- package/src/loadbalancer/providers/claude-provider.js +113 -0
- package/src/loadbalancer/providers/copilot-provider.js +104 -0
- package/src/loadbalancer/rate-limit-tracker.js +216 -0
- package/src/loadbalancer/session-bridge.js +179 -0
- package/src/loadbalancer/state/db.js +211 -0
- package/src/loadbalancer/state/migrations/001-initial.sql +50 -0
- package/src/loadbalancer/tools/index.js +123 -0
- package/src/loadbalancer/velocity-estimator.js +147 -0
- package/update-byan-agent/bin/update-byan-agent.js +27 -2
- package/API-BYAN-V2.md +0 -741
- package/BMAD-QUICK-REFERENCE.md +0 -370
- package/CHANGELOG-v2.1.0.md +0 -371
- package/MIGRATION-v2.0-to-v2.1.md +0 -430
- package/README-BYAN-V2.md +0 -446
- package/TEST-GUIDE-v2.3.2.md +0 -161
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CapabilityMatrix — Provider Feature Parity Routing
|
|
3
|
+
*
|
|
4
|
+
* Maps which tools/capabilities each provider supports.
|
|
5
|
+
* Routes capability-specific tasks to the right provider.
|
|
6
|
+
* Degrades gracefully when target provider lacks a capability.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CAPABILITIES = {
|
|
10
|
+
claude: {
|
|
11
|
+
file_edit: true,
|
|
12
|
+
file_read: true,
|
|
13
|
+
bash: true,
|
|
14
|
+
web_search: false,
|
|
15
|
+
mcp_tools: true,
|
|
16
|
+
subagents: true,
|
|
17
|
+
git: true,
|
|
18
|
+
streaming: true,
|
|
19
|
+
multi_turn: true,
|
|
20
|
+
max_context_tokens: 200000,
|
|
21
|
+
tool_use: true,
|
|
22
|
+
image_input: true,
|
|
23
|
+
},
|
|
24
|
+
copilot: {
|
|
25
|
+
file_edit: true,
|
|
26
|
+
file_read: true,
|
|
27
|
+
bash: true,
|
|
28
|
+
web_search: true,
|
|
29
|
+
mcp_tools: true,
|
|
30
|
+
subagents: true,
|
|
31
|
+
git: true,
|
|
32
|
+
streaming: true,
|
|
33
|
+
multi_turn: true,
|
|
34
|
+
max_context_tokens: 128000,
|
|
35
|
+
tool_use: true,
|
|
36
|
+
image_input: true,
|
|
37
|
+
},
|
|
38
|
+
byan_api: {
|
|
39
|
+
file_edit: false,
|
|
40
|
+
file_read: false,
|
|
41
|
+
bash: false,
|
|
42
|
+
web_search: false,
|
|
43
|
+
mcp_tools: false,
|
|
44
|
+
subagents: false,
|
|
45
|
+
git: false,
|
|
46
|
+
streaming: false,
|
|
47
|
+
multi_turn: true,
|
|
48
|
+
max_context_tokens: 32000,
|
|
49
|
+
tool_use: false,
|
|
50
|
+
image_input: false,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
class CapabilityMatrix {
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} [overrides] - Per-provider capability overrides
|
|
57
|
+
*/
|
|
58
|
+
constructor(overrides = {}) {
|
|
59
|
+
this.matrix = {};
|
|
60
|
+
|
|
61
|
+
for (const [provider, caps] of Object.entries(DEFAULT_CAPABILITIES)) {
|
|
62
|
+
this.matrix[provider] = { ...caps, ...(overrides[provider] || {}) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const [provider, caps] of Object.entries(overrides)) {
|
|
66
|
+
if (!this.matrix[provider]) {
|
|
67
|
+
this.matrix[provider] = caps;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if a provider supports a specific capability.
|
|
74
|
+
* @param {string} provider
|
|
75
|
+
* @param {string} capability
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
supports(provider, capability) {
|
|
79
|
+
return !!(this.matrix[provider]?.[capability]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get all capabilities for a provider.
|
|
84
|
+
* @param {string} provider
|
|
85
|
+
* @returns {object|null}
|
|
86
|
+
*/
|
|
87
|
+
getCapabilities(provider) {
|
|
88
|
+
return this.matrix[provider] || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Find providers that support a required capability.
|
|
93
|
+
* @param {string} capability
|
|
94
|
+
* @returns {string[]} Provider names that support it
|
|
95
|
+
*/
|
|
96
|
+
findProviders(capability) {
|
|
97
|
+
return Object.entries(this.matrix)
|
|
98
|
+
.filter(([, caps]) => caps[capability])
|
|
99
|
+
.map(([name]) => name);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Find the best provider for a set of required capabilities.
|
|
104
|
+
* @param {string[]} required - Required capability names
|
|
105
|
+
* @param {string[]} preferOrder - Preferred provider order
|
|
106
|
+
* @returns {{ provider: string|null, supported: string[], missing: string[] }}
|
|
107
|
+
*/
|
|
108
|
+
findBestProvider(required, preferOrder = []) {
|
|
109
|
+
let bestMatch = null;
|
|
110
|
+
let bestScore = -1;
|
|
111
|
+
|
|
112
|
+
const candidates = preferOrder.length > 0
|
|
113
|
+
? preferOrder
|
|
114
|
+
: Object.keys(this.matrix);
|
|
115
|
+
|
|
116
|
+
for (const provider of candidates) {
|
|
117
|
+
const caps = this.matrix[provider];
|
|
118
|
+
if (!caps) continue;
|
|
119
|
+
|
|
120
|
+
const supported = required.filter(cap => caps[cap]);
|
|
121
|
+
const score = supported.length;
|
|
122
|
+
|
|
123
|
+
if (score > bestScore) {
|
|
124
|
+
bestScore = score;
|
|
125
|
+
bestMatch = {
|
|
126
|
+
provider,
|
|
127
|
+
supported,
|
|
128
|
+
missing: required.filter(cap => !caps[cap]),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (score === required.length) break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return bestMatch || { provider: null, supported: [], missing: required };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get a comparison table of all providers.
|
|
140
|
+
* @returns {object} { capabilities: string[], providers: { name: { cap: bool } } }
|
|
141
|
+
*/
|
|
142
|
+
compare() {
|
|
143
|
+
const allCaps = new Set();
|
|
144
|
+
for (const caps of Object.values(this.matrix)) {
|
|
145
|
+
for (const key of Object.keys(caps)) {
|
|
146
|
+
allCaps.add(key);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
capabilities: [...allCaps].sort(),
|
|
152
|
+
providers: { ...this.matrix },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { CapabilityMatrix, DEFAULT_CAPABILITIES };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LoadBalancer Configuration Loader
|
|
3
|
+
*
|
|
4
|
+
* Loads loadbalancer.yaml, merges with defaults, resolves env vars.
|
|
5
|
+
* Config resolution order:
|
|
6
|
+
* 1. loadbalancer.default.yaml (bundled defaults)
|
|
7
|
+
* 2. {project-root}/_byan/loadbalancer.yaml (user overrides)
|
|
8
|
+
* 3. Environment variables (BYAN_LB_PRIMARY, etc.)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const yaml = require('js-yaml');
|
|
14
|
+
|
|
15
|
+
const DEFAULT_CONFIG_PATH = path.join(__dirname, 'loadbalancer.default.yaml');
|
|
16
|
+
|
|
17
|
+
function deepMerge(target, source) {
|
|
18
|
+
const result = { ...target };
|
|
19
|
+
for (const key of Object.keys(source)) {
|
|
20
|
+
if (
|
|
21
|
+
source[key] &&
|
|
22
|
+
typeof source[key] === 'object' &&
|
|
23
|
+
!Array.isArray(source[key]) &&
|
|
24
|
+
target[key] &&
|
|
25
|
+
typeof target[key] === 'object' &&
|
|
26
|
+
!Array.isArray(target[key])
|
|
27
|
+
) {
|
|
28
|
+
result[key] = deepMerge(target[key], source[key]);
|
|
29
|
+
} else {
|
|
30
|
+
result[key] = source[key];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function loadYaml(filePath) {
|
|
37
|
+
if (!fs.existsSync(filePath)) return null;
|
|
38
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
39
|
+
return yaml.load(content);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveEnvOverrides(config) {
|
|
43
|
+
const envMap = {
|
|
44
|
+
BYAN_LB_PRIMARY: 'primary',
|
|
45
|
+
BYAN_LB_PORT: 'mcp_server.port',
|
|
46
|
+
BYAN_LB_HOST: 'mcp_server.host',
|
|
47
|
+
BYAN_LB_LOG_LEVEL: 'logging.level',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
for (const [envKey, configPath] of Object.entries(envMap)) {
|
|
51
|
+
const val = process.env[envKey];
|
|
52
|
+
if (val === undefined) continue;
|
|
53
|
+
|
|
54
|
+
const parts = configPath.split('.');
|
|
55
|
+
let target = config;
|
|
56
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
57
|
+
if (!target[parts[i]]) target[parts[i]] = {};
|
|
58
|
+
target = target[parts[i]];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const lastKey = parts[parts.length - 1];
|
|
62
|
+
target[lastKey] = isNaN(val) ? val : Number(val);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return config;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateConfig(config) {
|
|
69
|
+
const errors = [];
|
|
70
|
+
|
|
71
|
+
if (!config.providers || typeof config.providers !== 'object') {
|
|
72
|
+
errors.push('providers: must be an object with at least one provider');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!config.primary) {
|
|
76
|
+
errors.push('primary: must specify a primary provider');
|
|
77
|
+
} else if (!config.providers?.[config.primary]) {
|
|
78
|
+
errors.push(`primary: provider "${config.primary}" not found in providers`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!Array.isArray(config.fallback_order)) {
|
|
82
|
+
errors.push('fallback_order: must be an array');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const rl = config.rate_limits;
|
|
86
|
+
if (rl) {
|
|
87
|
+
if (typeof rl.window_ms !== 'number' || rl.window_ms <= 0) {
|
|
88
|
+
errors.push('rate_limits.window_ms: must be a positive number');
|
|
89
|
+
}
|
|
90
|
+
if (typeof rl.max_429_in_window !== 'number' || rl.max_429_in_window <= 0) {
|
|
91
|
+
errors.push('rate_limits.max_429_in_window: must be a positive number');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return errors;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function loadConfig(projectRoot) {
|
|
99
|
+
const defaults = loadYaml(DEFAULT_CONFIG_PATH) || {};
|
|
100
|
+
|
|
101
|
+
const userConfigPath = path.join(projectRoot, '_byan', 'loadbalancer.yaml');
|
|
102
|
+
const userConfig = loadYaml(userConfigPath) || {};
|
|
103
|
+
|
|
104
|
+
let config = deepMerge(defaults, userConfig);
|
|
105
|
+
config = resolveEnvOverrides(config);
|
|
106
|
+
|
|
107
|
+
const errors = validateConfig(config);
|
|
108
|
+
if (errors.length > 0) {
|
|
109
|
+
throw new Error(`LoadBalancer config validation failed:\n - ${errors.join('\n - ')}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
config._resolved = {
|
|
113
|
+
projectRoot,
|
|
114
|
+
configSources: [
|
|
115
|
+
DEFAULT_CONFIG_PATH,
|
|
116
|
+
...(fs.existsSync(userConfigPath) ? [userConfigPath] : []),
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return config;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getEnabledProviders(config) {
|
|
124
|
+
return Object.entries(config.providers)
|
|
125
|
+
.filter(([, p]) => p.enabled !== false)
|
|
126
|
+
.map(([name, p]) => ({ name, ...p }));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getProviderOrder(config) {
|
|
130
|
+
const order = [config.primary, ...config.fallback_order];
|
|
131
|
+
return order.filter(name => config.providers[name]?.enabled !== false);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
loadConfig,
|
|
136
|
+
validateConfig,
|
|
137
|
+
getEnabledProviders,
|
|
138
|
+
getProviderOrder,
|
|
139
|
+
deepMerge,
|
|
140
|
+
DEFAULT_CONFIG_PATH,
|
|
141
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GracefulDegradation — Queue + Backoff When All Providers Rate-Limited
|
|
3
|
+
*
|
|
4
|
+
* When no provider can accept requests:
|
|
5
|
+
* 1. Queue pending tasks by priority (P1 > P2 > P3)
|
|
6
|
+
* 2. Exponential backoff (1s, 2s, 4s, 8s... cap 60s)
|
|
7
|
+
* 3. Notify user with estimated wait time
|
|
8
|
+
* 4. Auto-resume when any provider recovers
|
|
9
|
+
*
|
|
10
|
+
* Integrates with LoadBalancer via event listeners.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { EventEmitter } = require('events');
|
|
14
|
+
|
|
15
|
+
const PRIORITY = { P1: 1, P2: 2, P3: 3, DEFAULT: 2 };
|
|
16
|
+
const MIN_BACKOFF_MS = 1000;
|
|
17
|
+
const MAX_BACKOFF_MS = 60000;
|
|
18
|
+
|
|
19
|
+
class GracefulDegradation extends EventEmitter {
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} opts
|
|
22
|
+
* @param {import('./loadbalancer').LoadBalancer} opts.lb
|
|
23
|
+
*/
|
|
24
|
+
constructor(opts = {}) {
|
|
25
|
+
super();
|
|
26
|
+
this.lb = opts.lb;
|
|
27
|
+
this.queue = [];
|
|
28
|
+
this.backoffMs = MIN_BACKOFF_MS;
|
|
29
|
+
this.retryTimer = null;
|
|
30
|
+
this.processing = false;
|
|
31
|
+
this.totalQueued = 0;
|
|
32
|
+
this.totalProcessed = 0;
|
|
33
|
+
this.totalDropped = 0;
|
|
34
|
+
|
|
35
|
+
if (this.lb) {
|
|
36
|
+
this.lb.on('auto_failover', () => this._onProviderRecovery());
|
|
37
|
+
this.lb.on('rate_limit_change', (event) => {
|
|
38
|
+
if (event.to === 'HEALTHY' || event.to === 'RECOVERING') {
|
|
39
|
+
this._onProviderRecovery();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Enqueue a request when all providers are down.
|
|
47
|
+
* @param {object} request - { prompt, sessionId, preferProvider, priority }
|
|
48
|
+
* @returns {{ queued: true, position: number, estimatedWaitMs: number }}
|
|
49
|
+
*/
|
|
50
|
+
enqueue(request) {
|
|
51
|
+
const priority = PRIORITY[request.priority] || PRIORITY.DEFAULT;
|
|
52
|
+
|
|
53
|
+
const entry = {
|
|
54
|
+
id: `q-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
55
|
+
request,
|
|
56
|
+
priority,
|
|
57
|
+
enqueuedAt: Date.now(),
|
|
58
|
+
resolve: null,
|
|
59
|
+
reject: null,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const promise = new Promise((resolve, reject) => {
|
|
63
|
+
entry.resolve = resolve;
|
|
64
|
+
entry.reject = reject;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
this.queue.push(entry);
|
|
68
|
+
this.queue.sort((a, b) => a.priority - b.priority || a.enqueuedAt - b.enqueuedAt);
|
|
69
|
+
this.totalQueued++;
|
|
70
|
+
|
|
71
|
+
const position = this.queue.indexOf(entry) + 1;
|
|
72
|
+
const estimatedWaitMs = this.backoffMs * position;
|
|
73
|
+
|
|
74
|
+
this.emit('enqueued', {
|
|
75
|
+
id: entry.id,
|
|
76
|
+
position,
|
|
77
|
+
estimatedWaitMs,
|
|
78
|
+
queueSize: this.queue.length,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
this._scheduleRetry();
|
|
82
|
+
|
|
83
|
+
entry.promise = promise;
|
|
84
|
+
return { queued: true, position, estimatedWaitMs, id: entry.id, promise };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get queue status.
|
|
89
|
+
*/
|
|
90
|
+
getStatus() {
|
|
91
|
+
return {
|
|
92
|
+
queueSize: this.queue.length,
|
|
93
|
+
backoffMs: this.backoffMs,
|
|
94
|
+
processing: this.processing,
|
|
95
|
+
totalQueued: this.totalQueued,
|
|
96
|
+
totalProcessed: this.totalProcessed,
|
|
97
|
+
totalDropped: this.totalDropped,
|
|
98
|
+
items: this.queue.map((e, i) => ({
|
|
99
|
+
id: e.id,
|
|
100
|
+
position: i + 1,
|
|
101
|
+
priority: e.priority,
|
|
102
|
+
waitingMs: Date.now() - e.enqueuedAt,
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Drop a queued item by ID.
|
|
109
|
+
*/
|
|
110
|
+
drop(id) {
|
|
111
|
+
const idx = this.queue.findIndex(e => e.id === id);
|
|
112
|
+
if (idx === -1) return false;
|
|
113
|
+
|
|
114
|
+
const entry = this.queue.splice(idx, 1)[0];
|
|
115
|
+
entry.reject(new Error('Request dropped from queue'));
|
|
116
|
+
this.totalDropped++;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Drop all queued items.
|
|
122
|
+
*/
|
|
123
|
+
dropAll() {
|
|
124
|
+
for (const entry of this.queue) {
|
|
125
|
+
entry.reject(new Error('Queue flushed'));
|
|
126
|
+
}
|
|
127
|
+
this.totalDropped += this.queue.length;
|
|
128
|
+
this.queue = [];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_scheduleRetry() {
|
|
132
|
+
if (this.retryTimer || this.processing) return;
|
|
133
|
+
|
|
134
|
+
this.retryTimer = setTimeout(() => {
|
|
135
|
+
this.retryTimer = null;
|
|
136
|
+
this._processQueue();
|
|
137
|
+
}, this.backoffMs);
|
|
138
|
+
|
|
139
|
+
if (this.retryTimer.unref) this.retryTimer.unref();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async _processQueue() {
|
|
143
|
+
if (this.queue.length === 0 || this.processing) return;
|
|
144
|
+
|
|
145
|
+
this.processing = true;
|
|
146
|
+
|
|
147
|
+
const entry = this.queue[0];
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const result = await this.lb.send(entry.request);
|
|
151
|
+
|
|
152
|
+
if (result.rateLimited && result.error) {
|
|
153
|
+
this._increaseBackoff();
|
|
154
|
+
this.emit('retry_failed', {
|
|
155
|
+
id: entry.id,
|
|
156
|
+
backoffMs: this.backoffMs,
|
|
157
|
+
queueSize: this.queue.length,
|
|
158
|
+
});
|
|
159
|
+
this._scheduleRetry();
|
|
160
|
+
} else {
|
|
161
|
+
this.queue.shift();
|
|
162
|
+
entry.resolve(result);
|
|
163
|
+
this.totalProcessed++;
|
|
164
|
+
this.backoffMs = MIN_BACKOFF_MS;
|
|
165
|
+
|
|
166
|
+
this.emit('processed', {
|
|
167
|
+
id: entry.id,
|
|
168
|
+
provider: result.provider,
|
|
169
|
+
queueRemaining: this.queue.length,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (this.queue.length > 0) {
|
|
173
|
+
this._scheduleRetry();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch (err) {
|
|
177
|
+
this._increaseBackoff();
|
|
178
|
+
this._scheduleRetry();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
this.processing = false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_onProviderRecovery() {
|
|
185
|
+
this.backoffMs = MIN_BACKOFF_MS;
|
|
186
|
+
|
|
187
|
+
if (this.retryTimer) {
|
|
188
|
+
clearTimeout(this.retryTimer);
|
|
189
|
+
this.retryTimer = null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (this.queue.length > 0) {
|
|
193
|
+
this.emit('recovery_detected', { queueSize: this.queue.length });
|
|
194
|
+
this._processQueue();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_increaseBackoff() {
|
|
199
|
+
this.backoffMs = Math.min(this.backoffMs * 2, MAX_BACKOFF_MS);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
destroy() {
|
|
203
|
+
if (this.retryTimer) {
|
|
204
|
+
clearTimeout(this.retryTimer);
|
|
205
|
+
this.retryTimer = null;
|
|
206
|
+
}
|
|
207
|
+
this.dropAll();
|
|
208
|
+
this.removeAllListeners();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = { GracefulDegradation, PRIORITY };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HealthProbe — Proactive Provider Availability Monitoring
|
|
3
|
+
*
|
|
4
|
+
* Periodically pings each provider with a cheap request to detect:
|
|
5
|
+
* - Complete unavailability (no response / error)
|
|
6
|
+
* - Silent degradation (response time > threshold)
|
|
7
|
+
* - Pre-throttle signals (rate limit headers on probe)
|
|
8
|
+
*
|
|
9
|
+
* Updates RateLimitTracker proactively before user hits a wall.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { EventEmitter } = require('events');
|
|
13
|
+
|
|
14
|
+
class HealthProbe extends EventEmitter {
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} opts
|
|
17
|
+
* @param {object} opts.providers - { name: ProviderAdapter } map
|
|
18
|
+
* @param {object} opts.trackers - { name: RateLimitTracker } map
|
|
19
|
+
* @param {object} [opts.config] - health_probe section from loadbalancer config
|
|
20
|
+
*/
|
|
21
|
+
constructor(opts) {
|
|
22
|
+
super();
|
|
23
|
+
this.providers = opts.providers;
|
|
24
|
+
this.trackers = opts.trackers || {};
|
|
25
|
+
this.config = opts.config || {};
|
|
26
|
+
|
|
27
|
+
this.intervalMs = this.config.interval_ms || 30000;
|
|
28
|
+
this.timeoutMs = this.config.timeout_ms || 5000;
|
|
29
|
+
this.enabled = this.config.enabled !== false;
|
|
30
|
+
|
|
31
|
+
this.timer = null;
|
|
32
|
+
this.probeResults = {};
|
|
33
|
+
this.running = false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Start periodic health probing.
|
|
38
|
+
*/
|
|
39
|
+
start() {
|
|
40
|
+
if (!this.enabled || this.running) return;
|
|
41
|
+
this.running = true;
|
|
42
|
+
|
|
43
|
+
this._probeAll();
|
|
44
|
+
|
|
45
|
+
this.timer = setInterval(() => this._probeAll(), this.intervalMs);
|
|
46
|
+
if (this.timer.unref) this.timer.unref();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Stop health probing.
|
|
51
|
+
*/
|
|
52
|
+
stop() {
|
|
53
|
+
this.running = false;
|
|
54
|
+
if (this.timer) {
|
|
55
|
+
clearInterval(this.timer);
|
|
56
|
+
this.timer = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get latest probe results for all providers.
|
|
62
|
+
*/
|
|
63
|
+
getResults() {
|
|
64
|
+
return { ...this.probeResults };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run a single probe against one provider.
|
|
69
|
+
* @param {string} name - Provider name
|
|
70
|
+
* @returns {Promise<object>} Probe result
|
|
71
|
+
*/
|
|
72
|
+
async probeOne(name) {
|
|
73
|
+
const provider = this.providers[name];
|
|
74
|
+
if (!provider) {
|
|
75
|
+
return { provider: name, available: false, error: 'Provider not found' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const start = Date.now();
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const available = await Promise.race([
|
|
82
|
+
provider.isAvailable(),
|
|
83
|
+
new Promise((_, reject) =>
|
|
84
|
+
setTimeout(() => reject(new Error('Probe timeout')), this.timeoutMs)
|
|
85
|
+
),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const latencyMs = Date.now() - start;
|
|
89
|
+
|
|
90
|
+
const result = {
|
|
91
|
+
provider: name,
|
|
92
|
+
available: !!available,
|
|
93
|
+
latencyMs,
|
|
94
|
+
degraded: latencyMs > this.timeoutMs * 0.8,
|
|
95
|
+
probedAt: new Date().toISOString(),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
this.probeResults[name] = result;
|
|
99
|
+
|
|
100
|
+
if (result.degraded && this.trackers[name]) {
|
|
101
|
+
this.trackers[name].recordHeaders({ 'x-ratelimit-remaining': '5' });
|
|
102
|
+
this.emit('degradation', { provider: name, latencyMs });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!result.available && this.trackers[name]) {
|
|
106
|
+
this.trackers[name].record429({ source: 'health_probe', reason: 'unavailable' });
|
|
107
|
+
this.emit('unavailable', { provider: name });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return result;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
const latencyMs = Date.now() - start;
|
|
113
|
+
const result = {
|
|
114
|
+
provider: name,
|
|
115
|
+
available: false,
|
|
116
|
+
latencyMs,
|
|
117
|
+
degraded: true,
|
|
118
|
+
error: err.message,
|
|
119
|
+
probedAt: new Date().toISOString(),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
this.probeResults[name] = result;
|
|
123
|
+
|
|
124
|
+
if (this.trackers[name]) {
|
|
125
|
+
this.trackers[name].record429({ source: 'health_probe', reason: err.message });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.emit('probe_error', { provider: name, error: err.message });
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async _probeAll() {
|
|
134
|
+
const names = Object.keys(this.providers);
|
|
135
|
+
const results = await Promise.allSettled(
|
|
136
|
+
names.map(name => this.probeOne(name))
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
this.emit('probe_cycle', {
|
|
140
|
+
results: results.map(r => r.value || r.reason),
|
|
141
|
+
timestamp: new Date().toISOString(),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
destroy() {
|
|
146
|
+
this.stop();
|
|
147
|
+
this.removeAllListeners();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { HealthProbe };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Hooks — Rate Limit Detection for Claude Agent SDK
|
|
3
|
+
*
|
|
4
|
+
* Integrates with Claude Agent SDK hooks (PostToolUseFailure, Stop)
|
|
5
|
+
* to detect rate limit errors and feed them to RateLimitTracker.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create Claude hook handlers that report rate limits to the tracker.
|
|
10
|
+
* @param {import('../rate-limit-tracker').RateLimitTracker} tracker
|
|
11
|
+
* @returns {object} Hook configuration for Claude Agent SDK
|
|
12
|
+
*/
|
|
13
|
+
function createClaudeHooks(tracker) {
|
|
14
|
+
return {
|
|
15
|
+
PostToolUseFailure: [
|
|
16
|
+
{
|
|
17
|
+
matcher: { toolName: '*' },
|
|
18
|
+
callback: (event) => {
|
|
19
|
+
const error = event.error || {};
|
|
20
|
+
const isRateLimit =
|
|
21
|
+
error.name === 'RateLimitError' ||
|
|
22
|
+
error.status === 429 ||
|
|
23
|
+
(error.message && error.message.includes('rate_limit'));
|
|
24
|
+
|
|
25
|
+
if (isRateLimit) {
|
|
26
|
+
const retryAfter = error.headers?.['retry-after'] || error.retryAfter;
|
|
27
|
+
tracker.record429({
|
|
28
|
+
source: 'claude-hook',
|
|
29
|
+
toolName: event.toolName,
|
|
30
|
+
retryAfter,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
Stop: [
|
|
38
|
+
{
|
|
39
|
+
matcher: {},
|
|
40
|
+
callback: (event) => {
|
|
41
|
+
const reason = event.reason || '';
|
|
42
|
+
if (reason.includes('rate_limit') || reason.includes('overloaded')) {
|
|
43
|
+
tracker.record429({ source: 'claude-stop', reason });
|
|
44
|
+
} else {
|
|
45
|
+
tracker.recordSuccess();
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { createClaudeHooks };
|