@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.
- package/README.md +2 -2
- package/openapi.json +242 -3
- package/package.json +5 -5
- package/src/be/db.ts +152 -11
- package/src/be/memory/boot-reembed.ts +0 -1
- package/src/be/memory/providers/sqlite-store.ts +42 -25
- package/src/be/memory/raters/llm-client.ts +12 -5
- package/src/be/memory/types.ts +3 -0
- package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
- package/src/be/migrations/089_harness_variant.sql +2 -0
- package/src/be/migrations/090_model_tiers.sql +2 -0
- package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
- package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
- package/src/be/migrations/093_slack_message_tracking.sql +6 -0
- package/src/be/migrations/runner.ts +52 -0
- package/src/be/modelsdev-cache.json +3264 -1166
- package/src/be/scripts/boot-reembed.ts +74 -0
- package/src/be/scripts/db.ts +19 -3
- package/src/be/seed/index.ts +1 -1
- package/src/be/seed/registry.ts +2 -2
- package/src/be/seed/runner.ts +5 -5
- package/src/be/seed/types.ts +6 -1
- package/src/be/seed-pricing.ts +2 -0
- package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
- package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
- package/src/be/seed-scripts/index.ts +8 -7
- package/src/be/skill-sync.ts +28 -179
- package/src/commands/runner.ts +197 -10
- package/src/http/api-keys.ts +42 -0
- package/src/http/index.ts +13 -2
- package/src/http/mcp-bridge.ts +1 -1
- package/src/http/memory.ts +23 -24
- package/src/http/metrics.ts +55 -6
- package/src/http/schedules.ts +16 -15
- package/src/http/script-runs.ts +7 -1
- package/src/http/scripts.ts +147 -1
- package/src/http/tasks.ts +17 -6
- package/src/model-tiers.ts +140 -0
- package/src/providers/claude-adapter.ts +33 -1
- package/src/providers/claude-managed-adapter.ts +3 -0
- package/src/providers/claude-managed-models.ts +16 -0
- package/src/providers/codex-adapter.ts +8 -1
- package/src/providers/codex-models.ts +1 -0
- package/src/providers/codex-oauth/auth-json.ts +1 -0
- package/src/providers/harness-version.ts +7 -0
- package/src/providers/opencode-adapter.ts +12 -4
- package/src/providers/pi-mono-adapter.ts +90 -8
- package/src/providers/types.ts +2 -0
- package/src/scheduler/scheduler.ts +22 -34
- package/src/scripts-runtime/egress-secrets.ts +83 -0
- package/src/scripts-runtime/eval-harness.ts +4 -0
- package/src/scripts-runtime/executors/types.ts +7 -0
- package/src/scripts-runtime/loader.ts +2 -0
- package/src/server-user.ts +8 -2
- package/src/slack/channel-join.ts +41 -0
- package/src/slack/responses.ts +39 -11
- package/src/slack/watcher.ts +121 -8
- package/src/tests/additive-buffer.test.ts +0 -1
- package/src/tests/agents-list-model-display.test.ts +13 -0
- package/src/tests/api-key-tracking.test.ts +113 -0
- package/src/tests/approval-requests.test.ts +0 -6
- package/src/tests/aws-error-classifier.test.ts +148 -0
- package/src/tests/claude-managed-adapter.test.ts +12 -0
- package/src/tests/claude-managed-setup.test.ts +0 -4
- package/src/tests/codex-pool.test.ts +2 -6
- package/src/tests/context-window.test.ts +7 -0
- package/src/tests/http-api-integration.test.ts +23 -6
- package/src/tests/memory-edges.test.ts +0 -2
- package/src/tests/memory-rate-endpoint.test.ts +0 -2
- package/src/tests/memory-rater-e2e.test.ts +0 -2
- package/src/tests/memory-store.test.ts +19 -1
- package/src/tests/memory.test.ts +51 -0
- package/src/tests/metrics-http.test.ts +137 -3
- package/src/tests/migration-046-budgets.test.ts +33 -0
- package/src/tests/migration-runner-regressions.test.ts +69 -0
- package/src/tests/model-control.test.ts +162 -46
- package/src/tests/opencode-adapter.test.ts +9 -0
- package/src/tests/pi-mono-adapter.test.ts +319 -0
- package/src/tests/providers/pi-cost.test.ts +9 -0
- package/src/tests/reload-config.test.ts +33 -17
- package/src/tests/runner-fallback-output.test.ts +50 -0
- package/src/tests/runner-skills-refresh.test.ts +216 -46
- package/src/tests/script-runs-http.test.ts +7 -1
- package/src/tests/scripts-boot-reembed.test.ts +163 -0
- package/src/tests/scripts-embeddings.test.ts +90 -0
- package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
- package/src/tests/seed-scripts.test.ts +13 -1
- package/src/tests/seed.test.ts +26 -1
- package/src/tests/session-attach.test.ts +6 -6
- package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
- package/src/tests/skill-fs-writer.test.ts +250 -0
- package/src/tests/slack-attachments-block.test.ts +0 -1
- package/src/tests/slack-blocks.test.ts +0 -1
- package/src/tests/slack-channel-join.test.ts +80 -0
- package/src/tests/slack-identity-resolution.test.ts +0 -1
- package/src/tests/slack-watcher.test.ts +66 -0
- package/src/tests/structured-output.test.ts +0 -2
- package/src/tests/use-dismissible-card.test.ts +0 -4
- package/src/tests/workflow-agent-task.test.ts +5 -2
- package/src/tests/workflow-validation-port-routing.test.ts +181 -0
- package/src/tools/memory-get.ts +11 -0
- package/src/tools/memory-search.ts +18 -0
- package/src/tools/schedules/create-schedule.ts +71 -70
- package/src/tools/schedules/update-schedule.ts +43 -31
- package/src/tools/send-task.ts +16 -5
- package/src/tools/slack-post.ts +18 -15
- package/src/tools/slack-read.ts +9 -11
- package/src/tools/slack-reply.ts +18 -15
- package/src/tools/slack-start-thread.ts +17 -14
- package/src/tools/task-action.ts +11 -3
- package/src/types.ts +40 -0
- package/src/utils/aws-error-classifier.ts +97 -0
- package/src/utils/context-window.ts +5 -0
- package/src/utils/credentials.test.ts +68 -0
- package/src/utils/credentials.ts +66 -5
- package/src/utils/pretty-print.ts +25 -10
- package/src/utils/skill-fs-writer.ts +220 -0
- package/src/utils/skills-refresh.ts +123 -40
- package/src/workflows/engine.ts +3 -2
- package/src/workflows/executors/agent-task.ts +3 -1
package/src/be/skill-sync.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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 {
|
package/src/commands/runner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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"
|
|
2617
|
+
if (adapter.name === "codex") {
|
|
2498
2618
|
oauthSelection = (await resolveCodexOAuthCredentialInfo(opts.apiUrl, opts.apiKey)) ?? undefined;
|
|
2499
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
package/src/http/api-keys.ts
CHANGED
|
@@ -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
|
}
|