@desplega.ai/agent-swarm 1.102.0 → 1.103.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 (107) hide show
  1. package/dist/{actions-j9399ktm.js → actions-7txktrdj.js} +6 -6
  2. package/dist/{app-tnq1pz27.js → app-xm3xsdye.js} +3 -3
  3. package/dist/{assistant-c7y92gkw.js → assistant-qpv63wk6.js} +9 -9
  4. package/dist/{boot-reembed-k7e6fc1p.js → boot-reembed-9cxqka3e.js} +3 -3
  5. package/dist/{boot-reembed-b97r699t.js → boot-reembed-chxhrwv8.js} +4 -4
  6. package/dist/{boot-scrub-logs-14kbc61y.js → boot-scrub-logs-8qtfkcz8.js} +2 -2
  7. package/dist/{claude-managed-adapter-g07dbz3b.js → claude-managed-adapter-z29hjgzz.js} +3 -3
  8. package/dist/{cli-tsz1q7gk.js → cli-2307phk1.js} +375 -23
  9. package/dist/{cli-g5px3d7x.js → cli-2957pqpj.js} +24 -1
  10. package/dist/{cli-9qascwj7.js → cli-2qynerth.js} +1 -1
  11. package/dist/{cli-k1ghyj8b.js → cli-4fnp2tc3.js} +1 -1
  12. package/dist/{cli-hc4rd6sv.js → cli-85pf4z38.js} +1 -1
  13. package/dist/{cli-zk4gn40w.js → cli-8hyv6c2v.js} +6 -14
  14. package/dist/{cli-k81y1cjb.js → cli-8w95v19d.js} +1 -1
  15. package/dist/{cli-7xdq3jg3.js → cli-92fqyjcv.js} +1 -1
  16. package/dist/{cli-zctkpdmd.js → cli-dz3d6zjg.js} +1 -1
  17. package/dist/{cli-d0t4854c.js → cli-fedn86nz.js} +213 -476
  18. package/dist/{cli-g861fwjp.js → cli-fwxdt7kt.js} +3 -3
  19. package/dist/{cli-2aaa9mqr.js → cli-fygaw191.js} +2 -2
  20. package/dist/{cli-qbc6mncp.js → cli-gzepjz54.js} +2 -2
  21. package/dist/{cli-r68m8arr.js → cli-jt9d9fjw.js} +1 -1
  22. package/dist/{cli-axtg1zaa.js → cli-khe4x17a.js} +1 -1
  23. package/dist/{cli-sygq3fhn.js → cli-kk51f3tf.js} +5 -5
  24. package/dist/{cli-dgab5x2h.js → cli-krvxq8bc.js} +1 -1
  25. package/dist/{cli-xde8vhhp.js → cli-ksq38x4n.js} +2 -2
  26. package/dist/{cli-stqqf5kh.js → cli-m8f6qzck.js} +1 -1
  27. package/dist/{cli-ap4md0gc.js → cli-ns0r7mkr.js} +30 -5
  28. package/dist/{cli-t2jk33ta.js → cli-rttgde5f.js} +18 -6
  29. package/dist/{cli-mhj07xbq.js → cli-rvae030h.js} +3 -3
  30. package/dist/{cli-q6jzg6wj.js → cli-s14sb64w.js} +4 -4
  31. package/dist/{cli-zmf7refz.js → cli-v9283nma.js} +1 -1
  32. package/dist/{cli-fjx4x7nn.js → cli-xmtsan9k.js} +1 -1
  33. package/dist/{cli-vgck9mqq.js → cli-yeemwevj.js} +155 -37
  34. package/dist/{cli-5bqa41fd.js → cli-yfwwjeft.js} +1 -1
  35. package/dist/{cli-s8jw5bmg.js → cli-yp2krx5r.js} +2 -2
  36. package/dist/cli.js +9 -9
  37. package/dist/{codex-adapter-wf119mnw.js → codex-adapter-w0xharsg.js} +3 -3
  38. package/dist/{codex-session-runner-1gknw7mh.js → codex-session-runner-kzbjcm2k.js} +3 -3
  39. package/dist/{commands-2wyn3swb.js → commands-aqn63mcw.js} +2 -2
  40. package/dist/{db-567ntpxv.js → db-azrvwsnj.js} +2 -2
  41. package/dist/{handlers-wvwpk2kj.js → handlers-hf61har3.js} +9 -9
  42. package/dist/{hook-fatkywc0.js → hook-306p3yz4.js} +2 -2
  43. package/dist/{http-eq6wxve6.js → http-zts4haq7.js} +81 -62
  44. package/dist/{index-400x6p3j.js → index-29vg51x6.js} +6 -6
  45. package/dist/{index-9wqh5352.js → index-3c651yfk.js} +10 -10
  46. package/dist/{index-6aj8966p.js → index-bfh9hgek.js} +5 -5
  47. package/dist/{index-d1rm4ea3.js → index-h2yqjyk0.js} +8 -8
  48. package/dist/{keepalive-6x9nx92g.js → keepalive-a0dg5pds.js} +4 -4
  49. package/dist/{lead-5fgqwe13.js → lead-7mcxedkc.js} +21 -21
  50. package/dist/{maintenance-eajx5k1k.js → maintenance-mrzqd53t.js} +4 -4
  51. package/dist/{onboard-jc1w1edg.js → onboard-4dkpv9wa.js} +2 -2
  52. package/dist/{otel-impl-8k375amf.js → otel-impl-jt7gpyp9.js} +1 -1
  53. package/dist/{pi-mono-adapter-hf8sw7hs.js → pi-mono-adapter-zf7dt1wg.js} +1 -1
  54. package/dist/{pricing-refresh-zed6916t.js → pricing-refresh-xqnzh8dt.js} +4 -4
  55. package/dist/{seed-pricing-5805qzkz.js → seed-pricing-56tjm1fj.js} +3 -3
  56. package/dist/{setup-wz05vxh0.js → setup-tbwk8j10.js} +2 -2
  57. package/dist/{worker-j359graf.js → worker-e03za92t.js} +21 -21
  58. package/openapi.json +70 -1
  59. package/package.json +1 -1
  60. package/src/be/db.ts +83 -13
  61. package/src/be/migrations/098_repo_hooks_config.sql +3 -0
  62. package/src/be/task-lifecycle-events.ts +42 -0
  63. package/src/commands/runner.ts +174 -25
  64. package/src/github/task-reactions.ts +21 -0
  65. package/src/hooks/tool-loop-detection.test.ts +40 -0
  66. package/src/hooks/tool-loop-detection.ts +37 -1
  67. package/src/http/agents.ts +7 -0
  68. package/src/http/index.ts +16 -2
  69. package/src/http/prompt-templates.ts +1 -1
  70. package/src/http/repos.ts +5 -1
  71. package/src/http/schedules.ts +1 -1
  72. package/src/http/session-data.ts +2 -0
  73. package/src/http/tasks.ts +2 -1
  74. package/src/http/workflows.ts +1 -0
  75. package/src/prompts/resolver.ts +1 -1
  76. package/src/scheduler/scheduler.ts +25 -2
  77. package/src/server-runtime-counters.ts +12 -0
  78. package/src/server-user.ts +1 -2
  79. package/src/server.ts +5 -0
  80. package/src/telemetry.ts +18 -1
  81. package/src/tests/http-api-integration.test.ts +4 -0
  82. package/src/tests/model-control.test.ts +6 -6
  83. package/src/tests/swarm-repos.test.ts +25 -2
  84. package/src/tests/task-lifecycle-events.test.ts +53 -0
  85. package/src/tests/task-lifecycle-telemetry.test.ts +37 -1
  86. package/src/tests/telemetry-init.test.ts +32 -0
  87. package/src/tools/prompt-templates/preview.ts +1 -1
  88. package/src/tools/repos/update-repo.ts +6 -1
  89. package/src/tools/schedules/create-schedule.ts +1 -1
  90. package/src/tools/schedules/update-schedule.ts +1 -1
  91. package/src/tools/send-task.ts +7 -2
  92. package/src/tools/task-action.ts +6 -2
  93. package/src/types.ts +155 -1
  94. package/src/utils/template.ts +127 -0
  95. package/src/workflows/engine.ts +7 -0
  96. package/src/workflows/executors/agent-task.ts +1 -2
  97. package/src/workflows/template.ts +12 -121
  98. package/src/workflows/triggers.ts +6 -2
  99. package/templates/community/code-health-reports/PLAYBOOK.md +355 -0
  100. package/templates/community/code-health-reports/README.md +38 -0
  101. package/templates/community/code-health-reports/lead-prompt.md +52 -0
  102. package/templates/community/code-health-reports/report.mjs +378 -0
  103. package/templates/community/code-health-reports/run.sh +143 -0
  104. package/templates/schedules/weekly-code-health-reports/config.json +13 -0
  105. package/templates/schedules/weekly-code-health-reports/content.md +60 -0
  106. package/src/model-tiers.ts +0 -140
  107. package/templates/community/.gitkeep +0 -0
@@ -2,7 +2,6 @@ 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";
6
5
  import {
7
6
  type Attributes,
8
7
  initOtel,
@@ -34,7 +33,7 @@ import {
34
33
  type ProviderSessionConfig,
35
34
  } from "../providers/index.ts";
36
35
  import { initTelemetry, telemetry } from "../telemetry.ts";
37
- import type { ProviderName, RepoGuidelines } from "../types.ts";
36
+ import { type ProviderName, type RepoGuidelines, resolveTaskModelSelection } from "../types.ts";
38
37
  import { getApiKey } from "../utils/api-key.ts";
39
38
  import { computeBudgetBackoffMs } from "../utils/budget-backoff.ts";
40
39
  import { getMcpBaseUrl } from "../utils/constants.ts";
@@ -52,9 +51,9 @@ import { resolveHarnessProvider } from "../utils/harness-provider.ts";
52
51
  import { prettyPrintLine, prettyPrintStderr } from "../utils/pretty-print.ts";
53
52
  import { scrubSecrets } from "../utils/secret-scrubber.ts";
54
53
  import { refreshSkillsIfChanged } from "../utils/skills-refresh.ts";
54
+ import { interpolate } from "../utils/template.ts";
55
55
  import { detectVcsProvider } from "../vcs/index.ts";
56
56
  import { validateJsonSchema } from "../workflows/json-schema-validator.ts";
57
- import { interpolate } from "../workflows/template.ts";
58
57
  import { buildContextPreamble, buildResumeContextPreamble } from "./context-preamble.ts";
59
58
  import { awaitCredentials, BootMaxWaitExceededError, EX_CONFIG } from "./credential-wait.ts";
60
59
  import {
@@ -131,6 +130,7 @@ async function fetchRepoConfig(
131
130
  name: string;
132
131
  clonePath: string;
133
132
  defaultBranch: string;
133
+ hooks?: { enabled: boolean } | null;
134
134
  guidelines?: RepoGuidelines | null;
135
135
  } | null> {
136
136
  try {
@@ -145,6 +145,7 @@ async function fetchRepoConfig(
145
145
  name: string;
146
146
  clonePath: string;
147
147
  defaultBranch: string;
148
+ hooks?: { enabled: boolean } | null;
148
149
  guidelines?: RepoGuidelines | null;
149
150
  }>;
150
151
  };
@@ -235,12 +236,40 @@ async function refreshExistingRepoForTask(
235
236
  }
236
237
  }
237
238
 
239
+ function getInstallRepoHooksScriptPath(): string {
240
+ const imagePath = "/usr/local/bin/install-repo-hooks.sh";
241
+ if (existsSync(imagePath)) return imagePath;
242
+ return `${process.cwd()}/scripts/install-repo-hooks.sh`;
243
+ }
244
+
245
+ async function installRepoHooksForTask(
246
+ repoConfig: { name: string; clonePath: string; hooks?: { enabled: boolean } | null },
247
+ role: string,
248
+ ): Promise<void> {
249
+ if (repoConfig.hooks?.enabled !== true) return;
250
+
251
+ try {
252
+ const scriptPath = getInstallRepoHooksScriptPath();
253
+ await Bun.$`bash ${scriptPath} ${repoConfig.clonePath} ${repoConfig.name}`;
254
+ console.log(`[${role}] Installed git hooks for ${repoConfig.name}`);
255
+ } catch (err) {
256
+ const errorMsg = scrubSecrets((err as Error).message);
257
+ console.warn(`[${role}] Could not install git hooks for ${repoConfig.name}: ${errorMsg}`);
258
+ }
259
+ }
260
+
238
261
  /**
239
262
  * Ensure a repo is cloned and up-to-date for a task.
240
263
  * Returns { clonePath, claudeMd, warning }.
241
264
  */
242
265
  export async function ensureRepoForTask(
243
- repoConfig: { url: string; name: string; clonePath: string; defaultBranch: string },
266
+ repoConfig: {
267
+ url: string;
268
+ name: string;
269
+ clonePath: string;
270
+ defaultBranch: string;
271
+ hooks?: { enabled: boolean } | null;
272
+ },
244
273
  role: string,
245
274
  ): Promise<{
246
275
  clonePath: string;
@@ -275,6 +304,8 @@ export async function ensureRepoForTask(
275
304
  warning = await refreshExistingRepoForTask({ name, clonePath, defaultBranch }, role);
276
305
  }
277
306
 
307
+ await installRepoHooksForTask(repoConfig, role);
308
+
278
309
  const claudeMd = await readClaudeMd(clonePath, role);
279
310
  const autoStashes = await listSwarmAutostashes(clonePath, role);
280
311
  return { clonePath, claudeMd, warning, autoStashes };
@@ -1484,7 +1515,11 @@ function setupShutdownHandlers(
1484
1515
  }
1485
1516
 
1486
1517
  if (apiConfig) {
1487
- telemetry.session("ended", { agentId: apiConfig.agentId });
1518
+ telemetry.session("ended", {
1519
+ agentId: apiConfig.agentId,
1520
+ durationMs: state ? Date.now() - state.startedAt : undefined,
1521
+ tasksProcessed: state?.tasksProcessed ?? 0,
1522
+ });
1488
1523
  await closeAgent(apiConfig, role);
1489
1524
  }
1490
1525
  await savePm2State(role);
@@ -1554,12 +1589,18 @@ interface RunningTask {
1554
1589
  hasLocalEnvironment: boolean;
1555
1590
  /** Harness variant captured on session_init (e.g. "bridge" or "stock") */
1556
1591
  harnessVariant?: string;
1592
+ /** Harness metadata captured on session_init (currently includes provider package version) */
1593
+ harnessVariantMeta?: Record<string, unknown>;
1594
+ /** Resolved model used for the provider session, when configured */
1595
+ model?: string;
1557
1596
  }
1558
1597
 
1559
1598
  /** Runner state for tracking concurrent tasks */
1560
1599
  interface RunnerState {
1561
1600
  activeTasks: Map<string, RunningTask>;
1562
1601
  maxConcurrent: number;
1602
+ startedAt: number;
1603
+ tasksProcessed: number;
1563
1604
  /**
1564
1605
  * Effective harness provider for this worker boot session — resolved
1565
1606
  * from `swarm_config` (overlay) > `process.env.HARNESS_PROVIDER` > "claude".
@@ -2542,6 +2583,19 @@ function providerEventAttributes(event: ProviderEvent): Attributes {
2542
2583
  }
2543
2584
  }
2544
2585
 
2586
+ function normalizeSessionErrorCategory(category: string | undefined): string {
2587
+ switch (category) {
2588
+ case "rate_limit":
2589
+ case "api_error":
2590
+ case "context_overflow":
2591
+ case "timeout":
2592
+ case "provider_error":
2593
+ return category;
2594
+ default:
2595
+ return "unknown";
2596
+ }
2597
+ }
2598
+
2545
2599
  /**
2546
2600
  * Entry shape for the `activeToolSpans` map maintained by `runWithSession`.
2547
2601
  * Exported for unit tests that exercise `implicitCloseActiveToolSpans`.
@@ -2799,6 +2853,9 @@ async function spawnProviderProcess(
2799
2853
  const eventFlushTimer = setInterval(flushEvents, EVENT_FLUSH_INTERVAL_MS);
2800
2854
  const sessionStartTime = Date.now();
2801
2855
  let providerSessionId = session.sessionId;
2856
+ let pendingHarnessVariant: string | undefined;
2857
+ let pendingHarnessVariantMeta: Record<string, unknown> | undefined;
2858
+ let runningTaskForSessionInit: RunningTask | undefined;
2802
2859
  const activeToolSpans = new Map<
2803
2860
  string,
2804
2861
  {
@@ -2823,12 +2880,37 @@ async function spawnProviderProcess(
2823
2880
  // snapshots carry a real `cumulativeOutputTokens` (was 0 until completion).
2824
2881
  let cumulativeProgressOutputTokens = 0;
2825
2882
 
2883
+ function bufferSessionLogLine(content: string) {
2884
+ if (!shouldStream) return;
2885
+ logBuffer.lines.push(content);
2886
+ const shouldFlush =
2887
+ logBuffer.lines.length >= LOG_BUFFER_SIZE ||
2888
+ Date.now() - logBuffer.lastFlush >= LOG_FLUSH_INTERVAL_MS;
2889
+ if (shouldFlush) {
2890
+ flushLogBuffer(logBuffer, {
2891
+ apiUrl: opts.apiUrl,
2892
+ apiKey: opts.apiKey,
2893
+ agentId: opts.agentId,
2894
+ sessionId: opts.runnerSessionId,
2895
+ iteration: opts.iteration,
2896
+ taskId: effectiveTaskId,
2897
+ cli: adapter.name,
2898
+ }).catch(() => {});
2899
+ }
2900
+ }
2901
+
2826
2902
  session.onEvent((event) =>
2827
2903
  withSpanContext(sessionSpan, () => {
2828
2904
  sessionSpan.addEvent(`provider.${event.type}`, providerEventAttributes(event));
2829
2905
  switch (event.type) {
2830
2906
  case "session_init":
2831
2907
  providerSessionId = event.sessionId;
2908
+ pendingHarnessVariant = event.harnessVariant;
2909
+ pendingHarnessVariantMeta = event.harnessVariantMeta;
2910
+ if (runningTaskForSessionInit) {
2911
+ runningTaskForSessionInit.harnessVariant = event.harnessVariant;
2912
+ runningTaskForSessionInit.harnessVariantMeta = event.harnessVariantMeta;
2913
+ }
2832
2914
  sessionSpan.setAttributes({
2833
2915
  "agentswarm.provider.session_id": providerSessionId,
2834
2916
  "agentswarm.provider.name": event.provider,
@@ -3074,6 +3156,11 @@ async function spawnProviderProcess(
3074
3156
  }
3075
3157
  case "compaction": {
3076
3158
  // Always record compaction events (no throttle)
3159
+ telemetry.compaction("triggered", {
3160
+ compactTrigger: event.compactTrigger,
3161
+ preCompactTokens: event.preCompactTokens,
3162
+ contextTotalTokens: event.contextTotalTokens,
3163
+ });
3077
3164
  fetch(`${opts.apiUrl}/api/tasks/${realTaskId}/context`, {
3078
3165
  method: "POST",
3079
3166
  headers: {
@@ -3093,26 +3180,17 @@ async function spawnProviderProcess(
3093
3180
  }
3094
3181
  case "raw_log":
3095
3182
  prettyPrintLine(event.content, opts.role);
3096
- if (shouldStream) {
3097
- logBuffer.lines.push(event.content);
3098
- const shouldFlush =
3099
- logBuffer.lines.length >= LOG_BUFFER_SIZE ||
3100
- Date.now() - logBuffer.lastFlush >= LOG_FLUSH_INTERVAL_MS;
3101
- if (shouldFlush) {
3102
- flushLogBuffer(logBuffer, {
3103
- apiUrl: opts.apiUrl,
3104
- apiKey: opts.apiKey,
3105
- agentId: opts.agentId,
3106
- sessionId: opts.runnerSessionId,
3107
- iteration: opts.iteration,
3108
- taskId: effectiveTaskId,
3109
- cli: adapter.name,
3110
- }).catch(() => {});
3111
- }
3112
- }
3183
+ bufferSessionLogLine(event.content);
3113
3184
  break;
3114
3185
  case "raw_stderr":
3115
3186
  prettyPrintStderr(event.content, opts.role);
3187
+ bufferSessionLogLine(
3188
+ JSON.stringify({
3189
+ type: "stderr",
3190
+ content: event.content,
3191
+ timestamp: new Date().toISOString(),
3192
+ }),
3193
+ );
3116
3194
  break;
3117
3195
 
3118
3196
  case "progress": {
@@ -3202,6 +3280,21 @@ async function spawnProviderProcess(
3202
3280
  "gen_ai.usage.output_tokens": result.cost.outputTokens ?? 0,
3203
3281
  "agentswarm.cost.total_usd": result.cost.totalCostUsd ?? 0,
3204
3282
  });
3283
+ telemetry.session("cost", {
3284
+ agentId: opts.agentId,
3285
+ model: result.cost.model,
3286
+ provider: result.cost.provider ?? opts.harnessProvider,
3287
+ inputTokens: result.cost.inputTokens ?? 0,
3288
+ outputTokens: result.cost.outputTokens ?? 0,
3289
+ cacheReadTokens: result.cost.cacheReadTokens,
3290
+ cacheWriteTokens: result.cost.cacheWriteTokens,
3291
+ reasoningOutputTokens: result.cost.reasoningOutputTokens,
3292
+ thinkingTokens: result.cost.thinkingTokens,
3293
+ totalCostUsd: result.cost.totalCostUsd,
3294
+ durationMs: result.cost.durationMs,
3295
+ numTurns: result.cost.numTurns,
3296
+ isError: result.cost.isError,
3297
+ });
3205
3298
  try {
3206
3299
  await saveCostData(
3207
3300
  { ...result.cost, taskId: realTaskId, sessionId: opts.runnerSessionId },
@@ -3303,7 +3396,15 @@ async function spawnProviderProcess(
3303
3396
  // swap that mutates the global RunnerState (review finding 2).
3304
3397
  harnessProvider: opts.harnessProvider,
3305
3398
  hasLocalEnvironment: adapter.traits.hasLocalEnvironment,
3399
+ model: model || undefined,
3306
3400
  };
3401
+ runningTaskForSessionInit = runningTask;
3402
+ if (pendingHarnessVariant !== undefined) {
3403
+ runningTask.harnessVariant = pendingHarnessVariant;
3404
+ }
3405
+ if (pendingHarnessVariantMeta !== undefined) {
3406
+ runningTask.harnessVariantMeta = pendingHarnessVariantMeta;
3407
+ }
3307
3408
 
3308
3409
  // Non-blocking completion tracking
3309
3410
  promise
@@ -3333,6 +3434,10 @@ async function checkCompletedProcesses(
3333
3434
  credentialInfo?: RunningTask["credentialInfo"];
3334
3435
  harnessProvider: ProviderName;
3335
3436
  hasLocalEnvironment: boolean;
3437
+ harnessVariant?: string;
3438
+ harnessVariantMeta?: Record<string, unknown>;
3439
+ model?: string;
3440
+ durationMs: number;
3336
3441
  }> = [];
3337
3442
 
3338
3443
  for (const [taskId, task] of state.activeTasks) {
@@ -3350,6 +3455,10 @@ async function checkCompletedProcesses(
3350
3455
  credentialInfo: task.credentialInfo,
3351
3456
  harnessProvider: task.harnessProvider,
3352
3457
  hasLocalEnvironment: task.hasLocalEnvironment,
3458
+ harnessVariant: task.harnessVariant,
3459
+ harnessVariantMeta: task.harnessVariantMeta,
3460
+ model: task.model,
3461
+ durationMs: Date.now() - task.startTime.getTime(),
3353
3462
  });
3354
3463
  }
3355
3464
  }
@@ -3362,6 +3471,10 @@ async function checkCompletedProcesses(
3362
3471
  workingDir,
3363
3472
  credentialInfo,
3364
3473
  harnessProvider,
3474
+ harnessVariant,
3475
+ harnessVariantMeta,
3476
+ model,
3477
+ durationMs,
3365
3478
  } of completedTasks) {
3366
3479
  state.activeTasks.delete(taskId);
3367
3480
  vcsDetectedTasks.delete(taskId);
@@ -3482,6 +3595,33 @@ async function checkCompletedProcesses(
3482
3595
  bridgeFailureDiagnostics,
3483
3596
  );
3484
3597
 
3598
+ telemetry.taskEvent("session_completed", {
3599
+ taskId,
3600
+ agentId: apiConfig.agentId,
3601
+ provider: result.cost?.provider ?? harnessProvider,
3602
+ model: result.cost?.model ?? model,
3603
+ harnessVariant,
3604
+ harnessVersion:
3605
+ typeof harnessVariantMeta?.version === "string" ||
3606
+ typeof harnessVariantMeta?.version === "number"
3607
+ ? String(harnessVariantMeta.version)
3608
+ : undefined,
3609
+ exitCode: result.exitCode,
3610
+ isError: result.exitCode !== 0,
3611
+ durationMs,
3612
+ });
3613
+ if (result.exitCode !== 0 || result.isError) {
3614
+ telemetry.session("failure", {
3615
+ agentId: apiConfig.agentId,
3616
+ errorCategory: normalizeSessionErrorCategory(result.errorCategory),
3617
+ provider: result.cost?.provider ?? harnessProvider,
3618
+ model: result.cost?.model ?? model,
3619
+ durationMs: result.cost?.durationMs ?? durationMs,
3620
+ wasRateLimited: result.rateLimitResetAt != null,
3621
+ });
3622
+ }
3623
+ state.tasksProcessed += 1;
3624
+
3485
3625
  if (result.exitCode === 0 && credentialInfo) {
3486
3626
  reportKeyClearRateLimit(
3487
3627
  apiConfig.apiUrl,
@@ -3660,6 +3800,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3660
3800
  // Failures (network, API down, malformed value) fall back to env then "claude"
3661
3801
  // so a swarm_config outage cannot wedge boot.
3662
3802
  let bootProvider: ProviderName;
3803
+ let bootModel = process.env.MODEL_OVERRIDE || "";
3663
3804
  // Codex credits-exhausted cooldown is sourced solely from the global swarm_config
3664
3805
  // (key `CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS`). Initialize to the default constant
3665
3806
  // for the case where it is unset, then apply the swarm_config value below; on a
@@ -3669,6 +3810,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3669
3810
  try {
3670
3811
  const bootEnv = await fetchResolvedEnv(apiUrl, apiKey, agentId);
3671
3812
  bootProvider = bootEnv.resolvedProvider;
3813
+ bootModel = (bootEnv.env.MODEL_OVERRIDE as string | undefined) || bootModel;
3672
3814
  bootCooldownMs = resolveCodexCreditsExhaustedCooldownMs(
3673
3815
  bootEnv.env.CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS,
3674
3816
  );
@@ -3733,7 +3875,12 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3733
3875
  },
3734
3876
  );
3735
3877
  }
3736
- telemetry.session("started", { agentId });
3878
+ telemetry.session("started", {
3879
+ agentId,
3880
+ harnessProvider: bootProvider,
3881
+ model: bootModel || undefined,
3882
+ role,
3883
+ });
3737
3884
 
3738
3885
  let capabilities = config.capabilities;
3739
3886
 
@@ -3878,6 +4025,8 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3878
4025
  const state: RunnerState = {
3879
4026
  activeTasks: new Map(),
3880
4027
  maxConcurrent,
4028
+ startedAt: Date.now(),
4029
+ tasksProcessed: 0,
3881
4030
  harnessProvider: bootProvider,
3882
4031
  codexCreditsExhaustedCooldownMs: bootCooldownMs,
3883
4032
  };
@@ -4506,7 +4655,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4506
4655
  const effectiveConfig = repoConfig ?? {
4507
4656
  url: task.vcsRepo,
4508
4657
  name: task.vcsRepo.split("/").pop() || task.vcsRepo,
4509
- clonePath: `/workspace/repos/${task.vcsRepo.split("/").pop() || task.vcsRepo}`,
4658
+ clonePath: `/workspace/personal/repos/${task.vcsRepo.split("/").pop() || task.vcsRepo}`,
4510
4659
  defaultBranch: "main",
4511
4660
  };
4512
4661
  const repoContext = await ensureRepoForTask(effectiveConfig, role);
@@ -4897,7 +5046,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4897
5046
  const effectiveConfig = repoConfig ?? {
4898
5047
  url: taskVcsRepo,
4899
5048
  name: taskVcsRepo.split("/").pop() || taskVcsRepo,
4900
- clonePath: `/workspace/repos/${taskVcsRepo.split("/").pop() || taskVcsRepo}`,
5049
+ clonePath: `/workspace/personal/repos/${taskVcsRepo.split("/").pop() || taskVcsRepo}`,
4901
5050
  defaultBranch: "main",
4902
5051
  };
4903
5052
  const repoResult = await ensureRepoForTask(effectiveConfig, role);
@@ -1,3 +1,4 @@
1
+ import { onTaskStarted } from "../be/task-lifecycle-events";
1
2
  import type { AgentTask } from "../types";
2
3
  import {
3
4
  addGraphQLReaction,
@@ -64,3 +65,23 @@ export async function addEyesReactionOnTaskStart(task: AgentTask): Promise<void>
64
65
  console.error("[GitHub] Failed to add eyes reaction on task start:", error);
65
66
  }
66
67
  }
68
+
69
+ let registered = false;
70
+
71
+ /**
72
+ * Subscribe the GitHub eyes-reaction handler to the task-started lifecycle event.
73
+ *
74
+ * Call this once at API-server boot (from `createServer`). It is the API-side
75
+ * inverse of the old `be/db` → `github/task-reactions` import: the data layer now
76
+ * emits `task-started` and this integration reacts. Idempotent — `createServer`
77
+ * may run more than once per process, so repeated calls register only once.
78
+ *
79
+ * API-server only — never wire this on the worker side.
80
+ */
81
+ export function registerGithubTaskReactions(): void {
82
+ if (registered) return;
83
+ registered = true;
84
+ onTaskStarted((task) => {
85
+ addEyesReactionOnTaskStart(task).catch(() => {});
86
+ });
87
+ }
@@ -62,6 +62,46 @@ describe("tool-loop-detection", () => {
62
62
  expect(result!.reason).toContain("stuck in a loop");
63
63
  });
64
64
 
65
+ test("returns critical/blocked at 15 identical fully-specified edits", async () => {
66
+ let result: Awaited<ReturnType<typeof checkToolLoop>> | undefined;
67
+ for (let i = 0; i < 15; i++) {
68
+ result = await checkToolLoop(SESSION_KEY, "Edit", {
69
+ file_path: "/same.ts",
70
+ old_string: "before",
71
+ new_string: "after",
72
+ });
73
+ }
74
+ expect(result!.blocked).toBe(true);
75
+ expect(result!.severity).toBe("critical");
76
+ expect(result!.reason).toContain("15 times");
77
+ expect(result!.reason).toContain("Edit");
78
+ });
79
+
80
+ test("does not block normal codex file_change edits to the same file at 15 calls", async () => {
81
+ let result: Awaited<ReturnType<typeof checkToolLoop>> | undefined;
82
+ for (let i = 0; i < 15; i++) {
83
+ result = await checkToolLoop(SESSION_KEY, "Edit", {
84
+ changes: [{ path: "src/be/db.ts", kind: "update" }],
85
+ });
86
+ expect(result.blocked).toBe(false);
87
+ }
88
+ expect(result!.severity).toBe("warning");
89
+ expect(result!.reason).toContain("15 times");
90
+ });
91
+
92
+ test("still blocks prolonged codex file_change repetition", async () => {
93
+ let result: Awaited<ReturnType<typeof checkToolLoop>> | undefined;
94
+ for (let i = 0; i < 24; i++) {
95
+ result = await checkToolLoop(SESSION_KEY, "Edit", {
96
+ changes: [{ path: "src/be/db.ts", kind: "update" }],
97
+ });
98
+ }
99
+ expect(result!.blocked).toBe(true);
100
+ expect(result!.severity).toBe("critical");
101
+ expect(result!.reason).toContain("24 times");
102
+ expect(result!.reason).toContain("Edit");
103
+ });
104
+
65
105
  test("does not trigger for different args on same tool", async () => {
66
106
  for (let i = 0; i < 20; i++) {
67
107
  const result = await checkToolLoop(SESSION_KEY, "Read", {
@@ -22,6 +22,7 @@ const HISTORY_DIR = "/tmp/agent-swarm-tool-history";
22
22
  const MAX_HISTORY = 30; // Sliding window size
23
23
  const REPEAT_WARNING_THRESHOLD = 8;
24
24
  const REPEAT_CRITICAL_THRESHOLD = 15;
25
+ const LOW_CARDINALITY_FILE_CHANGE_CRITICAL_THRESHOLD = 24;
25
26
  const PINGPONG_WARNING_THRESHOLD = 6;
26
27
  const PINGPONG_CRITICAL_THRESHOLD = 12;
27
28
 
@@ -40,6 +41,38 @@ function hashArgs(args: Record<string, unknown>): string {
40
41
  return hash.toString(36);
41
42
  }
42
43
 
44
+ function isCodexFileChangeArgs(toolName: string, toolInput: Record<string, unknown>): boolean {
45
+ if (toolName !== "Edit" && toolName !== "Write" && toolName !== "Delete") {
46
+ return false;
47
+ }
48
+
49
+ const topLevelKeys = Object.keys(toolInput);
50
+ if (topLevelKeys.length !== 1 || topLevelKeys[0] !== "changes") {
51
+ return false;
52
+ }
53
+
54
+ const changes = toolInput.changes;
55
+ if (!Array.isArray(changes) || changes.length === 0) {
56
+ return false;
57
+ }
58
+
59
+ return changes.every((change) => {
60
+ if (!change || typeof change !== "object" || Array.isArray(change)) {
61
+ return false;
62
+ }
63
+
64
+ const entry = change as Record<string, unknown>;
65
+ const keys = Object.keys(entry).sort();
66
+ return (
67
+ keys.length === 2 &&
68
+ keys[0] === "kind" &&
69
+ keys[1] === "path" &&
70
+ typeof entry.path === "string" &&
71
+ (entry.kind === "add" || entry.kind === "update" || entry.kind === "delete")
72
+ );
73
+ });
74
+ }
75
+
43
76
  /**
44
77
  * Load tool call history for a session.
45
78
  */
@@ -92,6 +125,9 @@ export async function checkToolLoop(
92
125
 
93
126
  // Strategy 1: Same tool + same args repeated
94
127
  const key = `${toolName}:${argsHash}`;
128
+ const repeatCriticalThreshold = isCodexFileChangeArgs(toolName, toolInput)
129
+ ? LOW_CARDINALITY_FILE_CHANGE_CRITICAL_THRESHOLD
130
+ : REPEAT_CRITICAL_THRESHOLD;
95
131
  let repeatCount = 0;
96
132
  for (const record of history) {
97
133
  if (`${record.toolName}:${record.argsHash}` === key) {
@@ -99,7 +135,7 @@ export async function checkToolLoop(
99
135
  }
100
136
  }
101
137
 
102
- if (repeatCount >= REPEAT_CRITICAL_THRESHOLD) {
138
+ if (repeatCount >= repeatCriticalThreshold) {
103
139
  return {
104
140
  blocked: true,
105
141
  severity: "critical",
@@ -21,6 +21,7 @@ import {
21
21
  updateAgentStatus,
22
22
  upsertSwarmConfig,
23
23
  } from "../be/db";
24
+ import { telemetry } from "../telemetry";
24
25
  import {
25
26
  AgentCredStatusSchema,
26
27
  AgentLatestModelSchema,
@@ -322,6 +323,12 @@ export async function handleAgentRegister(
322
323
  return { agent, created: true };
323
324
  })();
324
325
 
326
+ telemetry.agent("registered", {
327
+ role: parsed.body.role,
328
+ capabilities: parsed.body.capabilities ?? [],
329
+ isReconnect: !result.created,
330
+ });
331
+
325
332
  if (result.created) {
326
333
  ensure({
327
334
  id: "registered",
package/src/http/index.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  withSpanContext,
23
23
  } from "../otel";
24
24
  import { startScriptRunSupervisor, stopScriptRunSupervisor } from "../script-workflows/supervisor";
25
+ import { getServerSessionsProcessed } from "../server-runtime-counters";
25
26
  import { startSlackApp, stopSlackApp } from "../slack";
26
27
  import { initTelemetry, telemetry } from "../telemetry";
27
28
  import { getApiKey } from "../utils/api-key";
@@ -113,6 +114,8 @@ const globalState = globalThis as typeof globalThis & {
113
114
 
114
115
  const API_GC_INTERVAL_MS = 5 * 60 * 1000;
115
116
  const MCP_TRANSPORT_IDLE_TIMEOUT_MS = DEFAULT_MCP_TRANSPORT_IDLE_TIMEOUT_MS;
117
+ const serverStartedAt = Date.now();
118
+ let shutdownSignal = "unknown";
116
119
 
117
120
  type GcCapableGlobal = typeof globalThis & { gc?: () => void };
118
121
 
@@ -363,6 +366,11 @@ globalState.__transportActivityUser = transportActivityUser;
363
366
 
364
367
  async function shutdown() {
365
368
  console.log("Shutting down HTTP server...");
369
+ telemetry.server("shutdown", {
370
+ signal: shutdownSignal,
371
+ uptimeMs: Date.now() - serverStartedAt,
372
+ sessionsProcessed: getServerSessionsProcessed(),
373
+ });
366
374
 
367
375
  // Stop scheduler (if enabled)
368
376
  if (hasCapability("scheduling")) {
@@ -424,8 +432,14 @@ async function shutdown() {
424
432
  // Only register signal handlers once (avoid duplicates on hot reload)
425
433
  if (!globalState.__sigintRegistered) {
426
434
  globalState.__sigintRegistered = true;
427
- process.on("SIGINT", shutdown);
428
- process.on("SIGTERM", shutdown);
435
+ process.on("SIGINT", () => {
436
+ shutdownSignal = "SIGINT";
437
+ shutdown();
438
+ });
439
+ process.on("SIGTERM", () => {
440
+ shutdownSignal = "SIGTERM";
441
+ shutdown();
442
+ });
429
443
  }
430
444
 
431
445
  if (!globalState.__runId) {
@@ -12,7 +12,7 @@ import {
12
12
  } from "../be/db";
13
13
  import { getAllTemplateDefinitions, getTemplateDefinition } from "../prompts/registry";
14
14
  import { resolveTemplate } from "../prompts/resolver";
15
- import { interpolate } from "../workflows/template";
15
+ import { interpolate } from "../utils/template";
16
16
  import { route } from "./route-def";
17
17
  import { json, jsonError } from "./utils";
18
18
 
package/src/http/repos.ts CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  getSwarmRepos,
8
8
  updateSwarmRepo,
9
9
  } from "../be/db";
10
- import { RepoGuidelinesSchema, SwarmRepoSchema } from "../types";
10
+ import { RepoGuidelinesSchema, RepoHooksSchema, SwarmRepoSchema } from "../types";
11
11
  import { route } from "./route-def";
12
12
  import { json, jsonError } from "./utils";
13
13
 
@@ -56,6 +56,7 @@ const createRepo = route({
56
56
  clonePath: z.string().optional(),
57
57
  defaultBranch: z.string().optional(),
58
58
  autoClone: z.boolean().optional(),
59
+ hooks: RepoHooksSchema.optional(),
59
60
  guidelines: RepoGuidelinesSchema.nullable().optional(),
60
61
  }),
61
62
  responses: {
@@ -78,6 +79,7 @@ const updateRepo = route({
78
79
  clonePath: z.string().optional(),
79
80
  defaultBranch: z.string().optional(),
80
81
  autoClone: z.boolean().optional(),
82
+ hooks: RepoHooksSchema.nullable().optional(),
81
83
  guidelines: RepoGuidelinesSchema.nullable().optional(),
82
84
  }),
83
85
  responses: {
@@ -141,6 +143,7 @@ export async function handleRepos(
141
143
  clonePath: parsed.body.clonePath,
142
144
  defaultBranch: parsed.body.defaultBranch,
143
145
  autoClone: parsed.body.autoClone,
146
+ hooks: parsed.body.hooks,
144
147
  guidelines: parsed.body.guidelines,
145
148
  });
146
149
  json(res, repo, 201);
@@ -165,6 +168,7 @@ export async function handleRepos(
165
168
  clonePath: parsed.body.clonePath,
166
169
  defaultBranch: parsed.body.defaultBranch,
167
170
  autoClone: parsed.body.autoClone,
171
+ hooks: parsed.body.hooks,
168
172
  guidelines: parsed.body.guidelines,
169
173
  });
170
174
  if (!updated) {
@@ -13,8 +13,8 @@ import {
13
13
  updateScheduledTask,
14
14
  } from "../be/db";
15
15
  import { mergeScheduleTiming, validateRecurringTiming } from "../be/schedules/validate";
16
- import { ModelTierSchema, splitLegacyModelAlias } from "../model-tiers";
17
16
  import { calculateNextRun, createStandaloneScheduleTask } from "../scheduler/scheduler";
17
+ import { ModelTierSchema, splitLegacyModelAlias } from "../types";
18
18
  import { getExecutorRegistry } from "../workflows";
19
19
  import { handleScheduleTrigger } from "../workflows/triggers";
20
20
  import { route } from "./route-def";
@@ -14,6 +14,7 @@ import {
14
14
  getTaskById,
15
15
  } from "../be/db";
16
16
  import { normalizeModelKey } from "../be/pricing-normalize";
17
+ import { incrementServerSessionsProcessed } from "../server-runtime-counters";
17
18
  import type { SessionCost, SessionCostSource } from "../types";
18
19
  import { route } from "./route-def";
19
20
  import { json, jsonError } from "./utils";
@@ -290,6 +291,7 @@ export async function handleSessionData(
290
291
  isError: parsed.body.isError ?? false,
291
292
  costSource,
292
293
  });
294
+ incrementServerSessionsProcessed();
293
295
  json(res, { success: true, cost }, 201);
294
296
  } catch (error) {
295
297
  console.error("[HTTP] Failed to create session cost:", error);