@iamoberlin/chorus 2.2.1 → 2.3.0
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/README.md +6 -0
- package/index.ts +39 -79
- package/openclaw.plugin.json +74 -1
- package/package.json +1 -1
- package/src/choirs.ts +51 -23
- package/src/config.ts +91 -0
- package/src/daemon.ts +18 -13
- package/src/delivery.ts +96 -0
- package/src/economics.ts +166 -0
- package/src/purpose-research.ts +78 -24
- package/src/scheduler.ts +88 -66
package/src/scheduler.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
import type { OpenClawPluginService, PluginLogger } from "openclaw/plugin-sdk";
|
|
9
9
|
import type { ChorusConfig } from "./config.js";
|
|
10
10
|
import { CHOIRS, shouldRunChoir, CASCADE_ORDER, type Choir } from "./choirs.js";
|
|
11
|
+
import { deliverChoirOutput } from "./delivery.js";
|
|
11
12
|
import { recordExecution, type ChoirExecution } from "./metrics.js";
|
|
13
|
+
import { recordCost, estimateCost } from "./economics.js";
|
|
12
14
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
13
15
|
import { join } from "path";
|
|
14
16
|
import { homedir } from "os";
|
|
@@ -22,8 +24,8 @@ interface AgentTurnResult {
|
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
// ── Delivery ─────────────────────────────────────────────────
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
+
// Delivery is handled via shared channel adapter to keep behavior consistent
|
|
28
|
+
// across scheduler and manual CLI execution paths.
|
|
27
29
|
|
|
28
30
|
interface ChoirContext {
|
|
29
31
|
choirId: string;
|
|
@@ -41,6 +43,29 @@ interface ChoirRunState {
|
|
|
41
43
|
const CHORUS_DIR = join(homedir(), ".chorus");
|
|
42
44
|
const RUN_STATE_PATH = join(CHORUS_DIR, "run-state.json");
|
|
43
45
|
|
|
46
|
+
// ── 429 Circuit Breaker ──────────────────────────────────────
|
|
47
|
+
// Exponential backoff on rate limit errors. Skips choir execution
|
|
48
|
+
// when backing off, resets on successful runs.
|
|
49
|
+
interface CircuitBreakerState {
|
|
50
|
+
backoffUntil: number; // timestamp ms — skip all choirs until this time
|
|
51
|
+
consecutiveFailures: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const MAX_BACKOFF_MS = 16 * 60 * 1000; // 16 minutes cap
|
|
55
|
+
const BASE_BACKOFF_MS = 60 * 1000; // 1 minute base
|
|
56
|
+
|
|
57
|
+
function isRateLimitError(err: unknown): boolean {
|
|
58
|
+
const msg = String(err).toLowerCase();
|
|
59
|
+
return msg.includes("429") || msg.includes("rate limit") || msg.includes("rate_limit") || msg.includes("too many requests");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function computeBackoffMs(failures: number): number {
|
|
63
|
+
const ms = BASE_BACKOFF_MS * Math.pow(2, Math.min(failures - 1, 4));
|
|
64
|
+
// Add jitter: ±25%
|
|
65
|
+
const jitter = ms * 0.25 * (Math.random() * 2 - 1);
|
|
66
|
+
return Math.min(ms + jitter, MAX_BACKOFF_MS);
|
|
67
|
+
}
|
|
68
|
+
|
|
44
69
|
// Load persisted run state from disk
|
|
45
70
|
function loadRunState(log: PluginLogger): Map<string, ChoirRunState> {
|
|
46
71
|
const state = new Map<string, ChoirRunState>();
|
|
@@ -106,12 +131,15 @@ export function createChoirScheduler(
|
|
|
106
131
|
// Load persisted state instead of starting fresh
|
|
107
132
|
const runState = loadRunState(log);
|
|
108
133
|
|
|
134
|
+
// Circuit breaker state
|
|
135
|
+
const circuitBreaker: CircuitBreakerState = { backoffUntil: 0, consecutiveFailures: 0 };
|
|
136
|
+
|
|
109
137
|
// CLI fallback for executing choirs when plugin API is unavailable
|
|
110
138
|
async function executeChoirViaCli(choir: Choir, prompt: string): Promise<string> {
|
|
111
139
|
const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => {
|
|
112
140
|
const child = spawn('openclaw', [
|
|
113
141
|
'agent',
|
|
114
|
-
'--session-id', `chorus
|
|
142
|
+
'--session-id', `chorus-${choir.id}`,
|
|
115
143
|
'--message', prompt,
|
|
116
144
|
'--json',
|
|
117
145
|
], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
@@ -190,7 +218,7 @@ export function createChoirScheduler(
|
|
|
190
218
|
if (typeof api.runAgentTurn === 'function') {
|
|
191
219
|
try {
|
|
192
220
|
const result: AgentTurnResult = await api.runAgentTurn({
|
|
193
|
-
sessionLabel: `chorus
|
|
221
|
+
sessionLabel: `chorus-${choir.id}`,
|
|
194
222
|
message: prompt,
|
|
195
223
|
isolated: true,
|
|
196
224
|
timeoutSeconds: 300,
|
|
@@ -208,6 +236,17 @@ export function createChoirScheduler(
|
|
|
208
236
|
output = result.text;
|
|
209
237
|
}
|
|
210
238
|
} catch (apiErr) {
|
|
239
|
+
if (isRateLimitError(apiErr)) {
|
|
240
|
+
circuitBreaker.consecutiveFailures++;
|
|
241
|
+
const backoffMs = computeBackoffMs(circuitBreaker.consecutiveFailures);
|
|
242
|
+
circuitBreaker.backoffUntil = Date.now() + backoffMs;
|
|
243
|
+
log.warn(`[chorus] ⚡ 429 RATE LIMIT on ${choir.name} — backing off ${(backoffMs/1000).toFixed(0)}s (failure #${circuitBreaker.consecutiveFailures})`);
|
|
244
|
+
execution.durationMs = Date.now() - startTime;
|
|
245
|
+
execution.success = false;
|
|
246
|
+
execution.error = "429_rate_limit_backoff";
|
|
247
|
+
recordExecution(execution);
|
|
248
|
+
return; // Skip this choir and remaining choirs will be skipped by checkAndRunChoirs
|
|
249
|
+
}
|
|
211
250
|
log.warn(`[chorus] API runAgentTurn failed for ${choir.name}, falling back to CLI: ${apiErr}`);
|
|
212
251
|
output = await executeChoirViaCli(choir, prompt);
|
|
213
252
|
}
|
|
@@ -221,6 +260,21 @@ export function createChoirScheduler(
|
|
|
221
260
|
execution.outputLength = output.length;
|
|
222
261
|
execution.tokensUsed = estimateTokens(output);
|
|
223
262
|
|
|
263
|
+
// Record economics cost (only for runs that produced output)
|
|
264
|
+
if (execution.success) {
|
|
265
|
+
const inputTokensEst = Math.ceil(prompt.length / 4);
|
|
266
|
+
const outputTokensEst = execution.tokensUsed || Math.ceil(output.length / 4);
|
|
267
|
+
recordCost({
|
|
268
|
+
choirId: choir.id,
|
|
269
|
+
timestamp: new Date().toISOString(),
|
|
270
|
+
inputTokens: inputTokensEst,
|
|
271
|
+
outputTokens: outputTokensEst,
|
|
272
|
+
model: "default",
|
|
273
|
+
inferenceMs: execution.durationMs,
|
|
274
|
+
costUsd: estimateCost(inputTokensEst, outputTokensEst),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
224
278
|
// Parse output for metrics (findings, alerts, improvements)
|
|
225
279
|
execution.findings = countFindings(output);
|
|
226
280
|
execution.alerts = countAlerts(output);
|
|
@@ -243,69 +297,17 @@ export function createChoirScheduler(
|
|
|
243
297
|
// Persist state to disk after each run
|
|
244
298
|
saveRunState(runState, log);
|
|
245
299
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
let target: string | undefined;
|
|
253
|
-
let channel: string | undefined;
|
|
254
|
-
|
|
255
|
-
if (channels) {
|
|
256
|
-
for (const [ch, cfg] of Object.entries(channels)) {
|
|
257
|
-
if (cfg?.enabled && cfg?.allowFrom?.[0]) {
|
|
258
|
-
target = cfg.allowFrom[0];
|
|
259
|
-
channel = ch;
|
|
260
|
-
break;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
300
|
+
// Reset circuit breaker on success
|
|
301
|
+
if (circuitBreaker.consecutiveFailures > 0) {
|
|
302
|
+
log.info(`[chorus] ✅ Circuit breaker reset after ${circuitBreaker.consecutiveFailures} failures`);
|
|
303
|
+
circuitBreaker.consecutiveFailures = 0;
|
|
304
|
+
circuitBreaker.backoffUntil = 0;
|
|
305
|
+
}
|
|
264
306
|
|
|
265
|
-
|
|
266
|
-
// Strip markdown for channels that don't support it
|
|
267
|
-
let deliveryText = output.slice(0, 4000);
|
|
268
|
-
if (channel === 'imessage') {
|
|
269
|
-
deliveryText = deliveryText
|
|
270
|
-
.replace(/\*\*(.+?)\*\*/g, '$1') // bold
|
|
271
|
-
.replace(/\*(.+?)\*/g, '$1') // italic
|
|
272
|
-
.replace(/__(.+?)__/g, '$1') // bold alt
|
|
273
|
-
.replace(/_(.+?)_/g, '$1') // italic alt
|
|
274
|
-
.replace(/`(.+?)`/g, '$1') // inline code
|
|
275
|
-
.replace(/```[\s\S]*?```/g, '') // code blocks
|
|
276
|
-
.replace(/^#{1,6}\s+/gm, '') // headers
|
|
277
|
-
.replace(/^\s*[-*+]\s+/gm, '• ') // bullet lists
|
|
278
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // links
|
|
279
|
-
}
|
|
307
|
+
log.info(`[chorus] ${choir.emoji} ${choir.name} completed (${(execution.durationMs/1000).toFixed(1)}s)`);
|
|
280
308
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
'message', 'send',
|
|
284
|
-
'--target', target,
|
|
285
|
-
'--message', deliveryText,
|
|
286
|
-
];
|
|
287
|
-
if (channel) args.push('--channel', channel);
|
|
288
|
-
|
|
289
|
-
const deliveryProc = spawn('openclaw', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
290
|
-
|
|
291
|
-
deliveryProc.on('close', (code) => {
|
|
292
|
-
if (code === 0) {
|
|
293
|
-
log.info(`[chorus] 📨 ${choir.name} output delivered via ${channel || 'default'}`);
|
|
294
|
-
} else {
|
|
295
|
-
log.warn(`[chorus] ⚠ ${choir.name} delivery failed (exit ${code})`);
|
|
296
|
-
}
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
deliveryProc.on('error', (err) => {
|
|
300
|
-
log.warn(`[chorus] ⚠ ${choir.name} delivery error: ${err.message}`);
|
|
301
|
-
});
|
|
302
|
-
} catch (deliveryErr) {
|
|
303
|
-
log.warn(`[chorus] ⚠ ${choir.name} delivery error: ${deliveryErr}`);
|
|
304
|
-
}
|
|
305
|
-
} else {
|
|
306
|
-
log.warn(`[chorus] ⚠ No delivery target found in OpenClaw config for ${choir.name}`);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
+
// Deliver output via shared adapter for consistent routing/format behavior
|
|
310
|
+
await deliverChoirOutput(api, choir, output, log);
|
|
309
311
|
|
|
310
312
|
// Log illumination flow
|
|
311
313
|
if (choir.passesTo.length > 0) {
|
|
@@ -380,10 +382,20 @@ export function createChoirScheduler(
|
|
|
380
382
|
return improvements.slice(0, 5); // Cap at 5
|
|
381
383
|
}
|
|
382
384
|
|
|
385
|
+
// Track choirs currently executing to prevent duplicate runs
|
|
386
|
+
const executing = new Set<string>();
|
|
387
|
+
|
|
383
388
|
// Check and run due choirs
|
|
384
389
|
async function checkAndRunChoirs(): Promise<void> {
|
|
385
390
|
const now = new Date();
|
|
386
391
|
|
|
392
|
+
// Circuit breaker: skip entire cycle if backing off from rate limit
|
|
393
|
+
if (Date.now() < circuitBreaker.backoffUntil) {
|
|
394
|
+
const remainingSec = ((circuitBreaker.backoffUntil - Date.now()) / 1000).toFixed(0);
|
|
395
|
+
log.debug(`[chorus] ⚡ Circuit breaker active — ${remainingSec}s remaining`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
387
399
|
// Check choirs in cascade order (important for illumination flow)
|
|
388
400
|
for (const choirId of CASCADE_ORDER) {
|
|
389
401
|
const choir = CHOIRS[choirId];
|
|
@@ -394,10 +406,20 @@ export function createChoirScheduler(
|
|
|
394
406
|
continue;
|
|
395
407
|
}
|
|
396
408
|
|
|
409
|
+
// Skip if already executing (prevents duplicate runs for slow choirs)
|
|
410
|
+
if (executing.has(choirId)) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
397
414
|
// Check if due based on interval
|
|
398
415
|
const state = runState.get(choirId);
|
|
399
416
|
if (shouldRunChoir(choir, now, state?.lastRun)) {
|
|
400
|
-
|
|
417
|
+
executing.add(choirId);
|
|
418
|
+
try {
|
|
419
|
+
await executeChoir(choir);
|
|
420
|
+
} finally {
|
|
421
|
+
executing.delete(choirId);
|
|
422
|
+
}
|
|
401
423
|
}
|
|
402
424
|
}
|
|
403
425
|
}
|