@desplega.ai/agent-swarm 1.92.1 → 1.93.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 (87) hide show
  1. package/openapi.json +63 -3
  2. package/package.json +5 -5
  3. package/src/be/db.ts +180 -6
  4. package/src/be/memory/boot-reembed.ts +84 -0
  5. package/src/be/memory/constants.ts +42 -1
  6. package/src/be/memory/providers/openai-embedding.ts +13 -0
  7. package/src/be/memory/providers/sqlite-store.ts +75 -26
  8. package/src/be/memory/raters/llm-client.ts +12 -5
  9. package/src/be/memory/reranker.ts +35 -17
  10. package/src/be/memory/types.ts +11 -0
  11. package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
  12. package/src/be/migrations/089_harness_variant.sql +2 -0
  13. package/src/be/modelsdev-cache.json +6478 -3099
  14. package/src/be/seed-pricing.ts +1 -0
  15. package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
  16. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
  17. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
  18. package/src/be/seed-scripts/catalog/compound-insights.ts +371 -0
  19. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
  20. package/src/be/seed-scripts/index.ts +5 -5
  21. package/src/be/skill-sync.ts +28 -179
  22. package/src/commands/runner.ts +124 -7
  23. package/src/http/api-keys.ts +42 -0
  24. package/src/http/index.ts +9 -0
  25. package/src/http/mcp-bridge.ts +1 -1
  26. package/src/http/memory.ts +27 -24
  27. package/src/http/tasks.ts +10 -6
  28. package/src/providers/claude-adapter.ts +33 -1
  29. package/src/providers/claude-managed-adapter.ts +3 -0
  30. package/src/providers/claude-managed-models.ts +7 -0
  31. package/src/providers/codex-adapter.ts +8 -1
  32. package/src/providers/codex-models.ts +1 -0
  33. package/src/providers/codex-oauth/auth-json.ts +1 -0
  34. package/src/providers/harness-version.ts +7 -0
  35. package/src/providers/opencode-adapter.ts +11 -4
  36. package/src/providers/pi-mono-adapter.ts +12 -2
  37. package/src/providers/types.ts +2 -0
  38. package/src/scripts-runtime/egress-secrets.ts +83 -0
  39. package/src/scripts-runtime/eval-harness.ts +4 -0
  40. package/src/scripts-runtime/executors/types.ts +7 -0
  41. package/src/scripts-runtime/loader.ts +2 -0
  42. package/src/server-user.ts +2 -2
  43. package/src/slack/channel-join.ts +41 -0
  44. package/src/tasks/worker-follow-up.ts +12 -0
  45. package/src/tests/additive-buffer.test.ts +0 -1
  46. package/src/tests/api-key-tracking.test.ts +113 -0
  47. package/src/tests/approval-requests.test.ts +0 -6
  48. package/src/tests/claude-managed-setup.test.ts +0 -4
  49. package/src/tests/codex-pool.test.ts +2 -6
  50. package/src/tests/http-api-integration.test.ts +4 -6
  51. package/src/tests/memory-e2e.test.ts +6 -6
  52. package/src/tests/memory-edges.test.ts +0 -2
  53. package/src/tests/memory-rate-endpoint.test.ts +0 -2
  54. package/src/tests/memory-rater-e2e.test.ts +4 -7
  55. package/src/tests/memory-reranker.test.ts +135 -124
  56. package/src/tests/memory-store.test.ts +19 -1
  57. package/src/tests/memory.test.ts +64 -12
  58. package/src/tests/model-control.test.ts +1 -1
  59. package/src/tests/reload-config.test.ts +33 -17
  60. package/src/tests/runner-skills-refresh.test.ts +216 -46
  61. package/src/tests/script-runs-http.test.ts +7 -1
  62. package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
  63. package/src/tests/seed-scripts.test.ts +218 -1
  64. package/src/tests/session-attach.test.ts +6 -6
  65. package/src/tests/skill-fs-writer.test.ts +250 -0
  66. package/src/tests/slack-attachments-block.test.ts +0 -1
  67. package/src/tests/slack-blocks.test.ts +0 -1
  68. package/src/tests/slack-channel-join.test.ts +80 -0
  69. package/src/tests/slack-identity-resolution.test.ts +0 -1
  70. package/src/tests/structured-output.test.ts +0 -2
  71. package/src/tests/task-cascade-fail.test.ts +304 -0
  72. package/src/tests/use-dismissible-card.test.ts +0 -4
  73. package/src/tools/schedules/create-schedule.ts +2 -2
  74. package/src/tools/schedules/update-schedule.ts +1 -1
  75. package/src/tools/send-task.ts +2 -2
  76. package/src/tools/slack-post.ts +18 -15
  77. package/src/tools/slack-read.ts +9 -11
  78. package/src/tools/slack-reply.ts +18 -15
  79. package/src/tools/slack-start-thread.ts +17 -14
  80. package/src/tools/task-action.ts +2 -2
  81. package/src/types.ts +11 -0
  82. package/src/utils/context-window.ts +3 -0
  83. package/src/utils/credentials.ts +22 -2
  84. package/src/utils/skill-fs-writer.ts +220 -0
  85. package/src/utils/skills-refresh.ts +123 -40
  86. package/templates/workflows/llm-safe-release-context/config.json +13 -0
  87. package/templates/workflows/llm-safe-release-context/content.md +69 -0
@@ -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 {
@@ -1110,6 +1110,35 @@ async function reportKeyRateLimit(
1110
1110
  }
1111
1111
  }
1112
1112
 
1113
+ /** Clear a stale rate-limit record after a successful task (fire-and-forget) */
1114
+ async function reportKeyClearRateLimit(
1115
+ apiUrl: string,
1116
+ apiKey: string,
1117
+ keyType: string,
1118
+ keySuffix: string,
1119
+ ): Promise<void> {
1120
+ try {
1121
+ const resp = await fetch(`${apiUrl}/api/keys/clear-rate-limit`, {
1122
+ method: "POST",
1123
+ headers: {
1124
+ "Content-Type": "application/json",
1125
+ Authorization: `Bearer ${apiKey}`,
1126
+ },
1127
+ body: JSON.stringify({ keyType, keySuffix }),
1128
+ });
1129
+ if (resp.ok) {
1130
+ const data = (await resp.json()) as { cleared?: boolean };
1131
+ if (data.cleared) {
1132
+ console.log(
1133
+ `[credentials] Cleared stale rate-limit for ...${keySuffix} after successful task`,
1134
+ );
1135
+ }
1136
+ }
1137
+ } catch {
1138
+ // Non-blocking
1139
+ }
1140
+ }
1141
+
1113
1142
  /**
1114
1143
  * Supersede a task via the API (for graceful shutdown / context-limit /
1115
1144
  * operator-triggered). Returns `{ ok: true, resumeTaskId }` on success.
@@ -1472,6 +1501,8 @@ interface RunningTask {
1472
1501
  * provider before it completed, and vice versa).
1473
1502
  */
1474
1503
  hasLocalEnvironment: boolean;
1504
+ /** Harness variant captured on session_init (e.g. "bridge" or "stock") */
1505
+ harnessVariant?: string;
1475
1506
  }
1476
1507
 
1477
1508
  /** Runner state for tracking concurrent tasks */
@@ -1590,6 +1621,8 @@ async function saveProviderSessionId(
1590
1621
  provider?: ProviderName,
1591
1622
  providerMeta?: Record<string, unknown>,
1592
1623
  model?: string,
1624
+ harnessVariant?: string,
1625
+ harnessVariantMeta?: Record<string, unknown>,
1593
1626
  ): Promise<void> {
1594
1627
  const headers: Record<string, string> = { "Content-Type": "application/json" };
1595
1628
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
@@ -1597,13 +1630,45 @@ async function saveProviderSessionId(
1597
1630
  if (provider !== undefined) body.provider = provider;
1598
1631
  if (providerMeta !== undefined) body.providerMeta = providerMeta;
1599
1632
  if (model !== undefined && model !== "") body.model = model;
1600
- await fetch(`${apiUrl}/api/tasks/${taskId}/claude-session`, {
1633
+ if (harnessVariant !== undefined) body.harnessVariant = harnessVariant;
1634
+ if (harnessVariantMeta !== undefined) body.harnessVariantMeta = harnessVariantMeta;
1635
+ await fetch(`${apiUrl}/api/tasks/${taskId}/session`, {
1601
1636
  method: "PUT",
1602
1637
  headers,
1603
1638
  body: JSON.stringify(body),
1604
1639
  });
1605
1640
  }
1606
1641
 
1642
+ async function findBridgeFailureArtifact(cwd: string): Promise<string | undefined> {
1643
+ try {
1644
+ const bridgeDir = `${cwd}/.claude-bridge/runs`;
1645
+ const dir = await Array.fromAsync(
1646
+ new Bun.Glob("*/tmux-pane-final.txt").scan({ cwd: bridgeDir, absolute: true }),
1647
+ );
1648
+ if (dir.length === 0) return undefined;
1649
+ dir.sort();
1650
+ return dir[dir.length - 1];
1651
+ } catch {
1652
+ return undefined;
1653
+ }
1654
+ }
1655
+
1656
+ async function updateHarnessVariantMeta(
1657
+ apiUrl: string,
1658
+ apiKey: string,
1659
+ taskId: string,
1660
+ claudeSessionId: string,
1661
+ meta: Record<string, unknown>,
1662
+ ): Promise<void> {
1663
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
1664
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
1665
+ await fetch(`${apiUrl}/api/tasks/${taskId}/session`, {
1666
+ method: "PUT",
1667
+ headers,
1668
+ body: JSON.stringify({ claudeSessionId, harnessVariantMeta: meta }),
1669
+ });
1670
+ }
1671
+
1607
1672
  /** Cache of tasks that already have VCS linked — prevents repeated gh pr list calls */
1608
1673
  const vcsDetectedTasks = new Set<string>();
1609
1674
 
@@ -2493,10 +2558,19 @@ async function spawnProviderProcess(
2493
2558
  // Resolve Codex OAuth pool slot BEFORE building ProviderSessionConfig so we
2494
2559
  // can pass codexSlot through and the adapter writes token refreshes back to
2495
2560
  // the correct slot key (codex_oauth_<slot>) instead of defaulting to slot 0.
2561
+ //
2562
+ // Always resolve for codex (not just when credentialSelections is empty) so
2563
+ // that if the OPENAI_API_KEY credential is rate-limited we can fail over to
2564
+ // a CODEX_OAUTH slot — even though the keyType differs.
2496
2565
  let oauthSelection: CredentialSelection | undefined;
2497
- if (adapter.name === "codex" && credentialSelections.length === 0) {
2566
+ if (adapter.name === "codex") {
2498
2567
  oauthSelection = (await resolveCodexOAuthCredentialInfo(opts.apiUrl, opts.apiKey)) ?? undefined;
2499
- if (oauthSelection && realTaskId) {
2568
+ const oauthIsPrimary =
2569
+ credentialSelections.length === 0 ||
2570
+ (credentialSelections[0]?.isRateLimitFallback &&
2571
+ oauthSelection &&
2572
+ !oauthSelection.isRateLimitFallback);
2573
+ if (oauthSelection && realTaskId && oauthIsPrimary) {
2500
2574
  reportKeyUsage(
2501
2575
  opts.apiUrl,
2502
2576
  opts.apiKey,
@@ -2676,6 +2750,8 @@ async function spawnProviderProcess(
2676
2750
  event.provider,
2677
2751
  event.providerMeta,
2678
2752
  model,
2753
+ event.harnessVariant,
2754
+ event.harnessVariantMeta,
2679
2755
  ).catch((err) => console.warn(`[runner] Failed to save session ID: ${err}`));
2680
2756
  } else {
2681
2757
  // Pool task: save provider session ID on active session so it can be
@@ -3085,8 +3161,23 @@ async function spawnProviderProcess(
3085
3161
  }),
3086
3162
  );
3087
3163
 
3088
- // Build credential info for rate limit tracking
3089
- const primarySelection = credentialSelections[0] ?? oauthSelection;
3164
+ // Build credential info for rate limit tracking.
3165
+ // For codex: when OPENAI_API_KEY is rate-limited but CODEX_OAUTH has
3166
+ // available slots (or vice versa), prefer the healthy credential.
3167
+ let primarySelection: CredentialSelection | undefined;
3168
+ const firstCred = credentialSelections[0];
3169
+ if (firstCred && oauthSelection) {
3170
+ if (firstCred.isRateLimitFallback && !oauthSelection.isRateLimitFallback) {
3171
+ primarySelection = oauthSelection;
3172
+ console.log(
3173
+ `[credentials] Cross-keyType failover: ${firstCred.keyType} all rate-limited, using ${oauthSelection.keyType} [...${oauthSelection.keySuffix}]`,
3174
+ );
3175
+ } else {
3176
+ primarySelection = firstCred;
3177
+ }
3178
+ } else {
3179
+ primarySelection = firstCred ?? oauthSelection;
3180
+ }
3090
3181
  const credentialInfo = primarySelection
3091
3182
  ? {
3092
3183
  keyType: primarySelection.keyType,
@@ -3160,7 +3251,14 @@ async function checkCompletedProcesses(
3160
3251
  }
3161
3252
 
3162
3253
  // Remove completed tasks from the map and ensure they're marked as finished
3163
- for (const { taskId, result, cursorUpdates, workingDir, credentialInfo } of completedTasks) {
3254
+ for (const {
3255
+ taskId,
3256
+ result,
3257
+ cursorUpdates,
3258
+ workingDir,
3259
+ credentialInfo,
3260
+ harnessProvider,
3261
+ } of completedTasks) {
3164
3262
  state.activeTasks.delete(taskId);
3165
3263
  vcsDetectedTasks.delete(taskId);
3166
3264
  vcsCheckTimestamps.delete(taskId);
@@ -3251,9 +3349,28 @@ async function checkCompletedProcesses(
3251
3349
  result.exitCode,
3252
3350
  failureReason,
3253
3351
  result.output,
3254
- state.harnessProvider,
3352
+ harnessProvider,
3255
3353
  );
3256
3354
 
3355
+ if (result.exitCode === 0 && credentialInfo) {
3356
+ reportKeyClearRateLimit(
3357
+ apiConfig.apiUrl,
3358
+ apiConfig.apiKey,
3359
+ credentialInfo.keyType,
3360
+ credentialInfo.keySuffix,
3361
+ ).catch(() => {});
3362
+ }
3363
+
3364
+ if (result.exitCode !== 0 && harnessProvider === "claude" && workingDir && result.sessionId) {
3365
+ const artifactPath = await findBridgeFailureArtifact(workingDir);
3366
+ if (artifactPath) {
3367
+ console.log(`[${role}] Bridge failure artifact found: ${artifactPath}`);
3368
+ updateHarnessVariantMeta(apiConfig.apiUrl, apiConfig.apiKey, taskId, result.sessionId, {
3369
+ failureArtifact: artifactPath,
3370
+ }).catch((err) => console.warn(`[runner] Failed to update harness variant meta: ${err}`));
3371
+ }
3372
+ }
3373
+
3257
3374
  ensure({
3258
3375
  id: "worker_process_finished",
3259
3376
  flow: "task",
@@ -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
  }
package/src/http/index.ts CHANGED
@@ -556,6 +556,15 @@ httpServer
556
556
 
557
557
  // Start expired-memory garbage collector (1-hour tick, immediate first run)
558
558
  startMemoryGc();
559
+
560
+ // Background backfill: re-embed any agent_memory rows with wrong-dimension
561
+ // embeddings (e.g. 1536d instead of 512d). Non-blocking, idempotent, no-op
562
+ // when the DB is clean. See src/be/memory/boot-reembed.ts.
563
+ import("../be/memory/boot-reembed")
564
+ .then(({ runBootReembed }) => runBootReembed())
565
+ .catch((err) => {
566
+ console.error("[boot-reembed] startup backfill failed (non-fatal):", err);
567
+ });
559
568
  })
560
569
  .on("error", (err) => {
561
570
  console.error("HTTP Server Error:", err);
@@ -16,7 +16,7 @@ function getBridgeServer(): McpServer {
16
16
  }
17
17
 
18
18
  type RegisteredTool = {
19
- handler: Function;
19
+ handler: (argsOrExtra: unknown, extra?: unknown) => unknown | Promise<unknown>;
20
20
  inputSchema?: unknown;
21
21
  enabled?: boolean;
22
22
  };
@@ -387,6 +387,8 @@ export async function handleMemory(
387
387
  name: r.name,
388
388
  content: r.content,
389
389
  similarity: r.similarity,
390
+ rawSimilarity: r.rawSimilarity,
391
+ compositeScore: r.compositeScore,
390
392
  source: r.source,
391
393
  scope: r.scope,
392
394
  })),
@@ -404,6 +406,7 @@ export async function handleMemory(
404
406
 
405
407
  const { query, agentId, scope, source, sourcePath, limit, offset } = parsed.body;
406
408
  const store = getMemoryStore();
409
+ const pageLimit = Math.min(limit, 100);
407
410
  const pathNeedle = sourcePath?.trim().toLowerCase();
408
411
  const matchesPath = (p: string | null) =>
409
412
  !pathNeedle || (p?.toLowerCase().includes(pathNeedle) ?? false);
@@ -414,11 +417,14 @@ export async function handleMemory(
414
417
  const queryEmbedding = await provider.embed(query.trim());
415
418
 
416
419
  if (!queryEmbedding) {
417
- json(res, { results: [], total: 0, mode: "semantic" });
420
+ json(res, { results: [], total: 0, limit: pageLimit, offset, mode: "semantic" });
418
421
  return true;
419
422
  }
420
423
 
421
- const candidateLimit = Math.min(limit, 100) * CANDIDATE_SET_MULTIPLIER;
424
+ const candidateLimit = Math.min(
425
+ 4096,
426
+ Math.max(offset + pageLimit, pageLimit) * CANDIDATE_SET_MULTIPLIER,
427
+ );
422
428
  let candidates = store.search(queryEmbedding, agentId ?? "", {
423
429
  scope,
424
430
  limit: candidateLimit,
@@ -431,10 +437,11 @@ export async function handleMemory(
431
437
  if (pathNeedle) {
432
438
  candidates = candidates.filter((c) => matchesPath(c.sourcePath));
433
439
  }
434
- const ranked = rerank(candidates, { limit: Math.min(limit, 100) });
440
+ const ranked = rerank(candidates, { limit: candidates.length });
441
+ const page = ranked.slice(offset, offset + pageLimit);
435
442
 
436
443
  json(res, {
437
- results: ranked.map((r) => ({
444
+ results: page.map((r) => ({
438
445
  id: r.id,
439
446
  name: r.name,
440
447
  content: r.content,
@@ -442,6 +449,8 @@ export async function handleMemory(
442
449
  scope: r.scope,
443
450
  source: r.source,
444
451
  similarity: r.similarity,
452
+ rawSimilarity: r.rawSimilarity,
453
+ compositeScore: r.compositeScore,
445
454
  createdAt: r.createdAt,
446
455
  accessedAt: r.accessedAt,
447
456
  accessCount: r.accessCount ?? 0,
@@ -453,33 +462,25 @@ export async function handleMemory(
453
462
  totalChunks: r.totalChunks,
454
463
  tags: r.tags,
455
464
  })),
456
- total: ranked.length,
465
+ total: candidates.length,
466
+ limit: pageLimit,
467
+ offset,
457
468
  mode: "semantic",
458
469
  });
459
470
  return true;
460
471
  }
461
472
 
462
- // When filtering by sourcePath, over-fetch then post-filter so the visible
463
- // page isn't gutted by the in-memory filter.
464
- const fetchLimit = pathNeedle
465
- ? Math.min(500, Math.max(limit * 10, 100))
466
- : Math.min(limit, 100);
467
- let rows = store.list(agentId ?? "", {
473
+ const listOptions = {
468
474
  scope,
469
- limit: fetchLimit,
475
+ limit: pageLimit,
470
476
  offset,
471
477
  isLead: true,
472
- });
473
- if (agentId) {
474
- rows = rows.filter((r) => r.agentId === agentId);
475
- }
476
- if (source) {
477
- rows = rows.filter((r) => r.source === source);
478
- }
479
- if (pathNeedle) {
480
- rows = rows.filter((r) => matchesPath(r.sourcePath));
481
- }
482
- rows = rows.slice(0, Math.min(limit, 100));
478
+ ownerAgentId: agentId,
479
+ source,
480
+ sourcePath: pathNeedle,
481
+ };
482
+ const rows = store.list(agentId ?? "", listOptions);
483
+ const total = store.count(agentId ?? "", listOptions);
483
484
 
484
485
  json(res, {
485
486
  results: rows.map((r) => ({
@@ -500,7 +501,9 @@ export async function handleMemory(
500
501
  totalChunks: r.totalChunks,
501
502
  tags: r.tags,
502
503
  })),
503
- total: rows.length,
504
+ total,
505
+ limit: pageLimit,
506
+ offset,
504
507
  mode: "list",
505
508
  });
506
509
  } catch (err) {
package/src/http/tasks.ts CHANGED
@@ -98,11 +98,11 @@ const createTask = route({
98
98
  },
99
99
  });
100
100
 
101
- const updateClaudeSession = route({
101
+ const updateSession = route({
102
102
  method: "put",
103
- path: "/api/tasks/{id}/claude-session",
104
- pattern: ["api", "tasks", null, "claude-session"],
105
- summary: "Update Claude session ID for a task",
103
+ path: "/api/tasks/{id}/session",
104
+ pattern: ["api", "tasks", null, "session"],
105
+ summary: "Update provider session ID and harness metadata for a task",
106
106
  tags: ["Tasks"],
107
107
  params: z.object({ id: z.string() }),
108
108
  body: z.union([
@@ -121,6 +121,8 @@ const updateClaudeSession = route({
121
121
  provider: ProviderNameSchema.exclude(["devin"]).optional(),
122
122
  model: z.string().optional(),
123
123
  providerMeta: z.object({}).optional(),
124
+ harnessVariant: z.string().optional(),
125
+ harnessVariantMeta: z.record(z.string(), z.unknown()).optional(),
124
126
  }),
125
127
  ]),
126
128
  responses: {
@@ -427,8 +429,8 @@ export async function handleTasks(
427
429
  return true;
428
430
  }
429
431
 
430
- if (updateClaudeSession.match(req.method, pathSegments)) {
431
- const parsed = await updateClaudeSession.parse(req, res, pathSegments, queryParams);
432
+ if (updateSession.match(req.method, pathSegments)) {
433
+ const parsed = await updateSession.parse(req, res, pathSegments, queryParams);
432
434
  if (!parsed) return true;
433
435
  const task = updateTaskClaudeSessionId(
434
436
  parsed.params.id,
@@ -436,6 +438,8 @@ export async function handleTasks(
436
438
  parsed.body.provider,
437
439
  parsed.body.providerMeta,
438
440
  parsed.body.model,
441
+ "harnessVariant" in parsed.body ? parsed.body.harnessVariant : undefined,
442
+ "harnessVariantMeta" in parsed.body ? parsed.body.harnessVariantMeta : undefined,
439
443
  );
440
444
  if (!task) {
441
445
  jsonError(res, "Task not found", 404);