@desplega.ai/agent-swarm 1.93.0 → 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 (67) hide show
  1. package/README.md +2 -2
  2. package/openapi.json +180 -1
  3. package/package.json +1 -1
  4. package/src/be/db.ts +63 -7
  5. package/src/be/migrations/090_model_tiers.sql +2 -0
  6. package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
  7. package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
  8. package/src/be/migrations/093_slack_message_tracking.sql +6 -0
  9. package/src/be/migrations/runner.ts +52 -0
  10. package/src/be/modelsdev-cache.json +2060 -198
  11. package/src/be/scripts/boot-reembed.ts +74 -0
  12. package/src/be/scripts/db.ts +19 -3
  13. package/src/be/seed/index.ts +1 -1
  14. package/src/be/seed/registry.ts +2 -2
  15. package/src/be/seed/runner.ts +5 -5
  16. package/src/be/seed/types.ts +6 -1
  17. package/src/be/seed-pricing.ts +1 -0
  18. package/src/be/seed-scripts/index.ts +3 -2
  19. package/src/commands/runner.ts +83 -13
  20. package/src/http/index.ts +13 -2
  21. package/src/http/metrics.ts +55 -6
  22. package/src/http/schedules.ts +16 -15
  23. package/src/http/script-runs.ts +7 -1
  24. package/src/http/scripts.ts +147 -1
  25. package/src/http/tasks.ts +7 -0
  26. package/src/model-tiers.ts +140 -0
  27. package/src/providers/claude-managed-models.ts +9 -0
  28. package/src/providers/opencode-adapter.ts +1 -0
  29. package/src/providers/pi-mono-adapter.ts +78 -6
  30. package/src/scheduler/scheduler.ts +22 -34
  31. package/src/server-user.ts +8 -2
  32. package/src/slack/responses.ts +39 -11
  33. package/src/slack/watcher.ts +121 -8
  34. package/src/tests/agents-list-model-display.test.ts +13 -0
  35. package/src/tests/aws-error-classifier.test.ts +148 -0
  36. package/src/tests/claude-managed-adapter.test.ts +12 -0
  37. package/src/tests/context-window.test.ts +7 -0
  38. package/src/tests/http-api-integration.test.ts +19 -0
  39. package/src/tests/metrics-http.test.ts +137 -3
  40. package/src/tests/migration-046-budgets.test.ts +33 -0
  41. package/src/tests/migration-runner-regressions.test.ts +69 -0
  42. package/src/tests/model-control.test.ts +162 -46
  43. package/src/tests/opencode-adapter.test.ts +9 -0
  44. package/src/tests/pi-mono-adapter.test.ts +319 -0
  45. package/src/tests/providers/pi-cost.test.ts +9 -0
  46. package/src/tests/runner-fallback-output.test.ts +50 -0
  47. package/src/tests/scripts-boot-reembed.test.ts +163 -0
  48. package/src/tests/scripts-embeddings.test.ts +90 -0
  49. package/src/tests/seed.test.ts +26 -1
  50. package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
  51. package/src/tests/slack-watcher.test.ts +66 -0
  52. package/src/tests/workflow-agent-task.test.ts +5 -2
  53. package/src/tests/workflow-validation-port-routing.test.ts +181 -0
  54. package/src/tools/memory-get.ts +11 -0
  55. package/src/tools/memory-search.ts +18 -0
  56. package/src/tools/schedules/create-schedule.ts +71 -70
  57. package/src/tools/schedules/update-schedule.ts +43 -31
  58. package/src/tools/send-task.ts +16 -5
  59. package/src/tools/task-action.ts +11 -3
  60. package/src/types.ts +29 -0
  61. package/src/utils/aws-error-classifier.ts +97 -0
  62. package/src/utils/context-window.ts +2 -0
  63. package/src/utils/credentials.test.ts +68 -0
  64. package/src/utils/credentials.ts +44 -3
  65. package/src/utils/pretty-print.ts +25 -10
  66. package/src/workflows/engine.ts +3 -2
  67. package/src/workflows/executors/agent-task.ts +3 -1
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Post-listen backfill: embed scripts that are missing embeddings (e.g. after
3
+ * boot seeding with scriptEmbeddingMode: "skip"). Runs once per boot,
4
+ * async/non-blocking, idempotent, no-op when every non-scratch script already
5
+ * has an embedding row.
6
+ *
7
+ * Mirrors the memory boot-reembed pattern (src/be/memory/boot-reembed.ts).
8
+ */
9
+
10
+ import { getDb } from "@/be/db";
11
+ import type { ScriptScope } from "@/types";
12
+ import { embedScript } from "./embeddings";
13
+
14
+ type ScriptMissingEmbedding = {
15
+ id: string;
16
+ name: string;
17
+ scope: ScriptScope;
18
+ scopeId: string | null;
19
+ source: string;
20
+ description: string;
21
+ intent: string;
22
+ signatureJson: string;
23
+ argsJsonSchema: string | null;
24
+ contentHash: string;
25
+ version: number;
26
+ isScratch: number;
27
+ typeChecked: number;
28
+ fsMode: "none" | "workspace-rw";
29
+ createdByAgentId: string | null;
30
+ createdAt: string;
31
+ updatedAt: string;
32
+ };
33
+
34
+ export async function runBootReembedScripts(): Promise<void> {
35
+ const db = getDb();
36
+
37
+ const missing = db
38
+ .prepare<ScriptMissingEmbedding, []>(
39
+ `SELECT s.* FROM scripts s
40
+ LEFT JOIN script_embeddings e ON e.scriptId = s.id
41
+ WHERE s.isScratch = 0 AND e.scriptId IS NULL`,
42
+ )
43
+ .all();
44
+
45
+ if (missing.length === 0) {
46
+ return;
47
+ }
48
+
49
+ console.log(`[boot-reembed-scripts] starting: ${missing.length} scripts missing embeddings`);
50
+
51
+ let embedded = 0;
52
+ let failed = 0;
53
+
54
+ for (const row of missing) {
55
+ try {
56
+ await embedScript({
57
+ ...row,
58
+ scopeId: row.scopeId ?? null,
59
+ isScratch: row.isScratch === 1,
60
+ typeChecked: row.typeChecked === 1,
61
+ createdByAgentId: row.createdByAgentId ?? null,
62
+ });
63
+ embedded++;
64
+ } catch (err) {
65
+ failed++;
66
+ console.error(
67
+ `[boot-reembed-scripts] failed to embed "${row.name}":`,
68
+ (err as Error).message,
69
+ );
70
+ }
71
+ }
72
+
73
+ console.log(`[boot-reembed-scripts] complete: embedded=${embedded} failed=${failed}`);
74
+ }
@@ -26,6 +26,7 @@ type ScriptWriteArgs = ScriptIdentity & {
26
26
  fsMode?: ScriptFsMode;
27
27
  agentId?: string | null;
28
28
  changeReason?: string | null;
29
+ embeddingMode?: "sync" | "skip";
29
30
  };
30
31
 
31
32
  export type UpsertScriptResult = {
@@ -178,10 +179,11 @@ export function insertScript(args: ScriptWriteArgs): ScriptRecord {
178
179
  * immediately consistent for authored/promoted scripts.
179
180
  */
180
181
  export async function upsertScriptByName(args: ScriptWriteArgs): Promise<UpsertScriptResult> {
182
+ const shouldEmbed = args.embeddingMode !== "skip";
181
183
  const existing = getScript(args);
182
184
  if (!existing) {
183
185
  const script = insertScript(args);
184
- if (!script.isScratch) {
186
+ if (!script.isScratch && shouldEmbed) {
185
187
  await embedScript(script);
186
188
  }
187
189
  return {
@@ -235,7 +237,7 @@ export async function upsertScriptByName(args: ScriptWriteArgs): Promise<UpsertS
235
237
 
236
238
  if (!row) throw new Error("Failed to update script metadata");
237
239
  const script = rowToScript(row);
238
- if (!script.isScratch && (trackedMetadataChanged || promotedFromScratch)) {
240
+ if (!script.isScratch && shouldEmbed && (trackedMetadataChanged || promotedFromScratch)) {
239
241
  await embedScript(script);
240
242
  }
241
243
  return {
@@ -318,7 +320,7 @@ export async function upsertScriptByName(args: ScriptWriteArgs): Promise<UpsertS
318
320
  });
319
321
 
320
322
  const script = txn();
321
- if (!script.isScratch) {
323
+ if (!script.isScratch && shouldEmbed) {
322
324
  await embedScript(script);
323
325
  }
324
326
 
@@ -347,6 +349,11 @@ export function getScript(args: ScriptIdentity): ScriptRecord | null {
347
349
  return row ? rowToScript(row) : null;
348
350
  }
349
351
 
352
+ export function getScriptById(id: string): ScriptRecord | null {
353
+ const row = getDb().prepare<ScriptRow, [string]>("SELECT * FROM scripts WHERE id = ?").get(id);
354
+ return row ? rowToScript(row) : null;
355
+ }
356
+
350
357
  export function getScriptVersion(args: {
351
358
  scriptId: string;
352
359
  version?: number;
@@ -408,6 +415,15 @@ export function listScripts(args?: {
408
415
  .map(rowToScript);
409
416
  }
410
417
 
418
+ export function listScriptVersions(scriptId: string): ScriptVersionRecord[] {
419
+ return getDb()
420
+ .prepare<ScriptVersionRow, [string]>(
421
+ "SELECT * FROM script_versions WHERE scriptId = ? ORDER BY version DESC",
422
+ )
423
+ .all(scriptId)
424
+ .map(rowToScriptVersion);
425
+ }
426
+
411
427
  export function deleteScript(args: ScriptIdentity): boolean {
412
428
  const existing = getScript(args);
413
429
  if (!existing) return false;
@@ -6,4 +6,4 @@
6
6
  export { runAllSeeders, SEEDERS } from "./registry";
7
7
  export { runSeeder, runSeeders } from "./runner";
8
8
  export { getSeedState, recordSeedState } from "./state-db";
9
- export type { SeedAction, Seeder, SeederResult, SeedItem } from "./types";
9
+ export type { SeedAction, Seeder, SeederResult, SeederRunOptions, SeedItem } from "./types";
@@ -9,11 +9,11 @@
9
9
  import { scriptsSeeder } from "../seed-scripts";
10
10
  import { skillsSeeder } from "../seed-skills";
11
11
  import { runSeeders } from "./runner";
12
- import type { Seeder, SeederResult } from "./types";
12
+ import type { Seeder, SeederResult, SeederRunOptions } from "./types";
13
13
 
14
14
  export const SEEDERS: Seeder[] = [scriptsSeeder, skillsSeeder];
15
15
 
16
16
  /** Apply every registered seeder. Called at API boot and by the seed CLI. */
17
- export function runAllSeeders(opts?: { quiet?: boolean }): Promise<SeederResult[]> {
17
+ export function runAllSeeders(opts?: SeederRunOptions): Promise<SeederResult[]> {
18
18
  return runSeeders(SEEDERS, opts);
19
19
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { getSeedState, recordSeedState } from "./state-db";
8
- import type { Seeder, SeederResult } from "./types";
8
+ import type { Seeder, SeederResult, SeederRunOptions } from "./types";
9
9
 
10
10
  /**
11
11
  * Apply one seeder. Idempotent and version-aware:
@@ -14,7 +14,7 @@ import type { Seeder, SeederResult } from "./types";
14
14
  * - upstream pristine, src same -> no-op
15
15
  * - upstream user-modified -> preserve (never overwrite)
16
16
  */
17
- export async function runSeeder(seeder: Seeder, opts?: { quiet?: boolean }): Promise<SeederResult> {
17
+ export async function runSeeder(seeder: Seeder, opts?: SeederRunOptions): Promise<SeederResult> {
18
18
  const result: SeederResult = {
19
19
  kind: seeder.kind,
20
20
  created: 0,
@@ -31,7 +31,7 @@ export async function runSeeder(seeder: Seeder, opts?: { quiet?: boolean }): Pro
31
31
 
32
32
  // Absent upstream -> create.
33
33
  if (upstream === null) {
34
- await seeder.apply(item, "create");
34
+ await seeder.apply(item, "create", opts);
35
35
  recordSeedState(seeder.kind, item.key, item.contentHash);
36
36
  result.created += 1;
37
37
  continue;
@@ -60,7 +60,7 @@ export async function runSeeder(seeder: Seeder, opts?: { quiet?: boolean }): Pro
60
60
  }
61
61
 
62
62
  // Pristine upstream + changed source -> update to the new source version.
63
- await seeder.apply(item, "update");
63
+ await seeder.apply(item, "update", opts);
64
64
  recordSeedState(seeder.kind, item.key, item.contentHash);
65
65
  result.updated += 1;
66
66
  } catch (err) {
@@ -88,7 +88,7 @@ export async function runSeeder(seeder: Seeder, opts?: { quiet?: boolean }): Pro
88
88
  /** Apply a list of seeders in order. */
89
89
  export async function runSeeders(
90
90
  seeders: Seeder[],
91
- opts?: { quiet?: boolean },
91
+ opts?: SeederRunOptions,
92
92
  ): Promise<SeederResult[]> {
93
93
  const results: SeederResult[] = [];
94
94
  for (const seeder of seeders) {
@@ -35,6 +35,11 @@ export interface SeedItem {
35
35
  readonly contentHash: string;
36
36
  }
37
37
 
38
+ export type SeederRunOptions = {
39
+ quiet?: boolean;
40
+ scriptEmbeddingMode?: "sync" | "skip";
41
+ };
42
+
38
43
  export interface Seeder<TItem extends SeedItem = SeedItem> {
39
44
  /** Kind discriminator — namespaces this seeder's rows in `seed_state`. */
40
45
  readonly kind: string;
@@ -46,7 +51,7 @@ export interface Seeder<TItem extends SeedItem = SeedItem> {
46
51
  */
47
52
  upstreamHash(item: TItem): string | null | Promise<string | null>;
48
53
  /** Create or update the upstream entity so it matches the source definition. */
49
- apply(item: TItem, action: "create" | "update"): void | Promise<void>;
54
+ apply(item: TItem, action: "create" | "update", opts?: SeederRunOptions): void | Promise<void>;
50
55
  }
51
56
 
52
57
  export type SeederResult = {
@@ -68,6 +68,7 @@ const MANUAL_PRICING_OVERRIDES: Array<{
68
68
  */
69
69
  const ANTHROPIC_SHORTNAME_TO_MODELSDEV: Record<string, string> = {
70
70
  fable: "claude-fable-5",
71
+ mythos: "claude-mythos-5",
71
72
  opus: "claude-opus-4-8",
72
73
  sonnet: "claude-sonnet-4-6",
73
74
  haiku: "claude-haiku-4-5",
@@ -21,7 +21,7 @@ import { computeContentHash } from "../db";
21
21
  import { getScript, upsertScriptByName } from "../scripts/db";
22
22
  import { extractArgsJsonSchema } from "../scripts/extract-schema";
23
23
  import { typecheckScript } from "../scripts/typecheck";
24
- import type { Seeder, SeedItem } from "../seed/types";
24
+ import type { Seeder, SeederRunOptions, SeedItem } from "../seed/types";
25
25
  import bootTriageSrc from "./catalog/boot-triage.inline.ts" with { type: "text" };
26
26
  // @ts-expect-error Bun text imports synthesize a default string for this helper.
27
27
  import catalogReportSrc from "./catalog/catalog-report.inline.ts" with { type: "text" };
@@ -234,7 +234,7 @@ export const scriptsSeeder: Seeder<ScriptSeedItem> = {
234
234
  return existing ? existing.contentHash : null;
235
235
  },
236
236
 
237
- async apply(item): Promise<void> {
237
+ async apply(item, _action, opts?: SeederRunOptions): Promise<void> {
238
238
  const { script } = item;
239
239
 
240
240
  const imports = validateScriptImports(script.source);
@@ -260,6 +260,7 @@ export const scriptsSeeder: Seeder<ScriptSeedItem> = {
260
260
  isScratch: false,
261
261
  typeChecked: true,
262
262
  changeReason: "Seeded from the built-in scripts catalog (src/be/seed-scripts)",
263
+ embeddingMode: opts?.scriptEmbeddingMode ?? "sync",
263
264
  });
264
265
  },
265
266
  };
@@ -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) {
@@ -1653,6 +1666,32 @@ async function findBridgeFailureArtifact(cwd: string): Promise<string | undefine
1653
1666
  }
1654
1667
  }
1655
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
+
1656
1695
  async function updateHarnessVariantMeta(
1657
1696
  apiUrl: string,
1658
1697
  apiKey: string,
@@ -2519,6 +2558,7 @@ async function spawnProviderProcess(
2519
2558
  iteration: number;
2520
2559
  taskId?: string;
2521
2560
  model?: string;
2561
+ modelTier?: string;
2522
2562
  resumeSessionId?: string;
2523
2563
  harnessProvider: ProviderName;
2524
2564
  cwd?: string;
@@ -2532,11 +2572,15 @@ async function spawnProviderProcess(
2532
2572
  // Correlation ID for logs/display — always defined
2533
2573
  const effectiveTaskId = realTaskId || crypto.randomUUID();
2534
2574
 
2535
- // 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).
2536
2578
  const { env: freshEnv, credentialSelections } = await fetchResolvedEnv(
2537
2579
  opts.apiUrl,
2538
2580
  opts.apiKey,
2539
2581
  opts.agentId,
2582
+ process.env,
2583
+ opts.model,
2540
2584
  );
2541
2585
 
2542
2586
  // Report which key was selected for this task (fire-and-forget)
@@ -2553,7 +2597,14 @@ async function spawnProviderProcess(
2553
2597
  }
2554
2598
 
2555
2599
  const configModel = (freshEnv.MODEL_OVERRIDE as string | undefined) || "";
2556
- 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 || "";
2557
2608
 
2558
2609
  // Resolve Codex OAuth pool slot BEFORE building ProviderSessionConfig so we
2559
2610
  // can pass codexSlot through and the adapter writes token refreshes back to
@@ -2644,7 +2695,7 @@ async function spawnProviderProcess(
2644
2695
  );
2645
2696
  const initialModelReport = buildLatestModelReport({
2646
2697
  model,
2647
- taskModel: opts.model,
2698
+ taskModel,
2648
2699
  configModel,
2649
2700
  taskId: realTaskId,
2650
2701
  harnessProvider: opts.harnessProvider,
@@ -2766,6 +2817,17 @@ async function spawnProviderProcess(
2766
2817
  );
2767
2818
  }
2768
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
+
2769
2831
  // Buffer session start event
2770
2832
  bufferEvent({
2771
2833
  category: "session",
@@ -3342,6 +3404,20 @@ async function checkCompletedProcesses(
3342
3404
  rateLimitedUntil,
3343
3405
  ).catch(() => {});
3344
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;
3345
3421
  await ensureTaskFinished(
3346
3422
  apiConfig,
3347
3423
  role,
@@ -3350,6 +3426,7 @@ async function checkCompletedProcesses(
3350
3426
  failureReason,
3351
3427
  result.output,
3352
3428
  harnessProvider,
3429
+ bridgeFailureDiagnostics,
3353
3430
  );
3354
3431
 
3355
3432
  if (result.exitCode === 0 && credentialInfo) {
@@ -3361,16 +3438,6 @@ async function checkCompletedProcesses(
3361
3438
  ).catch(() => {});
3362
3439
  }
3363
3440
 
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
-
3374
3441
  ensure({
3375
3442
  id: "worker_process_finished",
3376
3443
  flow: "task",
@@ -4391,6 +4458,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4391
4458
  iteration,
4392
4459
  taskId: task.id,
4393
4460
  model: (task as { model?: string }).model,
4461
+ modelTier: (task as { modelTier?: string }).modelTier,
4394
4462
  harnessProvider: state.harnessProvider,
4395
4463
  cwd: resumeCwd,
4396
4464
  vcsRepo: task.vcsRepo,
@@ -4710,6 +4778,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4710
4778
 
4711
4779
  // Extract model from task data for per-task model selection
4712
4780
  const taskModel = (trigger.task as { model?: string } | undefined)?.model;
4781
+ const taskModelTier = (trigger.task as { modelTier?: string } | undefined)?.modelTier;
4713
4782
 
4714
4783
  // Detect Slack context for conditional prompt sections
4715
4784
  const taskSlackChannelId = (trigger.task as { slackChannelId?: string } | undefined)
@@ -4852,6 +4921,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4852
4921
  iteration,
4853
4922
  taskId: trigger.taskId,
4854
4923
  model: taskModel,
4924
+ modelTier: taskModelTier,
4855
4925
  harnessProvider: state.harnessProvider,
4856
4926
  cwd: effectiveCwd,
4857
4927
  vcsRepo: taskVcsRepo,
package/src/http/index.ts CHANGED
@@ -458,10 +458,12 @@ try {
458
458
  // Seed the built-in entity catalog (scripts today; more kinds later) so
459
459
  // `script-search` & co. return useful hits from a fresh DB. Idempotent and
460
460
  // version-aware: a pristine entity updates when its source changes, a
461
- // user-modified one is preserved. See src/be/seed for the framework.
461
+ // user-modified one is preserved. Script embeddings are deferred to a
462
+ // post-listen backfill so boot doesn't block on embedding provider calls.
463
+ // See src/be/seed for the framework.
462
464
  try {
463
465
  const { runAllSeeders } = await import("../be/seed");
464
- await runAllSeeders();
466
+ await runAllSeeders({ scriptEmbeddingMode: "skip" });
465
467
  } catch (err) {
466
468
  console.error("[startup] Failed to seed built-in entities:", err);
467
469
  }
@@ -565,6 +567,15 @@ httpServer
565
567
  .catch((err) => {
566
568
  console.error("[boot-reembed] startup backfill failed (non-fatal):", err);
567
569
  });
570
+
571
+ // Background backfill: embed any scripts that were seeded without embeddings
572
+ // (scriptEmbeddingMode: "skip" during boot). Non-blocking, idempotent, no-op
573
+ // when every non-scratch script already has an embedding.
574
+ import("../be/scripts/boot-reembed")
575
+ .then(({ runBootReembedScripts }) => runBootReembedScripts())
576
+ .catch((err) => {
577
+ console.error("[boot-reembed-scripts] startup backfill failed (non-fatal):", err);
578
+ });
568
579
  })
569
580
  .on("error", (err) => {
570
581
  console.error("HTTP Server Error:", err);
@@ -48,12 +48,48 @@ function slugify(input: string): string {
48
48
 
49
49
  function validateMetricDefinition(definition: unknown) {
50
50
  const parsed = MetricDefinitionSchema.parse(definition);
51
+ for (const variable of parsed.variables ?? []) {
52
+ if (variable.optionsQuery) {
53
+ assertSelectOnlyQuery(variable.optionsQuery.sql);
54
+ }
55
+ }
51
56
  for (const widget of parsed.widgets) {
52
57
  assertSelectOnlyQuery(widget.query.sql);
53
58
  }
54
59
  return parsed;
55
60
  }
56
61
 
62
+ function resolveVariableOptionValues(variable: MetricVariable) {
63
+ if (!variable.optionsQuery) return variable.options ?? [];
64
+ assertSelectOnlyQuery(variable.optionsQuery.sql);
65
+ const result = executeReadOnlyQuery(variable.optionsQuery.sql, [], HARD_METRIC_MAX_ROWS);
66
+ return result.rows.map((row) => {
67
+ const record = Object.fromEntries(
68
+ result.columns.map((column, index) => [column, row[index] as MetricParam]),
69
+ );
70
+ const value = record[variable.optionsQuery!.valueKey];
71
+ const labelKey = variable.optionsQuery!.labelKey ?? variable.optionsQuery!.valueKey;
72
+ const label = record[labelKey] ?? value;
73
+ return {
74
+ label: label == null ? "" : String(label),
75
+ value: value == null ? null : value,
76
+ };
77
+ });
78
+ }
79
+
80
+ function resolveVariableOptions(metric: Metric) {
81
+ const optionsByKey: Record<string, Array<{ label: string; value: MetricParam }>> = {};
82
+ const variables = (metric.definition.variables ?? []).map((variable) => {
83
+ if (!variable.optionsQuery) {
84
+ return variable;
85
+ }
86
+ const options = resolveVariableOptionValues(variable);
87
+ optionsByKey[variable.key] = options;
88
+ return { ...variable, options };
89
+ });
90
+ return { variables, optionsByKey };
91
+ }
92
+
57
93
  function coerceVariableValue(variable: MetricVariable, raw: unknown): MetricParam {
58
94
  if (raw == null || raw === "") {
59
95
  return variable.defaultValue ?? null;
@@ -71,12 +107,21 @@ function coerceVariableValue(variable: MetricVariable, raw: unknown): MetricPara
71
107
  return String(raw);
72
108
  }
73
109
 
74
- function resolveMetricVariables(metric: Metric, provided: Record<string, unknown>) {
110
+ function resolveMetricVariables(
111
+ metric: Metric,
112
+ provided: Record<string, unknown>,
113
+ dynamicOptionsByKey: Record<string, Array<{ label: string; value: MetricParam }>> = {},
114
+ ) {
75
115
  const values: Record<string, MetricParam> = {};
76
116
  for (const variable of metric.definition.variables ?? []) {
77
- const value = coerceVariableValue(variable, provided[variable.key]);
78
- if (variable.options?.length) {
79
- const allowed = variable.options.some((option) => option.value === value);
117
+ const options = dynamicOptionsByKey[variable.key] ?? variable.options;
118
+ const raw = provided[variable.key];
119
+ const value =
120
+ (raw == null || raw === "") && variable.defaultValue === undefined && options?.length
121
+ ? options[0]!.value
122
+ : coerceVariableValue(variable, raw);
123
+ if (options?.length) {
124
+ const allowed = options.some((option) => option.value === value);
80
125
  if (!allowed) {
81
126
  throw new Error(`Metric variable "${variable.key}" must match one of its options`);
82
127
  }
@@ -125,10 +170,14 @@ function runMetricWidget(widget: MetricWidget, variables: Record<string, MetricP
125
170
  }
126
171
 
127
172
  function runMetric(metric: Metric, providedVariables: Record<string, unknown> = {}) {
128
- const variables = resolveMetricVariables(metric, providedVariables);
173
+ const resolved = resolveVariableOptions(metric);
174
+ const variables = resolveMetricVariables(metric, providedVariables, resolved.optionsByKey);
129
175
  const widgets = metric.definition.widgets.map((widget) => runMetricWidget(widget, variables));
130
176
  return {
131
- metric,
177
+ metric: {
178
+ ...metric,
179
+ definition: { ...metric.definition, variables: resolved.variables },
180
+ },
132
181
  variables,
133
182
  widgets,
134
183
  // Kept as the first widget result for older callers during the PR cycle.
@@ -12,9 +12,8 @@ import {
12
12
  updateScheduledTask,
13
13
  } from "../be/db";
14
14
  import { mergeScheduleTiming, validateRecurringTiming } from "../be/schedules/validate";
15
- import { calculateNextRun } from "../scheduler/scheduler";
16
- import { scheduleContextKey } from "../tasks/context-key";
17
- import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
15
+ import { ModelTierSchema, splitLegacyModelAlias } from "../model-tiers";
16
+ import { calculateNextRun, createStandaloneScheduleTask } from "../scheduler/scheduler";
18
17
  import { getExecutorRegistry } from "../workflows";
19
18
  import { handleScheduleTrigger } from "../workflows/triggers";
20
19
  import { route } from "./route-def";
@@ -41,6 +40,7 @@ const createSchedule = route({
41
40
  enabled: z.boolean().optional(),
42
41
  timezone: z.string().optional(),
43
42
  model: z.string().optional(),
43
+ modelTier: ModelTierSchema.optional(),
44
44
  scheduleType: z.enum(["recurring", "one_time"]).optional(),
45
45
  delayMs: z.number().int().optional(),
46
46
  runAt: z.string().optional(),
@@ -126,6 +126,7 @@ const updateSchedule = route({
126
126
  enabled: z.boolean().optional(),
127
127
  timezone: z.string().optional(),
128
128
  model: z.string().optional(),
129
+ modelTier: ModelTierSchema.nullable().optional(),
129
130
  nextRunAt: z.string().nullable().optional(),
130
131
  }),
131
132
  responses: {
@@ -270,7 +271,7 @@ export async function handleSchedules(
270
271
  enabled: body.enabled,
271
272
  nextRunAt,
272
273
  timezone: body.timezone,
273
- model: body.model,
274
+ ...splitLegacyModelAlias({ model: body.model, modelTier: body.modelTier }),
274
275
  scheduleType: body.scheduleType,
275
276
  });
276
277
 
@@ -333,17 +334,7 @@ export async function handleSchedules(
333
334
  const now = new Date().toISOString();
334
335
 
335
336
  const task = getDb().transaction(() => {
336
- const createdTask = createTaskWithSiblingAwareness(schedule.taskTemplate, {
337
- creatorAgentId: schedule.createdByAgentId,
338
- taskType: schedule.taskType,
339
- tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`, "manual-run"],
340
- priority: schedule.priority,
341
- agentId: schedule.targetAgentId,
342
- model: schedule.model,
343
- scheduleId: schedule.id,
344
- source: "schedule",
345
- contextKey: scheduleContextKey({ scheduleId: schedule.id }),
346
- });
337
+ const createdTask = createStandaloneScheduleTask(schedule, ["manual-run"]);
347
338
 
348
339
  if (schedule.scheduleType === "one_time") {
349
340
  updateScheduledTask(schedule.id, {
@@ -388,6 +379,16 @@ export async function handleSchedules(
388
379
  const parsed = await updateSchedule.parse(req, res, pathSegments, queryParams);
389
380
  if (!parsed) return true;
390
381
  const body = parsed.body as Record<string, unknown>;
382
+ if (parsed.body.model !== undefined || parsed.body.modelTier !== undefined) {
383
+ const normalizedModel = splitLegacyModelAlias({
384
+ model: parsed.body.model,
385
+ modelTier: parsed.body.modelTier,
386
+ });
387
+ if (parsed.body.model !== undefined) body.model = normalizedModel.model ?? null;
388
+ if (parsed.body.modelTier !== undefined || normalizedModel.modelTier) {
389
+ body.modelTier = normalizedModel.modelTier ?? null;
390
+ }
391
+ }
391
392
 
392
393
  const existing = getScheduledTaskById(parsed.params.id);
393
394
  if (!existing) {