@iamoberlin/chorus 2.2.1 → 2.4.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.
@@ -8,6 +8,7 @@
8
8
  import type { OpenClawPluginService, PluginLogger } from "openclaw/plugin-sdk";
9
9
  import { loadPurposes, updatePurpose, type Purpose } from "./purposes.js";
10
10
  import { recordExecution, type ChoirExecution } from "./metrics.js";
11
+ import { sortByFairness, type FairnessScore } from "./behavioral-sink.js";
11
12
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
12
13
  import { join } from "path";
13
14
  import { homedir } from "os";
@@ -35,6 +36,9 @@ export const DEFAULT_PURPOSE_RESEARCH_CONFIG: PurposeResearchConfig = {
35
36
  checkIntervalMs: 60000,
36
37
  };
37
38
 
39
+ // Guard: purposes currently running research (prevents duplicate triggers)
40
+ const runningPurposes = new Set<string>();
41
+
38
42
  interface DailyRunTracker {
39
43
  date: string;
40
44
  count: number;
@@ -71,6 +75,36 @@ function countAlerts(output: string): number {
71
75
  return 1;
72
76
  }
73
77
 
78
+ function extractTextFromApiResult(result: any): string {
79
+ if (!result) return "";
80
+ const payloadText = (result.payloads || [])
81
+ .map((p: any) => p?.text || "")
82
+ .filter((t: string) => Boolean(t))
83
+ .pop();
84
+ return payloadText || result.text || result.result?.text || result.response || result.content || "";
85
+ }
86
+
87
+ function extractTextFromCliStdout(stdout: string): string {
88
+ if (!stdout) return "";
89
+
90
+ // Find the last top-level JSON object to skip any log noise before payload.
91
+ for (let i = stdout.length - 1; i >= 0; i--) {
92
+ if (stdout[i] !== "{") continue;
93
+ try {
94
+ const parsed = JSON.parse(stdout.slice(i));
95
+ const payloadText = (parsed.result?.payloads || [])
96
+ .map((p: any) => p?.text || "")
97
+ .filter((t: string) => Boolean(t))
98
+ .pop();
99
+ return payloadText || parsed.result?.text || parsed.text || parsed.response || parsed.content || "";
100
+ } catch {
101
+ // Keep scanning backward for valid JSON.
102
+ }
103
+ }
104
+
105
+ return "";
106
+ }
107
+
74
108
  const STATE_DIR = join(homedir(), ".chorus");
75
109
  const STATE_FILE = join(STATE_DIR, "research-state.json");
76
110
 
@@ -160,6 +194,8 @@ export function createPurposeResearchScheduler(
160
194
  if (purpose.progress >= 100) return false;
161
195
  if (purpose.research?.enabled === false) return false;
162
196
  if (!purpose.criteria?.length && !purpose.research?.domains?.length) return false;
197
+ // Guard: skip if already running (prevents duplicate triggers while agent is executing)
198
+ if (runningPurposes.has(purpose.id)) return false;
163
199
 
164
200
  const lastRun = purpose.research?.lastRun ?? 0;
165
201
  const frequency = calculateFrequency(purpose);
@@ -214,33 +250,43 @@ PURPOSE RESEARCH: ${purpose.name}
214
250
  You are researching for the following purpose:
215
251
  ${purpose.description || purpose.name}
216
252
 
217
- Search domains: ${domains}
253
+ YOU HAVE FULL TOOL ACCESS. Use exec to run shell commands, web_search to search the web, web_fetch to read URLs. If criteria below include "run:" commands, EXECUTE THEM — do not just describe what you would do.
218
254
 
219
- Success criteria to inform research:
255
+ Working directory: ${WORKSPACE_PATH}
256
+
257
+ Criteria (follow these — if they say "run:", actually run the command):
220
258
  ${criteria}
221
259
 
222
260
  Tasks:
223
- 1. Search for recent developments relevant to this purpose
224
- 2. Assess impact on purpose progress or timeline
225
- 3. Flag anything that challenges or validates current assumptions
226
- 4. Note actionable insights
261
+ 1. EXECUTE any commands specified in criteria above
262
+ 2. Use web_search for at least 2 queries on recent developments
263
+ 3. Assess impact on purpose progress or timeline
264
+ 4. Surface actionable insights with measurable specifics (metrics, thresholds, dates)
227
265
 
228
266
  Alert threshold: ${alertThreshold}
229
267
  ${alertGuidance[alertThreshold]}
230
268
 
231
269
  Output format:
232
- - FINDINGS: Key discoveries (bullet points)
270
+ - EXECUTED: Commands run and their results (summarized)
271
+ - FINDINGS: Key discoveries (bullet points with numbers)
233
272
  - IMPACT: How this affects the purpose (progress/timeline/risk)
234
273
  - ALERTS: Anything requiring immediate attention (or "none")
235
274
  - NEXT: What to research next time
236
275
 
237
- Your output will be saved automatically. Focus on the research content.
276
+ Your output will be saved automatically. Focus on actionable content, not analysis of what you could do.
238
277
 
239
- CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
278
+ CRITICAL: If sending alerts to plain-text channels, use PLAIN TEXT ONLY (no markdown).
240
279
  `.trim();
241
280
  }
242
281
 
243
- async function runResearch(purpose: Purpose): Promise<void> {
282
+ async function runResearch(purpose: Purpose): Promise<boolean> {
283
+ // Guard: prevent concurrent runs of the same purpose
284
+ if (runningPurposes.has(purpose.id)) {
285
+ log.debug(`[purpose-research] ⏭️ "${purpose.name}" already running, skipping`);
286
+ return false;
287
+ }
288
+ runningPurposes.add(purpose.id);
289
+
244
290
  const startTime = Date.now();
245
291
  log.info(`[purpose-research] 🔬 Running research for "${purpose.name}"`);
246
292
 
@@ -261,12 +307,16 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
261
307
  if (typeof api.runAgentTurn === "function") {
262
308
  try {
263
309
  result = await api.runAgentTurn({
264
- sessionLabel: `chorus:purpose:${purpose.id}`,
310
+ sessionLabel: `chorus-purpose-${purpose.id}`,
265
311
  message: prompt,
266
312
  isolated: true,
267
313
  timeoutSeconds: config.researchTimeoutMs / 1000,
268
314
  });
269
- output = result?.response || "";
315
+ output = extractTextFromApiResult(result);
316
+ if (!output.trim()) {
317
+ log.debug(`[purpose-research] API returned empty output, falling back to CLI for "${purpose.name}"`);
318
+ result = null;
319
+ }
270
320
  } catch (apiErr) {
271
321
  log.debug(`[purpose-research] API runAgentTurn failed, falling back to CLI: ${apiErr}`);
272
322
  result = null;
@@ -279,7 +329,7 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
279
329
  result = await new Promise<any>((resolve) => {
280
330
  const child = spawn("openclaw", [
281
331
  "agent",
282
- "--session-id", `chorus:purpose:${purpose.id}`,
332
+ "--session-id", `chorus-purpose-${purpose.id}`,
283
333
  "--message", prompt,
284
334
  "--json",
285
335
  ], { stdio: ['pipe', 'pipe', 'pipe'] });
@@ -296,16 +346,15 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
296
346
  });
297
347
 
298
348
  if (result.status === 0 && result.stdout) {
299
- try {
300
- const json = JSON.parse(result.stdout);
301
- output = json.result?.payloads?.[0]?.text || json.response || "";
302
- } catch {
303
- output = result.stdout;
304
- }
349
+ output = extractTextFromCliStdout(result.stdout);
305
350
  } else if (result.stderr) {
306
351
  log.error(`[purpose-research] CLI error: ${result.stderr}`);
307
352
  }
308
353
  }
354
+
355
+ if (!output.trim()) {
356
+ throw new Error("No research output produced");
357
+ }
309
358
  execution.durationMs = Date.now() - startTime;
310
359
  execution.success = true;
311
360
  execution.outputLength = output.length;
@@ -335,24 +384,51 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
335
384
  }
336
385
  }
337
386
 
387
+ // Re-read purpose from disk to avoid overwriting fields changed during the run
388
+ // (e.g., frequency modified externally while agent was executing)
389
+ const freshPurposes = await loadPurposes();
390
+ const freshPurpose = freshPurposes.find((p) => p.id === purpose.id);
391
+ const freshResearch = freshPurpose?.research ?? purpose.research;
392
+
338
393
  await updatePurpose(purpose.id, {
339
394
  research: {
340
- ...purpose.research,
341
- enabled: purpose.research?.enabled ?? true,
395
+ ...freshResearch,
396
+ enabled: freshResearch?.enabled ?? true,
342
397
  lastRun: Date.now(),
343
- runCount: (purpose.research?.runCount ?? 0) + 1,
398
+ runCount: (freshResearch?.runCount ?? 0) + 1,
344
399
  },
345
400
  });
401
+ dailyRuns.count++;
402
+ persistState();
403
+ return true;
346
404
  } catch (err) {
347
405
  execution.durationMs = Date.now() - startTime;
348
406
  execution.success = false;
349
407
  execution.error = String(err);
350
408
  log.error(`[purpose-research] ✗ "${purpose.name}" failed: ${err}`);
351
- }
352
409
 
353
- recordExecution(execution);
354
- dailyRuns.count++;
355
- persistState();
410
+ // Update lastRun even on failure to prevent retry storms
411
+ // (without this, failed runs leave lastRun stale and the scheduler retries immediately)
412
+ try {
413
+ const failPurposes = await loadPurposes();
414
+ const failPurpose = failPurposes.find((p) => p.id === purpose.id);
415
+ if (failPurpose) {
416
+ await updatePurpose(purpose.id, {
417
+ research: {
418
+ ...(failPurpose.research ?? purpose.research),
419
+ lastRun: Date.now(),
420
+ },
421
+ });
422
+ }
423
+ } catch (updateErr) {
424
+ log.error(`[purpose-research] Failed to update lastRun after error: ${updateErr}`);
425
+ }
426
+
427
+ return false;
428
+ } finally {
429
+ runningPurposes.delete(purpose.id);
430
+ recordExecution(execution);
431
+ }
356
432
  }
357
433
 
358
434
  async function checkAndRun(): Promise<void> {
@@ -377,21 +453,37 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
377
453
 
378
454
  if (duePurposes.length === 0) return;
379
455
 
380
- duePurposes.sort((a, b) => {
381
- const aDeadline = a.deadline
382
- ? typeof a.deadline === "string"
383
- ? Date.parse(a.deadline)
384
- : a.deadline
385
- : Infinity;
386
- const bDeadline = b.deadline
387
- ? typeof b.deadline === "string"
388
- ? Date.parse(b.deadline)
389
- : b.deadline
390
- : Infinity;
391
- return aDeadline - bDeadline;
392
- });
393
-
394
- const purpose = duePurposes[0];
456
+ // ── Universe 25 Fairness Sort ─────────────────────────────
457
+ // Replaces naive deadline sort that caused purpose starvation
458
+ // (calibration lesson #23). Overdue purposes get exponentially
459
+ // increasing priority — no purpose can be crowded out.
460
+ const fairnessSorted = sortByFairness(
461
+ duePurposes.map(p => ({
462
+ id: p.id,
463
+ lastRun: p.research?.lastRun,
464
+ frequency: calculateFrequency(p),
465
+ deadline: p.deadline
466
+ ? typeof p.deadline === "string"
467
+ ? Date.parse(p.deadline)
468
+ : p.deadline
469
+ : undefined,
470
+ }))
471
+ );
472
+
473
+ const selectedId = fairnessSorted[0]?.id;
474
+ const purpose = duePurposes.find(p => p.id === selectedId);
475
+ const fairness = fairnessSorted[0]?.fairness;
476
+
477
+ if (!purpose) return;
478
+
479
+ if (fairness?.starving) {
480
+ log.warn(
481
+ `[purpose-research] 🏚️ STARVATION RECOVERY: "${purpose.name}" — ` +
482
+ `${fairness.starvationRatio.toFixed(1)}x overdue (expected every ` +
483
+ `${(fairness.expectedInterval / 3600000).toFixed(1)}h, actual ` +
484
+ `${(fairness.actualInterval / 3600000).toFixed(1)}h). Boost: ${fairness.priorityBoost.toFixed(1)}x`
485
+ )
486
+ }
395
487
 
396
488
  if (dailyRuns.count < config.dailyRunCap) {
397
489
  await runResearch(purpose);
@@ -442,7 +534,10 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
442
534
  const purposes = await loadPurposes();
443
535
  const purpose = purposes.find((p) => p.id === purposeId);
444
536
  if (!purpose) throw new Error(`Purpose "${purposeId}" not found`);
445
- await runResearch(purpose);
537
+ const success = await runResearch(purpose);
538
+ if (!success) {
539
+ throw new Error(`Research run failed for "${purposeId}"`);
540
+ }
446
541
  },
447
542
 
448
543
  getStatus: () => {
package/src/scheduler.ts CHANGED
@@ -8,11 +8,14 @@
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";
15
17
  import { spawn } from "child_process";
18
+ import { recordAndAssess } from "./behavioral-sink.js";
16
19
 
17
20
  // Type for the plugin API's runAgentTurn method
18
21
  interface AgentTurnResult {
@@ -22,8 +25,8 @@ interface AgentTurnResult {
22
25
  }
23
26
 
24
27
  // ── Delivery ─────────────────────────────────────────────────
25
- // Choir agents handle their own delivery via OpenClaw messaging tools.
26
- // The scheduler's job is execution and scheduling not routing messages.
28
+ // Delivery is handled via shared channel adapter to keep behavior consistent
29
+ // across scheduler and manual CLI execution paths.
27
30
 
28
31
  interface ChoirContext {
29
32
  choirId: string;
@@ -41,6 +44,29 @@ interface ChoirRunState {
41
44
  const CHORUS_DIR = join(homedir(), ".chorus");
42
45
  const RUN_STATE_PATH = join(CHORUS_DIR, "run-state.json");
43
46
 
47
+ // ── 429 Circuit Breaker ──────────────────────────────────────
48
+ // Exponential backoff on rate limit errors. Skips choir execution
49
+ // when backing off, resets on successful runs.
50
+ interface CircuitBreakerState {
51
+ backoffUntil: number; // timestamp ms — skip all choirs until this time
52
+ consecutiveFailures: number;
53
+ }
54
+
55
+ const MAX_BACKOFF_MS = 16 * 60 * 1000; // 16 minutes cap
56
+ const BASE_BACKOFF_MS = 60 * 1000; // 1 minute base
57
+
58
+ function isRateLimitError(err: unknown): boolean {
59
+ const msg = String(err).toLowerCase();
60
+ return msg.includes("429") || msg.includes("rate limit") || msg.includes("rate_limit") || msg.includes("too many requests") || msg.includes("overloaded") || msg.includes("unavailable");
61
+ }
62
+
63
+ function computeBackoffMs(failures: number): number {
64
+ const ms = BASE_BACKOFF_MS * Math.pow(2, Math.min(failures - 1, 4));
65
+ // Add jitter: ±25%
66
+ const jitter = ms * 0.25 * (Math.random() * 2 - 1);
67
+ return Math.min(ms + jitter, MAX_BACKOFF_MS);
68
+ }
69
+
44
70
  // Load persisted run state from disk
45
71
  function loadRunState(log: PluginLogger): Map<string, ChoirRunState> {
46
72
  const state = new Map<string, ChoirRunState>();
@@ -106,12 +132,15 @@ export function createChoirScheduler(
106
132
  // Load persisted state instead of starting fresh
107
133
  const runState = loadRunState(log);
108
134
 
135
+ // Circuit breaker state
136
+ const circuitBreaker: CircuitBreakerState = { backoffUntil: 0, consecutiveFailures: 0 };
137
+
109
138
  // CLI fallback for executing choirs when plugin API is unavailable
110
139
  async function executeChoirViaCli(choir: Choir, prompt: string): Promise<string> {
111
140
  const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => {
112
141
  const child = spawn('openclaw', [
113
142
  'agent',
114
- '--session-id', `chorus:${choir.id}`,
143
+ '--session-id', `chorus-${choir.id}`,
115
144
  '--message', prompt,
116
145
  '--json',
117
146
  ], { stdio: ['pipe', 'pipe', 'pipe'] });
@@ -190,7 +219,7 @@ export function createChoirScheduler(
190
219
  if (typeof api.runAgentTurn === 'function') {
191
220
  try {
192
221
  const result: AgentTurnResult = await api.runAgentTurn({
193
- sessionLabel: `chorus:${choir.id}`,
222
+ sessionLabel: `chorus-${choir.id}`,
194
223
  message: prompt,
195
224
  isolated: true,
196
225
  timeoutSeconds: 300,
@@ -208,6 +237,17 @@ export function createChoirScheduler(
208
237
  output = result.text;
209
238
  }
210
239
  } catch (apiErr) {
240
+ if (isRateLimitError(apiErr)) {
241
+ circuitBreaker.consecutiveFailures++;
242
+ const backoffMs = computeBackoffMs(circuitBreaker.consecutiveFailures);
243
+ circuitBreaker.backoffUntil = Date.now() + backoffMs;
244
+ log.warn(`[chorus] ⚡ 429 RATE LIMIT on ${choir.name} — backing off ${(backoffMs/1000).toFixed(0)}s (failure #${circuitBreaker.consecutiveFailures})`);
245
+ execution.durationMs = Date.now() - startTime;
246
+ execution.success = false;
247
+ execution.error = "429_rate_limit_backoff";
248
+ recordExecution(execution);
249
+ return; // Skip this choir and remaining choirs will be skipped by checkAndRunChoirs
250
+ }
211
251
  log.warn(`[chorus] API runAgentTurn failed for ${choir.name}, falling back to CLI: ${apiErr}`);
212
252
  output = await executeChoirViaCli(choir, prompt);
213
253
  }
@@ -221,17 +261,50 @@ export function createChoirScheduler(
221
261
  execution.outputLength = output.length;
222
262
  execution.tokensUsed = estimateTokens(output);
223
263
 
264
+ // Record economics cost (only for runs that produced output)
265
+ if (execution.success) {
266
+ const inputTokensEst = Math.ceil(prompt.length / 4);
267
+ const outputTokensEst = execution.tokensUsed || Math.ceil(output.length / 4);
268
+ recordCost({
269
+ choirId: choir.id,
270
+ timestamp: new Date().toISOString(),
271
+ inputTokens: inputTokensEst,
272
+ outputTokens: outputTokensEst,
273
+ model: "default",
274
+ inferenceMs: execution.durationMs,
275
+ costUsd: estimateCost(inputTokensEst, outputTokensEst),
276
+ });
277
+ }
278
+
224
279
  // Parse output for metrics (findings, alerts, improvements)
225
280
  execution.findings = countFindings(output);
226
281
  execution.alerts = countAlerts(output);
227
282
  execution.improvements = extractImprovements(output, choir.id);
228
283
 
229
- // Store context for downstream choirs
230
- contextStore.set(choir.id, {
231
- choirId: choir.id,
232
- output: output.slice(0, 2000), // Truncate for context passing
233
- timestamp: new Date(),
234
- });
284
+ // ── Behavioral Sink Prevention ──────────────────────────
285
+ // Assess output quality and gate illumination to prevent
286
+ // degraded context from cascading downstream (Universe 25).
287
+ const sinkAssessment = recordAndAssess(choir.id, output);
288
+
289
+ if (sinkAssessment.quality.score < 20 && choir.id !== "angels") {
290
+ log.warn(`[chorus] 🪞 BEAUTIFUL ONE detected: ${choir.name} scored ${sinkAssessment.quality.score}/100 — flags: ${sinkAssessment.quality.flags.join(", ")}`);
291
+ }
292
+
293
+ // Store context for downstream choirs — only if quality passes sink gate
294
+ if (sinkAssessment.illumination.usable) {
295
+ const qualityTag = sinkAssessment.illumination.degraded
296
+ ? `\n[⚠️ CONTEXT QUALITY DEGRADED — score ${sinkAssessment.quality.score}/100]\n`
297
+ : "";
298
+ contextStore.set(choir.id, {
299
+ choirId: choir.id,
300
+ output: qualityTag + output.slice(0, 2000),
301
+ timestamp: new Date(),
302
+ });
303
+ } else {
304
+ log.warn(`[chorus] 🚫 SINK GATE: ${choir.name} output blocked from illumination chain (score ${sinkAssessment.quality.score}/100)`);
305
+ // Don't update context — downstream choirs keep stale but good context
306
+ // rather than receiving fresh but degraded context
307
+ }
235
308
 
236
309
  // Update run state
237
310
  runState.set(choir.id, {
@@ -243,69 +316,17 @@ export function createChoirScheduler(
243
316
  // Persist state to disk after each run
244
317
  saveRunState(runState, log);
245
318
 
246
- log.info(`[chorus] ${choir.emoji} ${choir.name} completed (${(execution.durationMs/1000).toFixed(1)}s)`);
319
+ // Reset circuit breaker on success
320
+ if (circuitBreaker.consecutiveFailures > 0) {
321
+ log.info(`[chorus] ✅ Circuit breaker reset after ${circuitBreaker.consecutiveFailures} failures`);
322
+ circuitBreaker.consecutiveFailures = 0;
323
+ circuitBreaker.backoffUntil = 0;
324
+ }
247
325
 
248
- // Deliver output to user via OpenClaw messaging if choir is marked for delivery
249
- // Reads target from OpenClaw config (channels.*.allowFrom) — no hardcoded PII
250
- if (choir.delivers && output && output !== "(no response)" && output !== "HEARTBEAT_OK" && output !== "NO_REPLY") {
251
- const channels = api.config?.channels as Record<string, any> | undefined;
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
- }
326
+ log.info(`[chorus] ${choir.emoji} ${choir.name} completed (${(execution.durationMs/1000).toFixed(1)}s)`);
264
327
 
265
- if (target) {
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
- }
280
-
281
- try {
282
- const args = [
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
- }
328
+ // Deliver output via shared adapter for consistent routing/format behavior
329
+ await deliverChoirOutput(api, choir, output, log);
309
330
 
310
331
  // Log illumination flow
311
332
  if (choir.passesTo.length > 0) {
@@ -380,10 +401,20 @@ export function createChoirScheduler(
380
401
  return improvements.slice(0, 5); // Cap at 5
381
402
  }
382
403
 
404
+ // Track choirs currently executing to prevent duplicate runs
405
+ const executing = new Set<string>();
406
+
383
407
  // Check and run due choirs
384
408
  async function checkAndRunChoirs(): Promise<void> {
385
409
  const now = new Date();
386
410
 
411
+ // Circuit breaker: skip entire cycle if backing off from rate limit
412
+ if (Date.now() < circuitBreaker.backoffUntil) {
413
+ const remainingSec = ((circuitBreaker.backoffUntil - Date.now()) / 1000).toFixed(0);
414
+ log.debug(`[chorus] ⚡ Circuit breaker active — ${remainingSec}s remaining`);
415
+ return;
416
+ }
417
+
387
418
  // Check choirs in cascade order (important for illumination flow)
388
419
  for (const choirId of CASCADE_ORDER) {
389
420
  const choir = CHOIRS[choirId];
@@ -394,10 +425,20 @@ export function createChoirScheduler(
394
425
  continue;
395
426
  }
396
427
 
428
+ // Skip if already executing (prevents duplicate runs for slow choirs)
429
+ if (executing.has(choirId)) {
430
+ continue;
431
+ }
432
+
397
433
  // Check if due based on interval
398
434
  const state = runState.get(choirId);
399
435
  if (shouldRunChoir(choir, now, state?.lastRun)) {
400
- await executeChoir(choir);
436
+ executing.add(choirId);
437
+ try {
438
+ await executeChoir(choir);
439
+ } finally {
440
+ executing.delete(choirId);
441
+ }
401
442
  }
402
443
  }
403
444
  }