@desplega.ai/agent-swarm 1.92.2 → 1.94.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.
Files changed (122) hide show
  1. package/README.md +2 -2
  2. package/openapi.json +242 -3
  3. package/package.json +5 -5
  4. package/src/be/db.ts +152 -11
  5. package/src/be/memory/boot-reembed.ts +0 -1
  6. package/src/be/memory/providers/sqlite-store.ts +42 -25
  7. package/src/be/memory/raters/llm-client.ts +12 -5
  8. package/src/be/memory/types.ts +3 -0
  9. package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
  10. package/src/be/migrations/089_harness_variant.sql +2 -0
  11. package/src/be/migrations/090_model_tiers.sql +2 -0
  12. package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
  13. package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
  14. package/src/be/migrations/093_slack_message_tracking.sql +6 -0
  15. package/src/be/migrations/runner.ts +52 -0
  16. package/src/be/modelsdev-cache.json +3264 -1166
  17. package/src/be/scripts/boot-reembed.ts +74 -0
  18. package/src/be/scripts/db.ts +19 -3
  19. package/src/be/seed/index.ts +1 -1
  20. package/src/be/seed/registry.ts +2 -2
  21. package/src/be/seed/runner.ts +5 -5
  22. package/src/be/seed/types.ts +6 -1
  23. package/src/be/seed-pricing.ts +2 -0
  24. package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
  25. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
  26. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
  27. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
  28. package/src/be/seed-scripts/index.ts +8 -7
  29. package/src/be/skill-sync.ts +28 -179
  30. package/src/commands/runner.ts +197 -10
  31. package/src/http/api-keys.ts +42 -0
  32. package/src/http/index.ts +13 -2
  33. package/src/http/mcp-bridge.ts +1 -1
  34. package/src/http/memory.ts +23 -24
  35. package/src/http/metrics.ts +55 -6
  36. package/src/http/schedules.ts +16 -15
  37. package/src/http/script-runs.ts +7 -1
  38. package/src/http/scripts.ts +147 -1
  39. package/src/http/tasks.ts +17 -6
  40. package/src/model-tiers.ts +140 -0
  41. package/src/providers/claude-adapter.ts +33 -1
  42. package/src/providers/claude-managed-adapter.ts +3 -0
  43. package/src/providers/claude-managed-models.ts +16 -0
  44. package/src/providers/codex-adapter.ts +8 -1
  45. package/src/providers/codex-models.ts +1 -0
  46. package/src/providers/codex-oauth/auth-json.ts +1 -0
  47. package/src/providers/harness-version.ts +7 -0
  48. package/src/providers/opencode-adapter.ts +12 -4
  49. package/src/providers/pi-mono-adapter.ts +90 -8
  50. package/src/providers/types.ts +2 -0
  51. package/src/scheduler/scheduler.ts +22 -34
  52. package/src/scripts-runtime/egress-secrets.ts +83 -0
  53. package/src/scripts-runtime/eval-harness.ts +4 -0
  54. package/src/scripts-runtime/executors/types.ts +7 -0
  55. package/src/scripts-runtime/loader.ts +2 -0
  56. package/src/server-user.ts +8 -2
  57. package/src/slack/channel-join.ts +41 -0
  58. package/src/slack/responses.ts +39 -11
  59. package/src/slack/watcher.ts +121 -8
  60. package/src/tests/additive-buffer.test.ts +0 -1
  61. package/src/tests/agents-list-model-display.test.ts +13 -0
  62. package/src/tests/api-key-tracking.test.ts +113 -0
  63. package/src/tests/approval-requests.test.ts +0 -6
  64. package/src/tests/aws-error-classifier.test.ts +148 -0
  65. package/src/tests/claude-managed-adapter.test.ts +12 -0
  66. package/src/tests/claude-managed-setup.test.ts +0 -4
  67. package/src/tests/codex-pool.test.ts +2 -6
  68. package/src/tests/context-window.test.ts +7 -0
  69. package/src/tests/http-api-integration.test.ts +23 -6
  70. package/src/tests/memory-edges.test.ts +0 -2
  71. package/src/tests/memory-rate-endpoint.test.ts +0 -2
  72. package/src/tests/memory-rater-e2e.test.ts +0 -2
  73. package/src/tests/memory-store.test.ts +19 -1
  74. package/src/tests/memory.test.ts +51 -0
  75. package/src/tests/metrics-http.test.ts +137 -3
  76. package/src/tests/migration-046-budgets.test.ts +33 -0
  77. package/src/tests/migration-runner-regressions.test.ts +69 -0
  78. package/src/tests/model-control.test.ts +162 -46
  79. package/src/tests/opencode-adapter.test.ts +9 -0
  80. package/src/tests/pi-mono-adapter.test.ts +319 -0
  81. package/src/tests/providers/pi-cost.test.ts +9 -0
  82. package/src/tests/reload-config.test.ts +33 -17
  83. package/src/tests/runner-fallback-output.test.ts +50 -0
  84. package/src/tests/runner-skills-refresh.test.ts +216 -46
  85. package/src/tests/script-runs-http.test.ts +7 -1
  86. package/src/tests/scripts-boot-reembed.test.ts +163 -0
  87. package/src/tests/scripts-embeddings.test.ts +90 -0
  88. package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
  89. package/src/tests/seed-scripts.test.ts +13 -1
  90. package/src/tests/seed.test.ts +26 -1
  91. package/src/tests/session-attach.test.ts +6 -6
  92. package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
  93. package/src/tests/skill-fs-writer.test.ts +250 -0
  94. package/src/tests/slack-attachments-block.test.ts +0 -1
  95. package/src/tests/slack-blocks.test.ts +0 -1
  96. package/src/tests/slack-channel-join.test.ts +80 -0
  97. package/src/tests/slack-identity-resolution.test.ts +0 -1
  98. package/src/tests/slack-watcher.test.ts +66 -0
  99. package/src/tests/structured-output.test.ts +0 -2
  100. package/src/tests/use-dismissible-card.test.ts +0 -4
  101. package/src/tests/workflow-agent-task.test.ts +5 -2
  102. package/src/tests/workflow-validation-port-routing.test.ts +181 -0
  103. package/src/tools/memory-get.ts +11 -0
  104. package/src/tools/memory-search.ts +18 -0
  105. package/src/tools/schedules/create-schedule.ts +71 -70
  106. package/src/tools/schedules/update-schedule.ts +43 -31
  107. package/src/tools/send-task.ts +16 -5
  108. package/src/tools/slack-post.ts +18 -15
  109. package/src/tools/slack-read.ts +9 -11
  110. package/src/tools/slack-reply.ts +18 -15
  111. package/src/tools/slack-start-thread.ts +17 -14
  112. package/src/tools/task-action.ts +11 -3
  113. package/src/types.ts +40 -0
  114. package/src/utils/aws-error-classifier.ts +97 -0
  115. package/src/utils/context-window.ts +5 -0
  116. package/src/utils/credentials.test.ts +68 -0
  117. package/src/utils/credentials.ts +66 -5
  118. package/src/utils/pretty-print.ts +25 -10
  119. package/src/utils/skill-fs-writer.ts +220 -0
  120. package/src/utils/skills-refresh.ts +123 -40
  121. package/src/workflows/engine.ts +3 -2
  122. package/src/workflows/executors/agent-task.ts +3 -1
@@ -6,85 +6,19 @@
6
6
  * so Claude Code, Pi, and Codex discover them natively.
7
7
  *
8
8
  * This runs on the API side — workers call it via POST /api/skills/sync-filesystem.
9
+ * The actual FS write logic lives in the worker-safe src/utils/skill-fs-writer.ts
10
+ * so workers can also call it locally with their own homedir().
9
11
  */
10
12
 
11
- import type { Dirent } from "node:fs";
12
- import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
13
13
  import { homedir } from "node:os";
14
- import { dirname, join } from "node:path";
14
+ import {
15
+ type SkillFsEntry,
16
+ type SkillSyncResult,
17
+ writeSkillsToFilesystem,
18
+ } from "../utils/skill-fs-writer";
15
19
  import { getAgentSkills, getSkillFiles } from "./db";
16
20
 
17
- export interface SkillSyncResult {
18
- synced: number;
19
- removed: number;
20
- errors: string[];
21
- }
22
-
23
- /**
24
- * Marker file written into every swarm-managed skill directory. Cleanup
25
- * only ever removes directories that contain this marker, so unrelated
26
- * personal skills the user installed via the harness's own tooling (e.g.
27
- * `codex skills add ...` writing into `~/.codex/skills/<name>/`) are left
28
- * untouched even when the API server shares a HOME with the worker (local
29
- * dev). See `~/.codex/skills` blast-radius note in PR #555.
30
- */
31
- const SWARM_MARKER_FILE = ".swarm-managed";
32
-
33
- function reconcileManagedSkillFiles(skillDir: string, currentRelativeFiles: Set<string>): number {
34
- if (!existsSync(join(skillDir, SWARM_MARKER_FILE))) return 0;
35
-
36
- let removed = 0;
37
-
38
- const walk = (dir: string, relativeDir = ""): boolean => {
39
- let entries: Dirent[];
40
- try {
41
- entries = readdirSync(dir, { withFileTypes: true });
42
- } catch {
43
- return false;
44
- }
45
-
46
- let hasEntries = false;
47
- for (const entry of entries) {
48
- const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
49
- const fullPath = join(dir, entry.name);
50
-
51
- if (entry.isDirectory()) {
52
- const childHasEntries = walk(fullPath, relativePath);
53
- if (!childHasEntries) {
54
- try {
55
- rmSync(fullPath, { recursive: true, force: true });
56
- } catch {
57
- hasEntries = true;
58
- }
59
- } else {
60
- hasEntries = true;
61
- }
62
- continue;
63
- }
64
-
65
- if (
66
- relativePath === "SKILL.md" ||
67
- relativePath === SWARM_MARKER_FILE ||
68
- currentRelativeFiles.has(relativePath)
69
- ) {
70
- hasEntries = true;
71
- continue;
72
- }
73
-
74
- try {
75
- rmSync(fullPath, { force: true });
76
- removed++;
77
- } catch {
78
- hasEntries = true;
79
- }
80
- }
81
-
82
- return hasEntries;
83
- };
84
-
85
- walk(skillDir);
86
- return removed;
87
- }
21
+ export type { SkillSyncResult };
88
22
 
89
23
  /**
90
24
  * Sync agent's installed skills to the filesystem.
@@ -92,6 +26,9 @@ function reconcileManagedSkillFiles(skillDir: string, currentRelativeFiles: Set<
92
26
  * For simple skills (content in DB): writes SKILL.md to ~/.claude/skills/<name>/
93
27
  * For DB-backed complex skills: writes SKILL.md plus bundled skill_files rows.
94
28
  * Legacy complex skills without skill_files remain handled by npx in entrypoint.
29
+ *
30
+ * API-side adapter: fetches skill data from DB, builds SkillFsEntry[], then
31
+ * delegates all FS writes to writeSkillsToFilesystem() from skill-fs-writer.ts.
95
32
  */
96
33
  export function syncSkillsToFilesystem(
97
34
  agentId: string,
@@ -100,112 +37,24 @@ export function syncSkillsToFilesystem(
100
37
  ): SkillSyncResult {
101
38
  const skills = getAgentSkills(agentId);
102
39
  const home = homeOverride ?? homedir();
103
- const errors: string[] = [];
104
- let synced = 0;
105
- let removed = 0;
106
-
107
- // Directories to write to
108
- const skillDirs: string[] = [];
109
- if (harnessType === "claude" || harnessType === "all") {
110
- skillDirs.push(join(home, ".claude", "skills"));
111
- }
112
- if (harnessType === "pi" || harnessType === "all") {
113
- skillDirs.push(join(home, ".pi", "agent", "skills"));
114
- }
115
- if (harnessType === "codex" || harnessType === "all") {
116
- skillDirs.push(join(home, ".codex", "skills"));
117
- }
118
-
119
- // Ensure base dirs exist
120
- for (const dir of skillDirs) {
121
- mkdirSync(dir, { recursive: true });
122
- }
123
-
124
- // Track which skill names we write (for cleanup)
125
- const writtenNames = new Set<string>();
126
-
127
- for (const skill of skills) {
128
- if (!skill.isActive || !skill.isEnabled) continue;
129
- const bundledFiles = skill.isComplex ? getSkillFiles(skill.id) : [];
130
- if (skill.isComplex && bundledFiles.length === 0) continue; // Legacy complex skills handled by npx
131
- if (!skill.content) continue;
132
-
133
- // Sanitize skill name to prevent path traversal (strip /, .., and non-safe chars)
134
- const safeName = skill.name.replace(/[^a-zA-Z0-9_-]/g, "_");
135
- if (!safeName) continue;
136
-
137
- writtenNames.add(safeName);
138
- const currentBundledFilePaths = new Set(
139
- bundledFiles.filter((file) => !file.isBinary).map((file) => file.path),
140
- );
141
-
142
- for (const baseDir of skillDirs) {
143
- const skillDir = join(baseDir, safeName);
144
- const skillFile = join(skillDir, "SKILL.md");
145
- const markerFile = join(skillDir, SWARM_MARKER_FILE);
146
-
147
- try {
148
- mkdirSync(skillDir, { recursive: true });
149
- removed += reconcileManagedSkillFiles(skillDir, currentBundledFilePaths);
150
- writeFileSync(skillFile, skill.content, "utf-8");
151
- writeFileSync(markerFile, "", "utf-8");
152
- synced++;
153
- } catch (err) {
154
- const msg = err instanceof Error ? err.message : "Unknown error";
155
- errors.push(`${skill.name} -> ${skillDir}: ${msg}`);
156
- console.error(
157
- `[skill-sync] Failed to write SKILL.md for ${skill.name} to ${skillDir}: ${msg}`,
158
- );
159
- }
160
-
161
- for (const file of bundledFiles) {
162
- if (file.isBinary) {
163
- console.log(`[skill-sync] Skipping binary skill file ${skill.name}/${file.path}`);
164
- continue;
165
- }
166
-
167
- const targetPath = join(skillDir, file.path);
168
- try {
169
- mkdirSync(dirname(targetPath), { recursive: true });
170
- writeFileSync(targetPath, file.content, "utf-8");
171
- } catch (err) {
172
- const msg = err instanceof Error ? err.message : "Unknown error";
173
- errors.push(`${skill.name}/${file.path} -> ${targetPath}: ${msg}`);
174
- console.error(
175
- `[skill-sync] Failed to write bundled file ${skill.name}/${file.path} to ${targetPath}: ${msg}`,
176
- );
177
- }
178
- }
179
- }
180
- }
181
-
182
- // Cleanup: only remove directories WE previously created (marker file
183
- // present). Leaves user-installed personal skills alone — important on
184
- // local dev where ~/.codex/skills holds skills the user installed
185
- // outside the swarm.
186
- for (const baseDir of skillDirs) {
187
- if (!existsSync(baseDir)) continue;
188
-
189
- try {
190
- const existing = readdirSync(baseDir, { withFileTypes: true });
191
- for (const entry of existing) {
192
- if (!entry.isDirectory()) continue;
193
- if (writtenNames.has(entry.name)) continue;
194
- const skillDir = join(baseDir, entry.name);
195
- if (!existsSync(join(skillDir, SWARM_MARKER_FILE))) continue;
196
- try {
197
- rmSync(skillDir, { recursive: true, force: true });
198
- removed++;
199
- } catch {
200
- // Non-fatal — skip cleanup errors
201
- }
202
- }
203
- } catch {
204
- // Non-fatal — skip if we can't read the directory
205
- }
206
- }
207
40
 
208
- return { synced, removed, errors };
41
+ const entries: SkillFsEntry[] = skills.map((skill) => ({
42
+ id: skill.id,
43
+ name: skill.name,
44
+ content: skill.content ?? null,
45
+ isComplex: skill.isComplex,
46
+ isEnabled: skill.isEnabled,
47
+ isActive: skill.isActive,
48
+ files: skill.isComplex
49
+ ? getSkillFiles(skill.id).map((f) => ({
50
+ path: f.path,
51
+ content: f.content,
52
+ isBinary: f.isBinary,
53
+ }))
54
+ : [],
55
+ }));
56
+
57
+ return writeSkillsToFilesystem(entries, harnessType, home);
209
58
  }
210
59
 
211
60
  export interface SkillsSignature {
@@ -2,6 +2,7 @@ import { existsSync, statSync } from "node:fs";
2
2
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { ensure, initialize } from "@desplega.ai/business-use";
4
4
  import type { TemplateResponse } from "../../templates/schema.ts";
5
+ import { resolveTaskModelSelection } from "../model-tiers.ts";
5
6
  import {
6
7
  type Attributes,
7
8
  initOtel,
@@ -350,6 +351,7 @@ async function fetchResolvedEnv(
350
351
  apiKey: string,
351
352
  agentId: string,
352
353
  baseEnv: Record<string, string | undefined> = process.env,
354
+ taskModel?: string,
353
355
  ): Promise<ResolvedEnvResult> {
354
356
  const env: Record<string, string | undefined> = { ...baseEnv };
355
357
 
@@ -382,6 +384,12 @@ async function fetchResolvedEnv(
382
384
 
383
385
  const resolvedProvider = resolveHarnessProvider(env, baseEnv);
384
386
 
387
+ // Effective model: per-task model takes priority over the agent-level
388
+ // MODEL_OVERRIDE from swarm_config. Passed to resolveCredentialPools so
389
+ // the harness × model matrix can exclude incompatible credential vars
390
+ // (e.g. OPENAI_API_KEY when an OpenRouter model is selected on opencode).
391
+ const effectiveModel = taskModel || (env.MODEL_OVERRIDE as string | undefined) || "";
392
+
385
393
  const credentialSelections = await resolveCredentialPools(env, {
386
394
  apiUrl,
387
395
  apiKey,
@@ -393,6 +401,7 @@ async function fetchResolvedEnv(
393
401
  // Use the resolved provider (swarm_config > env) so an operator can flip
394
402
  // the worker's harness from the dashboard without restarting the container.
395
403
  provider: resolvedProvider,
404
+ model: effectiveModel,
396
405
  });
397
406
 
398
407
  return { env, credentialSelections, resolvedProvider };
@@ -867,6 +876,7 @@ export async function ensureTaskFinished(
867
876
  * from the resolved swarm_config value. Falls back to env when omitted.
868
877
  */
869
878
  provider?: ProviderName,
879
+ failureDiagnostics?: string,
870
880
  ): Promise<void> {
871
881
  const headers: Record<string, string> = {
872
882
  "X-Agent-ID": config.agentId,
@@ -883,6 +893,9 @@ export async function ensureTaskFinished(
883
893
 
884
894
  if (status === "failed") {
885
895
  body.failureReason = failureReason || `Claude process exited with code ${exitCode}`;
896
+ if (failureDiagnostics) {
897
+ body.failureReason = `${body.failureReason}\n\n${failureDiagnostics}`;
898
+ }
886
899
  } else if (providerOutput) {
887
900
  const validation = await validateProviderOutputIfNeeded(config, taskId, providerOutput);
888
901
  if (validation.ok) {
@@ -1110,6 +1123,35 @@ async function reportKeyRateLimit(
1110
1123
  }
1111
1124
  }
1112
1125
 
1126
+ /** Clear a stale rate-limit record after a successful task (fire-and-forget) */
1127
+ async function reportKeyClearRateLimit(
1128
+ apiUrl: string,
1129
+ apiKey: string,
1130
+ keyType: string,
1131
+ keySuffix: string,
1132
+ ): Promise<void> {
1133
+ try {
1134
+ const resp = await fetch(`${apiUrl}/api/keys/clear-rate-limit`, {
1135
+ method: "POST",
1136
+ headers: {
1137
+ "Content-Type": "application/json",
1138
+ Authorization: `Bearer ${apiKey}`,
1139
+ },
1140
+ body: JSON.stringify({ keyType, keySuffix }),
1141
+ });
1142
+ if (resp.ok) {
1143
+ const data = (await resp.json()) as { cleared?: boolean };
1144
+ if (data.cleared) {
1145
+ console.log(
1146
+ `[credentials] Cleared stale rate-limit for ...${keySuffix} after successful task`,
1147
+ );
1148
+ }
1149
+ }
1150
+ } catch {
1151
+ // Non-blocking
1152
+ }
1153
+ }
1154
+
1113
1155
  /**
1114
1156
  * Supersede a task via the API (for graceful shutdown / context-limit /
1115
1157
  * operator-triggered). Returns `{ ok: true, resumeTaskId }` on success.
@@ -1472,6 +1514,8 @@ interface RunningTask {
1472
1514
  * provider before it completed, and vice versa).
1473
1515
  */
1474
1516
  hasLocalEnvironment: boolean;
1517
+ /** Harness variant captured on session_init (e.g. "bridge" or "stock") */
1518
+ harnessVariant?: string;
1475
1519
  }
1476
1520
 
1477
1521
  /** Runner state for tracking concurrent tasks */
@@ -1590,6 +1634,8 @@ async function saveProviderSessionId(
1590
1634
  provider?: ProviderName,
1591
1635
  providerMeta?: Record<string, unknown>,
1592
1636
  model?: string,
1637
+ harnessVariant?: string,
1638
+ harnessVariantMeta?: Record<string, unknown>,
1593
1639
  ): Promise<void> {
1594
1640
  const headers: Record<string, string> = { "Content-Type": "application/json" };
1595
1641
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
@@ -1597,13 +1643,71 @@ async function saveProviderSessionId(
1597
1643
  if (provider !== undefined) body.provider = provider;
1598
1644
  if (providerMeta !== undefined) body.providerMeta = providerMeta;
1599
1645
  if (model !== undefined && model !== "") body.model = model;
1600
- await fetch(`${apiUrl}/api/tasks/${taskId}/claude-session`, {
1646
+ if (harnessVariant !== undefined) body.harnessVariant = harnessVariant;
1647
+ if (harnessVariantMeta !== undefined) body.harnessVariantMeta = harnessVariantMeta;
1648
+ await fetch(`${apiUrl}/api/tasks/${taskId}/session`, {
1601
1649
  method: "PUT",
1602
1650
  headers,
1603
1651
  body: JSON.stringify(body),
1604
1652
  });
1605
1653
  }
1606
1654
 
1655
+ async function findBridgeFailureArtifact(cwd: string): Promise<string | undefined> {
1656
+ try {
1657
+ const bridgeDir = `${cwd}/.claude-bridge/runs`;
1658
+ const dir = await Array.fromAsync(
1659
+ new Bun.Glob("*/tmux-pane-final.txt").scan({ cwd: bridgeDir, absolute: true }),
1660
+ );
1661
+ if (dir.length === 0) return undefined;
1662
+ dir.sort();
1663
+ return dir[dir.length - 1];
1664
+ } catch {
1665
+ return undefined;
1666
+ }
1667
+ }
1668
+
1669
+ async function readBridgeFailureTail(
1670
+ artifactPath: string,
1671
+ maxLines = 40,
1672
+ maxChars = 4000,
1673
+ ): Promise<string | undefined> {
1674
+ try {
1675
+ const text = await Bun.file(artifactPath).text();
1676
+ const tail = text.split(/\r?\n/).slice(-maxLines).join("\n").trim();
1677
+ if (!tail) return undefined;
1678
+ return tail.length > maxChars ? tail.slice(-maxChars) : tail;
1679
+ } catch {
1680
+ return undefined;
1681
+ }
1682
+ }
1683
+
1684
+ export async function getBridgeFailureDiagnostics(
1685
+ cwd: string,
1686
+ ): Promise<{ artifactPath: string; paneTail?: string } | undefined> {
1687
+ const artifactPath = await findBridgeFailureArtifact(cwd);
1688
+ if (!artifactPath) return undefined;
1689
+ return {
1690
+ artifactPath,
1691
+ paneTail: await readBridgeFailureTail(artifactPath),
1692
+ };
1693
+ }
1694
+
1695
+ async function updateHarnessVariantMeta(
1696
+ apiUrl: string,
1697
+ apiKey: string,
1698
+ taskId: string,
1699
+ claudeSessionId: string,
1700
+ meta: Record<string, unknown>,
1701
+ ): Promise<void> {
1702
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
1703
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
1704
+ await fetch(`${apiUrl}/api/tasks/${taskId}/session`, {
1705
+ method: "PUT",
1706
+ headers,
1707
+ body: JSON.stringify({ claudeSessionId, harnessVariantMeta: meta }),
1708
+ });
1709
+ }
1710
+
1607
1711
  /** Cache of tasks that already have VCS linked — prevents repeated gh pr list calls */
1608
1712
  const vcsDetectedTasks = new Set<string>();
1609
1713
 
@@ -2454,6 +2558,7 @@ async function spawnProviderProcess(
2454
2558
  iteration: number;
2455
2559
  taskId?: string;
2456
2560
  model?: string;
2561
+ modelTier?: string;
2457
2562
  resumeSessionId?: string;
2458
2563
  harnessProvider: ProviderName;
2459
2564
  cwd?: string;
@@ -2467,11 +2572,15 @@ async function spawnProviderProcess(
2467
2572
  // Correlation ID for logs/display — always defined
2468
2573
  const effectiveTaskId = realTaskId || crypto.randomUUID();
2469
2574
 
2470
- // Resolve env first so we can use MODEL_OVERRIDE from config
2575
+ // Resolve env first so we can use MODEL_OVERRIDE from config.
2576
+ // Pass opts.model (per-task model) so the credential picker can apply
2577
+ // the harness × model matrix (e.g. exclude OPENAI_API_KEY for OpenRouter models).
2471
2578
  const { env: freshEnv, credentialSelections } = await fetchResolvedEnv(
2472
2579
  opts.apiUrl,
2473
2580
  opts.apiKey,
2474
2581
  opts.agentId,
2582
+ process.env,
2583
+ opts.model,
2475
2584
  );
2476
2585
 
2477
2586
  // Report which key was selected for this task (fire-and-forget)
@@ -2488,15 +2597,31 @@ async function spawnProviderProcess(
2488
2597
  }
2489
2598
 
2490
2599
  const configModel = (freshEnv.MODEL_OVERRIDE as string | undefined) || "";
2491
- const model = opts.model || configModel || "";
2600
+ const taskModelSelection = resolveTaskModelSelection({
2601
+ model: opts.model,
2602
+ modelTier: opts.modelTier,
2603
+ harnessProvider: opts.harnessProvider,
2604
+ env: freshEnv,
2605
+ });
2606
+ const taskModel = taskModelSelection.model || "";
2607
+ const model = taskModel || configModel || "";
2492
2608
 
2493
2609
  // Resolve Codex OAuth pool slot BEFORE building ProviderSessionConfig so we
2494
2610
  // can pass codexSlot through and the adapter writes token refreshes back to
2495
2611
  // the correct slot key (codex_oauth_<slot>) instead of defaulting to slot 0.
2612
+ //
2613
+ // Always resolve for codex (not just when credentialSelections is empty) so
2614
+ // that if the OPENAI_API_KEY credential is rate-limited we can fail over to
2615
+ // a CODEX_OAUTH slot — even though the keyType differs.
2496
2616
  let oauthSelection: CredentialSelection | undefined;
2497
- if (adapter.name === "codex" && credentialSelections.length === 0) {
2617
+ if (adapter.name === "codex") {
2498
2618
  oauthSelection = (await resolveCodexOAuthCredentialInfo(opts.apiUrl, opts.apiKey)) ?? undefined;
2499
- if (oauthSelection && realTaskId) {
2619
+ const oauthIsPrimary =
2620
+ credentialSelections.length === 0 ||
2621
+ (credentialSelections[0]?.isRateLimitFallback &&
2622
+ oauthSelection &&
2623
+ !oauthSelection.isRateLimitFallback);
2624
+ if (oauthSelection && realTaskId && oauthIsPrimary) {
2500
2625
  reportKeyUsage(
2501
2626
  opts.apiUrl,
2502
2627
  opts.apiKey,
@@ -2570,7 +2695,7 @@ async function spawnProviderProcess(
2570
2695
  );
2571
2696
  const initialModelReport = buildLatestModelReport({
2572
2697
  model,
2573
- taskModel: opts.model,
2698
+ taskModel,
2574
2699
  configModel,
2575
2700
  taskId: realTaskId,
2576
2701
  harnessProvider: opts.harnessProvider,
@@ -2676,6 +2801,8 @@ async function spawnProviderProcess(
2676
2801
  event.provider,
2677
2802
  event.providerMeta,
2678
2803
  model,
2804
+ event.harnessVariant,
2805
+ event.harnessVariantMeta,
2679
2806
  ).catch((err) => console.warn(`[runner] Failed to save session ID: ${err}`));
2680
2807
  } else {
2681
2808
  // Pool task: save provider session ID on active session so it can be
@@ -2690,6 +2817,17 @@ async function spawnProviderProcess(
2690
2817
  );
2691
2818
  }
2692
2819
 
2820
+ // Structured session-start log for observability (covers all providers)
2821
+ {
2822
+ const variant = event.harnessVariant ?? "unknown";
2823
+ const version =
2824
+ (event.harnessVariantMeta as Record<string, unknown> | undefined)?.version ??
2825
+ "unknown";
2826
+ console.log(
2827
+ `[${opts.role}] [harness] provider=${event.provider ?? opts.harnessProvider} variant=${variant} version=${version} model=${model || "default"}`,
2828
+ );
2829
+ }
2830
+
2693
2831
  // Buffer session start event
2694
2832
  bufferEvent({
2695
2833
  category: "session",
@@ -3085,8 +3223,23 @@ async function spawnProviderProcess(
3085
3223
  }),
3086
3224
  );
3087
3225
 
3088
- // Build credential info for rate limit tracking
3089
- const primarySelection = credentialSelections[0] ?? oauthSelection;
3226
+ // Build credential info for rate limit tracking.
3227
+ // For codex: when OPENAI_API_KEY is rate-limited but CODEX_OAUTH has
3228
+ // available slots (or vice versa), prefer the healthy credential.
3229
+ let primarySelection: CredentialSelection | undefined;
3230
+ const firstCred = credentialSelections[0];
3231
+ if (firstCred && oauthSelection) {
3232
+ if (firstCred.isRateLimitFallback && !oauthSelection.isRateLimitFallback) {
3233
+ primarySelection = oauthSelection;
3234
+ console.log(
3235
+ `[credentials] Cross-keyType failover: ${firstCred.keyType} all rate-limited, using ${oauthSelection.keyType} [...${oauthSelection.keySuffix}]`,
3236
+ );
3237
+ } else {
3238
+ primarySelection = firstCred;
3239
+ }
3240
+ } else {
3241
+ primarySelection = firstCred ?? oauthSelection;
3242
+ }
3090
3243
  const credentialInfo = primarySelection
3091
3244
  ? {
3092
3245
  keyType: primarySelection.keyType,
@@ -3160,7 +3313,14 @@ async function checkCompletedProcesses(
3160
3313
  }
3161
3314
 
3162
3315
  // Remove completed tasks from the map and ensure they're marked as finished
3163
- for (const { taskId, result, cursorUpdates, workingDir, credentialInfo } of completedTasks) {
3316
+ for (const {
3317
+ taskId,
3318
+ result,
3319
+ cursorUpdates,
3320
+ workingDir,
3321
+ credentialInfo,
3322
+ harnessProvider,
3323
+ } of completedTasks) {
3164
3324
  state.activeTasks.delete(taskId);
3165
3325
  vcsDetectedTasks.delete(taskId);
3166
3326
  vcsCheckTimestamps.delete(taskId);
@@ -3244,6 +3404,20 @@ async function checkCompletedProcesses(
3244
3404
  rateLimitedUntil,
3245
3405
  ).catch(() => {});
3246
3406
  }
3407
+ let bridgeDiagnostics: Awaited<ReturnType<typeof getBridgeFailureDiagnostics>> | undefined;
3408
+ if (result.exitCode !== 0 && harnessProvider === "claude" && workingDir) {
3409
+ bridgeDiagnostics = await getBridgeFailureDiagnostics(workingDir);
3410
+ if (bridgeDiagnostics?.artifactPath && result.sessionId) {
3411
+ console.log(`[${role}] Bridge failure artifact found: ${bridgeDiagnostics.artifactPath}`);
3412
+ updateHarnessVariantMeta(apiConfig.apiUrl, apiConfig.apiKey, taskId, result.sessionId, {
3413
+ failureArtifact: bridgeDiagnostics.artifactPath,
3414
+ }).catch((err) => console.warn(`[runner] Failed to update harness variant meta: ${err}`));
3415
+ }
3416
+ }
3417
+ const bridgeFailureDiagnostics =
3418
+ bridgeDiagnostics?.paneTail != null
3419
+ ? `Claude bridge final tmux pane tail (${bridgeDiagnostics.artifactPath}):\n${bridgeDiagnostics.paneTail}`
3420
+ : undefined;
3247
3421
  await ensureTaskFinished(
3248
3422
  apiConfig,
3249
3423
  role,
@@ -3251,9 +3425,19 @@ async function checkCompletedProcesses(
3251
3425
  result.exitCode,
3252
3426
  failureReason,
3253
3427
  result.output,
3254
- state.harnessProvider,
3428
+ harnessProvider,
3429
+ bridgeFailureDiagnostics,
3255
3430
  );
3256
3431
 
3432
+ if (result.exitCode === 0 && credentialInfo) {
3433
+ reportKeyClearRateLimit(
3434
+ apiConfig.apiUrl,
3435
+ apiConfig.apiKey,
3436
+ credentialInfo.keyType,
3437
+ credentialInfo.keySuffix,
3438
+ ).catch(() => {});
3439
+ }
3440
+
3257
3441
  ensure({
3258
3442
  id: "worker_process_finished",
3259
3443
  flow: "task",
@@ -4274,6 +4458,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4274
4458
  iteration,
4275
4459
  taskId: task.id,
4276
4460
  model: (task as { model?: string }).model,
4461
+ modelTier: (task as { modelTier?: string }).modelTier,
4277
4462
  harnessProvider: state.harnessProvider,
4278
4463
  cwd: resumeCwd,
4279
4464
  vcsRepo: task.vcsRepo,
@@ -4593,6 +4778,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4593
4778
 
4594
4779
  // Extract model from task data for per-task model selection
4595
4780
  const taskModel = (trigger.task as { model?: string } | undefined)?.model;
4781
+ const taskModelTier = (trigger.task as { modelTier?: string } | undefined)?.modelTier;
4596
4782
 
4597
4783
  // Detect Slack context for conditional prompt sections
4598
4784
  const taskSlackChannelId = (trigger.task as { slackChannelId?: string } | undefined)
@@ -4735,6 +4921,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4735
4921
  iteration,
4736
4922
  taskId: trigger.taskId,
4737
4923
  model: taskModel,
4924
+ modelTier: taskModelTier,
4738
4925
  harnessProvider: state.harnessProvider,
4739
4926
  cwd: effectiveCwd,
4740
4927
  vcsRepo: taskVcsRepo,
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
3
  import {
4
+ clearKeyRateLimit,
4
5
  getAvailableKeyIndices,
5
6
  getKeyCostSummary,
6
7
  getKeyStatuses,
@@ -134,6 +135,26 @@ const setKeyName = route({
134
135
  auth: { apiKey: true },
135
136
  });
136
137
 
138
+ const clearRateLimitRoute = route({
139
+ method: "post",
140
+ path: "/api/keys/clear-rate-limit",
141
+ pattern: ["api", "keys", "clear-rate-limit"],
142
+ summary: "Clear rate-limited status for a key after a successful use proves it is healthy",
143
+ tags: ["API Keys"],
144
+ body: z.object({
145
+ keyType: z.string(),
146
+ keySuffix: z.string().min(1).max(10),
147
+ scope: z.string().optional(),
148
+ scopeId: z.string().optional(),
149
+ }),
150
+ responses: {
151
+ 200: { description: "Rate limit cleared (or key was not rate-limited)" },
152
+ 400: { description: "Validation error" },
153
+ 401: { description: "Unauthorized" },
154
+ },
155
+ auth: { apiKey: true },
156
+ });
157
+
137
158
  // ─── Handler ─────────────────────────────────────────────────────────────────
138
159
 
139
160
  export async function handleApiKeys(
@@ -242,5 +263,26 @@ export async function handleApiKeys(
242
263
  return true;
243
264
  }
244
265
 
266
+ // POST /api/keys/clear-rate-limit
267
+ if (clearRateLimitRoute.match(req.method, pathSegments)) {
268
+ const parsed = await clearRateLimitRoute.parse(req, res, pathSegments, queryParams);
269
+ if (!parsed) return true;
270
+
271
+ const { keyType, keySuffix, scope, scopeId } = parsed.body;
272
+ try {
273
+ const cleared = clearKeyRateLimit(keyType, keySuffix, scope, scopeId ?? null);
274
+ json(res, {
275
+ success: true,
276
+ cleared,
277
+ message: cleared
278
+ ? `Rate limit cleared for ...${keySuffix}`
279
+ : `Key ...${keySuffix} was not rate-limited`,
280
+ });
281
+ } catch (err) {
282
+ jsonError(res, err instanceof Error ? err.message : "Failed to clear rate limit", 500);
283
+ }
284
+ return true;
285
+ }
286
+
245
287
  return false;
246
288
  }