@desplega.ai/agent-swarm 1.56.5 → 1.56.6

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/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.56.4",
5
+ "version": "1.56.5",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
@@ -1415,6 +1415,237 @@
1415
1415
  }
1416
1416
  }
1417
1417
  },
1418
+ "/api/keys/report-usage": {
1419
+ "post": {
1420
+ "summary": "Record which API key was used for a task",
1421
+ "tags": [
1422
+ "API Keys"
1423
+ ],
1424
+ "security": [
1425
+ {
1426
+ "bearerAuth": []
1427
+ }
1428
+ ],
1429
+ "requestBody": {
1430
+ "content": {
1431
+ "application/json": {
1432
+ "schema": {
1433
+ "type": "object",
1434
+ "properties": {
1435
+ "keyType": {
1436
+ "type": "string"
1437
+ },
1438
+ "keySuffix": {
1439
+ "type": "string",
1440
+ "minLength": 1,
1441
+ "maxLength": 10
1442
+ },
1443
+ "keyIndex": {
1444
+ "type": "integer",
1445
+ "minimum": 0
1446
+ },
1447
+ "taskId": {
1448
+ "type": "string",
1449
+ "format": "uuid"
1450
+ },
1451
+ "scope": {
1452
+ "type": "string"
1453
+ },
1454
+ "scopeId": {
1455
+ "type": "string"
1456
+ }
1457
+ },
1458
+ "required": [
1459
+ "keyType",
1460
+ "keySuffix",
1461
+ "keyIndex"
1462
+ ]
1463
+ }
1464
+ }
1465
+ }
1466
+ },
1467
+ "responses": {
1468
+ "200": {
1469
+ "description": "Usage recorded"
1470
+ },
1471
+ "400": {
1472
+ "description": "Validation error"
1473
+ },
1474
+ "401": {
1475
+ "description": "Unauthorized"
1476
+ }
1477
+ }
1478
+ }
1479
+ },
1480
+ "/api/keys/report-rate-limit": {
1481
+ "post": {
1482
+ "summary": "Mark an API key as rate-limited",
1483
+ "tags": [
1484
+ "API Keys"
1485
+ ],
1486
+ "security": [
1487
+ {
1488
+ "bearerAuth": []
1489
+ }
1490
+ ],
1491
+ "requestBody": {
1492
+ "content": {
1493
+ "application/json": {
1494
+ "schema": {
1495
+ "type": "object",
1496
+ "properties": {
1497
+ "keyType": {
1498
+ "type": "string"
1499
+ },
1500
+ "keySuffix": {
1501
+ "type": "string",
1502
+ "minLength": 1,
1503
+ "maxLength": 10
1504
+ },
1505
+ "keyIndex": {
1506
+ "type": "integer",
1507
+ "minimum": 0
1508
+ },
1509
+ "rateLimitedUntil": {
1510
+ "type": "string",
1511
+ "format": "date-time"
1512
+ },
1513
+ "scope": {
1514
+ "type": "string"
1515
+ },
1516
+ "scopeId": {
1517
+ "type": "string"
1518
+ }
1519
+ },
1520
+ "required": [
1521
+ "keyType",
1522
+ "keySuffix",
1523
+ "keyIndex",
1524
+ "rateLimitedUntil"
1525
+ ]
1526
+ }
1527
+ }
1528
+ }
1529
+ },
1530
+ "responses": {
1531
+ "200": {
1532
+ "description": "Key marked as rate-limited"
1533
+ },
1534
+ "400": {
1535
+ "description": "Validation error"
1536
+ },
1537
+ "401": {
1538
+ "description": "Unauthorized"
1539
+ }
1540
+ }
1541
+ }
1542
+ },
1543
+ "/api/keys/available": {
1544
+ "get": {
1545
+ "summary": "Get available (non-rate-limited) key indices for a credential type",
1546
+ "tags": [
1547
+ "API Keys"
1548
+ ],
1549
+ "security": [
1550
+ {
1551
+ "bearerAuth": []
1552
+ }
1553
+ ],
1554
+ "parameters": [
1555
+ {
1556
+ "schema": {
1557
+ "type": "string"
1558
+ },
1559
+ "required": true,
1560
+ "name": "keyType",
1561
+ "in": "query"
1562
+ },
1563
+ {
1564
+ "schema": {
1565
+ "type": "integer",
1566
+ "minimum": 1
1567
+ },
1568
+ "required": true,
1569
+ "name": "totalKeys",
1570
+ "in": "query"
1571
+ },
1572
+ {
1573
+ "schema": {
1574
+ "type": "string"
1575
+ },
1576
+ "required": false,
1577
+ "name": "scope",
1578
+ "in": "query"
1579
+ },
1580
+ {
1581
+ "schema": {
1582
+ "type": "string"
1583
+ },
1584
+ "required": false,
1585
+ "name": "scopeId",
1586
+ "in": "query"
1587
+ }
1588
+ ],
1589
+ "responses": {
1590
+ "200": {
1591
+ "description": "List of available key indices"
1592
+ },
1593
+ "400": {
1594
+ "description": "Validation error"
1595
+ },
1596
+ "401": {
1597
+ "description": "Unauthorized"
1598
+ }
1599
+ }
1600
+ }
1601
+ },
1602
+ "/api/keys/status": {
1603
+ "get": {
1604
+ "summary": "Get all API key status records",
1605
+ "tags": [
1606
+ "API Keys"
1607
+ ],
1608
+ "security": [
1609
+ {
1610
+ "bearerAuth": []
1611
+ }
1612
+ ],
1613
+ "parameters": [
1614
+ {
1615
+ "schema": {
1616
+ "type": "string"
1617
+ },
1618
+ "required": false,
1619
+ "name": "keyType",
1620
+ "in": "query"
1621
+ },
1622
+ {
1623
+ "schema": {
1624
+ "type": "string"
1625
+ },
1626
+ "required": false,
1627
+ "name": "scope",
1628
+ "in": "query"
1629
+ },
1630
+ {
1631
+ "schema": {
1632
+ "type": "string"
1633
+ },
1634
+ "required": false,
1635
+ "name": "scopeId",
1636
+ "in": "query"
1637
+ }
1638
+ ],
1639
+ "responses": {
1640
+ "200": {
1641
+ "description": "List of key status records"
1642
+ },
1643
+ "401": {
1644
+ "description": "Unauthorized"
1645
+ }
1646
+ }
1647
+ }
1648
+ },
1418
1649
  "/api/events": {
1419
1650
  "post": {
1420
1651
  "summary": "Store a single event",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.56.5",
3
+ "version": "1.56.6",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
package/src/be/db.ts CHANGED
@@ -7487,3 +7487,157 @@ export function getContextSummaryByTaskId(taskId: string): ContextSummary {
7487
7487
  snapshotCount: countRow?.cnt ?? 0,
7488
7488
  };
7489
7489
  }
7490
+
7491
+ // ─── API Key Pool Tracking ───────────────────────────────────────────────────
7492
+
7493
+ export interface ApiKeyStatus {
7494
+ id: string;
7495
+ keyType: string;
7496
+ keySuffix: string;
7497
+ keyIndex: number;
7498
+ scope: string;
7499
+ scopeId: string | null;
7500
+ status: string;
7501
+ rateLimitedUntil: string | null;
7502
+ lastUsedAt: string | null;
7503
+ lastRateLimitAt: string | null;
7504
+ totalUsageCount: number;
7505
+ rateLimitCount: number;
7506
+ createdAt: string;
7507
+ updatedAt: string;
7508
+ }
7509
+
7510
+ /**
7511
+ * Get available (non-rate-limited) key indices for a credential type.
7512
+ * Automatically clears expired rate limits before returning.
7513
+ */
7514
+ export function getAvailableKeyIndices(
7515
+ keyType: string,
7516
+ totalKeys: number,
7517
+ scope = "global",
7518
+ scopeId: string | null = null,
7519
+ ): number[] {
7520
+ const now = new Date().toISOString();
7521
+ const db = getDb();
7522
+ const effectiveScopeId = scopeId ?? "";
7523
+
7524
+ // Auto-clear expired rate limits
7525
+ db.prepare(
7526
+ `UPDATE api_key_status
7527
+ SET status = 'available', rateLimitedUntil = NULL, updatedAt = ?
7528
+ WHERE keyType = ? AND scope = ? AND scopeId = ?
7529
+ AND status = 'rate_limited' AND rateLimitedUntil IS NOT NULL AND rateLimitedUntil <= ?`,
7530
+ ).run(now, keyType, scope, effectiveScopeId, now);
7531
+
7532
+ // Get currently rate-limited key indices
7533
+ const rateLimited = db
7534
+ .prepare<{ keyIndex: number }, [string, string, string]>(
7535
+ `SELECT keyIndex FROM api_key_status
7536
+ WHERE keyType = ? AND scope = ? AND scopeId = ?
7537
+ AND status = 'rate_limited'`,
7538
+ )
7539
+ .all(keyType, scope, effectiveScopeId);
7540
+
7541
+ const blockedIndices = new Set(rateLimited.map((r) => r.keyIndex));
7542
+ const available: number[] = [];
7543
+ for (let i = 0; i < totalKeys; i++) {
7544
+ if (!blockedIndices.has(i)) available.push(i);
7545
+ }
7546
+ return available;
7547
+ }
7548
+
7549
+ /**
7550
+ * Record that a key was used for a task (upsert key status + update task).
7551
+ */
7552
+ export function recordKeyUsage(
7553
+ keyType: string,
7554
+ keySuffix: string,
7555
+ keyIndex: number,
7556
+ taskId: string | null,
7557
+ scope = "global",
7558
+ scopeId: string | null = null,
7559
+ ): void {
7560
+ const now = new Date().toISOString();
7561
+ const db = getDb();
7562
+ const effectiveScopeId = scopeId ?? "";
7563
+
7564
+ // Upsert key status record
7565
+ db.prepare(
7566
+ `INSERT INTO api_key_status (keyType, keySuffix, keyIndex, scope, scopeId, lastUsedAt, totalUsageCount, updatedAt)
7567
+ VALUES (?, ?, ?, ?, ?, ?, 1, ?)
7568
+ ON CONFLICT(keyType, keySuffix, scope, scopeId)
7569
+ DO UPDATE SET
7570
+ lastUsedAt = excluded.lastUsedAt,
7571
+ totalUsageCount = totalUsageCount + 1,
7572
+ keyIndex = excluded.keyIndex,
7573
+ updatedAt = excluded.updatedAt`,
7574
+ ).run(keyType, keySuffix, keyIndex, scope, effectiveScopeId, now, now);
7575
+
7576
+ // Record which key was used on the task
7577
+ if (taskId) {
7578
+ db.prepare("UPDATE agent_tasks SET credentialKeySuffix = ? WHERE id = ?").run(
7579
+ keySuffix,
7580
+ taskId,
7581
+ );
7582
+ }
7583
+ }
7584
+
7585
+ /**
7586
+ * Mark a key as rate-limited with a retry-after timestamp.
7587
+ */
7588
+ export function markKeyRateLimited(
7589
+ keyType: string,
7590
+ keySuffix: string,
7591
+ keyIndex: number,
7592
+ rateLimitedUntil: string,
7593
+ scope = "global",
7594
+ scopeId: string | null = null,
7595
+ ): void {
7596
+ const now = new Date().toISOString();
7597
+ const effectiveScopeId = scopeId ?? "";
7598
+ getDb()
7599
+ .prepare(
7600
+ `INSERT INTO api_key_status (keyType, keySuffix, keyIndex, scope, scopeId, status, rateLimitedUntil, lastRateLimitAt, rateLimitCount, updatedAt)
7601
+ VALUES (?, ?, ?, ?, ?, 'rate_limited', ?, ?, 1, ?)
7602
+ ON CONFLICT(keyType, keySuffix, scope, scopeId)
7603
+ DO UPDATE SET
7604
+ status = 'rate_limited',
7605
+ rateLimitedUntil = excluded.rateLimitedUntil,
7606
+ lastRateLimitAt = excluded.lastRateLimitAt,
7607
+ rateLimitCount = rateLimitCount + 1,
7608
+ keyIndex = excluded.keyIndex,
7609
+ updatedAt = excluded.updatedAt`,
7610
+ )
7611
+ .run(keyType, keySuffix, keyIndex, scope, effectiveScopeId, rateLimitedUntil, now, now);
7612
+ }
7613
+
7614
+ /**
7615
+ * Get all key status records for a credential type.
7616
+ */
7617
+ export function getKeyStatuses(
7618
+ keyType?: string,
7619
+ scope?: string,
7620
+ scopeId?: string | null,
7621
+ ): ApiKeyStatus[] {
7622
+ const db = getDb();
7623
+ const conditions: string[] = [];
7624
+ const params: string[] = [];
7625
+
7626
+ if (keyType) {
7627
+ conditions.push("keyType = ?");
7628
+ params.push(keyType);
7629
+ }
7630
+ if (scope) {
7631
+ conditions.push("scope = ?");
7632
+ params.push(scope);
7633
+ if (scopeId !== undefined) {
7634
+ conditions.push("scopeId = ?");
7635
+ params.push(scopeId ?? "");
7636
+ }
7637
+ }
7638
+
7639
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
7640
+ return db
7641
+ .prepare<ApiKeyStatus, string[]>(`SELECT * FROM api_key_status ${where} ORDER BY keyIndex`)
7642
+ .all(...params);
7643
+ }
@@ -0,0 +1,38 @@
1
+ -- Track API key pool status for rate limit awareness and automatic rotation
2
+ CREATE TABLE IF NOT EXISTS api_key_status (
3
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
4
+ -- Which credential type: 'CLAUDE_CODE_OAUTH_TOKEN' or 'ANTHROPIC_API_KEY'
5
+ keyType TEXT NOT NULL,
6
+ -- Last 5 characters of the key (for identification without storing secrets)
7
+ keySuffix TEXT NOT NULL,
8
+ -- Position in the comma-separated credential pool (0-based)
9
+ keyIndex INTEGER NOT NULL,
10
+ -- Scope mirrors swarm_config (global, agent, repo)
11
+ scope TEXT NOT NULL DEFAULT 'global',
12
+ scopeId TEXT NOT NULL DEFAULT '',
13
+ -- Current status
14
+ status TEXT NOT NULL DEFAULT 'available' CHECK(status IN ('available', 'rate_limited')),
15
+ -- When the rate limit expires (ISO timestamp from retry-after)
16
+ rateLimitedUntil TEXT,
17
+ -- Tracking timestamps
18
+ lastUsedAt TEXT,
19
+ lastRateLimitAt TEXT,
20
+ -- Counters
21
+ totalUsageCount INTEGER NOT NULL DEFAULT 0,
22
+ rateLimitCount INTEGER NOT NULL DEFAULT 0,
23
+ -- Metadata
24
+ createdAt TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
25
+ updatedAt TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
26
+ -- Unique constraint: one entry per key type + suffix + scope.
27
+ -- Known limitation: two different keys sharing the same last 5 chars will collide,
28
+ -- causing the ON CONFLICT upsert to overwrite keyIndex. In practice this is rare
29
+ -- (1 in ~60M for random alphanumeric suffixes) and the impact is minor (wrong index
30
+ -- logged, but rate-limit tracking still works since suffix is the real identifier).
31
+ UNIQUE(keyType, keySuffix, scope, scopeId)
32
+ );
33
+
34
+ CREATE INDEX IF NOT EXISTS idx_api_key_status_lookup
35
+ ON api_key_status(keyType, scope, scopeId, status);
36
+
37
+ -- Track which key was used per task (last 5 chars only)
38
+ ALTER TABLE agent_tasks ADD COLUMN credentialKeySuffix TEXT;
@@ -18,7 +18,8 @@ import {
18
18
  type ProviderSessionConfig,
19
19
  } from "../providers/index.ts";
20
20
  import { getContextWindowSize } from "../utils/context-window.ts";
21
- import { resolveCredentialPools } from "../utils/credentials.ts";
21
+ import { type CredentialSelection, resolveCredentialPools } from "../utils/credentials.ts";
22
+ import { parseRateLimitResetTime } from "../utils/error-tracker.ts";
22
23
  import { prettyPrintLine, prettyPrintStderr } from "../utils/pretty-print.ts";
23
24
  import { detectVcsProvider } from "../vcs/index.ts";
24
25
  import { interpolate } from "../workflows/template.ts";
@@ -178,12 +179,17 @@ async function closeAgent(config: ApiConfig, role: string): Promise<void> {
178
179
  * Falls back to baseEnv on any error (network, parse, etc).
179
180
  * Credential env vars with comma-separated values get one randomly selected.
180
181
  */
182
+ interface ResolvedEnvResult {
183
+ env: Record<string, string | undefined>;
184
+ credentialSelections: CredentialSelection[];
185
+ }
186
+
181
187
  async function fetchResolvedEnv(
182
188
  apiUrl: string,
183
189
  apiKey: string,
184
190
  agentId: string,
185
191
  baseEnv: Record<string, string | undefined> = process.env,
186
- ): Promise<Record<string, string | undefined>> {
192
+ ): Promise<ResolvedEnvResult> {
187
193
  const env: Record<string, string | undefined> = { ...baseEnv };
188
194
 
189
195
  if (apiUrl && agentId) {
@@ -213,8 +219,9 @@ async function fetchResolvedEnv(
213
219
  }
214
220
  }
215
221
 
216
- resolveCredentialPools(env);
217
- return env;
222
+ const credentialSelections = await resolveCredentialPools(env, { apiUrl, apiKey });
223
+
224
+ return { env, credentialSelections };
218
225
  }
219
226
 
220
227
  /** Tools that produce noise — skip auto-progress for these */
@@ -545,6 +552,64 @@ async function ensureTaskFinished(
545
552
  }
546
553
  }
547
554
 
555
+ /** Report key usage to the API (fire-and-forget) */
556
+ async function reportKeyUsage(
557
+ apiUrl: string,
558
+ apiKey: string,
559
+ keyType: string,
560
+ selection: CredentialSelection,
561
+ taskId?: string,
562
+ ): Promise<void> {
563
+ try {
564
+ await fetch(`${apiUrl}/api/keys/report-usage`, {
565
+ method: "POST",
566
+ headers: {
567
+ "Content-Type": "application/json",
568
+ Authorization: `Bearer ${apiKey}`,
569
+ },
570
+ body: JSON.stringify({
571
+ keyType,
572
+ keySuffix: selection.keySuffix,
573
+ keyIndex: selection.index,
574
+ taskId,
575
+ }),
576
+ });
577
+ } catch {
578
+ // Non-blocking
579
+ }
580
+ }
581
+
582
+ /** Report a rate-limited key to the API (fire-and-forget) */
583
+ async function reportKeyRateLimit(
584
+ apiUrl: string,
585
+ apiKey: string,
586
+ keyType: string,
587
+ keySuffix: string,
588
+ keyIndex: number,
589
+ rateLimitedUntil: string,
590
+ ): Promise<void> {
591
+ try {
592
+ await fetch(`${apiUrl}/api/keys/report-rate-limit`, {
593
+ method: "POST",
594
+ headers: {
595
+ "Content-Type": "application/json",
596
+ Authorization: `Bearer ${apiKey}`,
597
+ },
598
+ body: JSON.stringify({
599
+ keyType,
600
+ keySuffix,
601
+ keyIndex,
602
+ rateLimitedUntil,
603
+ }),
604
+ });
605
+ console.log(
606
+ `[credentials] Reported key ...${keySuffix} as rate-limited until ${rateLimitedUntil}`,
607
+ );
608
+ } catch {
609
+ // Non-blocking
610
+ }
611
+ }
612
+
548
613
  /**
549
614
  * Pause a task via the API (for graceful shutdown).
550
615
  * Unlike marking as failed, paused tasks can be resumed after container restart.
@@ -775,6 +840,12 @@ interface RunningTask {
775
840
  cursorUpdates?: Array<{ channelId: string; ts: string }>;
776
841
  /** Resolved working directory for VCS detection */
777
842
  workingDir?: string;
843
+ /** Credential tracking: which key was used for this task */
844
+ credentialInfo?: {
845
+ keyType: string;
846
+ keySuffix: string;
847
+ keyIndex: number;
848
+ };
778
849
  }
779
850
 
780
851
  /** Runner state for tracking concurrent tasks */
@@ -1395,7 +1466,18 @@ async function spawnProviderProcess(
1395
1466
  const effectiveTaskId = realTaskId || crypto.randomUUID();
1396
1467
 
1397
1468
  // Resolve env first so we can use MODEL_OVERRIDE from config
1398
- const freshEnv = await fetchResolvedEnv(opts.apiUrl, opts.apiKey, opts.agentId);
1469
+ const { env: freshEnv, credentialSelections } = await fetchResolvedEnv(
1470
+ opts.apiUrl,
1471
+ opts.apiKey,
1472
+ opts.agentId,
1473
+ );
1474
+
1475
+ // Report which key was selected for this task (fire-and-forget)
1476
+ if (credentialSelections.length > 0 && realTaskId) {
1477
+ for (const sel of credentialSelections) {
1478
+ reportKeyUsage(opts.apiUrl, opts.apiKey, sel.keyType, sel, realTaskId).catch(() => {});
1479
+ }
1480
+ }
1399
1481
 
1400
1482
  // Propagate agent-fs config to process.env so getBasePrompt() can read them
1401
1483
  // (fetchResolvedEnv returns a new object, doesn't update process.env)
@@ -1729,6 +1811,16 @@ async function spawnProviderProcess(
1729
1811
  return result;
1730
1812
  });
1731
1813
 
1814
+ // Build credential info for rate limit tracking
1815
+ const primarySelection = credentialSelections[0];
1816
+ const credentialInfo = primarySelection
1817
+ ? {
1818
+ keyType: primarySelection.keyType,
1819
+ keySuffix: primarySelection.keySuffix,
1820
+ keyIndex: primarySelection.index,
1821
+ }
1822
+ : undefined;
1823
+
1732
1824
  const runningTask: RunningTask = {
1733
1825
  taskId: effectiveTaskId,
1734
1826
  session,
@@ -1736,6 +1828,7 @@ async function spawnProviderProcess(
1736
1828
  startTime: new Date(),
1737
1829
  promise,
1738
1830
  result: null,
1831
+ credentialInfo,
1739
1832
  };
1740
1833
 
1741
1834
  // Non-blocking completion tracking
@@ -1766,7 +1859,7 @@ async function runProviderIteration(
1766
1859
  cwd?: string;
1767
1860
  },
1768
1861
  ): Promise<ProviderResult> {
1769
- const freshEnv = await fetchResolvedEnv(opts.apiUrl, opts.apiKey, opts.agentId);
1862
+ const { env: freshEnv } = await fetchResolvedEnv(opts.apiUrl, opts.apiKey, opts.agentId);
1770
1863
  const model = (freshEnv.MODEL_OVERRIDE as string) || "";
1771
1864
 
1772
1865
  const config: ProviderSessionConfig = {
@@ -1811,6 +1904,7 @@ async function checkCompletedProcesses(
1811
1904
  triggerType?: string;
1812
1905
  cursorUpdates?: Array<{ channelId: string; ts: string }>;
1813
1906
  workingDir?: string;
1907
+ credentialInfo?: RunningTask["credentialInfo"];
1814
1908
  }> = [];
1815
1909
 
1816
1910
  for (const [taskId, task] of state.activeTasks) {
@@ -1825,12 +1919,13 @@ async function checkCompletedProcesses(
1825
1919
  triggerType: task.triggerType,
1826
1920
  cursorUpdates: task.cursorUpdates,
1827
1921
  workingDir: task.workingDir,
1922
+ credentialInfo: task.credentialInfo,
1828
1923
  });
1829
1924
  }
1830
1925
  }
1831
1926
 
1832
1927
  // Remove completed tasks from the map and ensure they're marked as finished
1833
- for (const { taskId, result, cursorUpdates, workingDir } of completedTasks) {
1928
+ for (const { taskId, result, cursorUpdates, workingDir, credentialInfo } of completedTasks) {
1834
1929
  state.activeTasks.delete(taskId);
1835
1930
 
1836
1931
  if (apiConfig) {
@@ -1849,6 +1944,28 @@ async function checkCompletedProcesses(
1849
1944
  if (result.exitCode !== 0 && result.failureReason) {
1850
1945
  failureReason = result.failureReason;
1851
1946
  console.log(`[${role}] Detected error for task ${taskId.slice(0, 8)}: ${failureReason}`);
1947
+
1948
+ // If rate-limited and we know which key was used, report it
1949
+ if (credentialInfo && /rate.?limit/i.test(failureReason)) {
1950
+ // Try to extract reset time from the error message (e.g. "resets 3pm (UTC)")
1951
+ const parsedResetTime = parseRateLimitResetTime(failureReason);
1952
+ const defaultCooldownMs = 5 * 60 * 1000;
1953
+ const rateLimitedUntil =
1954
+ parsedResetTime ?? new Date(Date.now() + defaultCooldownMs).toISOString();
1955
+ if (parsedResetTime) {
1956
+ console.log(
1957
+ `[credentials] Parsed rate limit reset time from error: ${parsedResetTime}`,
1958
+ );
1959
+ }
1960
+ reportKeyRateLimit(
1961
+ apiConfig.apiUrl,
1962
+ apiConfig.apiKey,
1963
+ credentialInfo.keyType,
1964
+ credentialInfo.keySuffix,
1965
+ credentialInfo.keyIndex,
1966
+ rateLimitedUntil,
1967
+ ).catch(() => {});
1968
+ }
1852
1969
  }
1853
1970
  await ensureTaskFinished(apiConfig, role, taskId, result.exitCode, failureReason);
1854
1971