@desplega.ai/agent-swarm 1.88.0 → 1.90.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 (64) hide show
  1. package/README.md +7 -0
  2. package/openapi.json +41 -1
  3. package/package.json +3 -2
  4. package/plugin/skills/composio/SKILL.md +173 -0
  5. package/plugin/skills/composio-gmail/SKILL.md +83 -0
  6. package/plugin/skills/composio-google-calendar/SKILL.md +81 -0
  7. package/plugin/skills/composio-google-docs/SKILL.md +71 -0
  8. package/src/be/db.ts +353 -2
  9. package/src/be/migrations/081_metrics.sql +39 -0
  10. package/src/be/migrations/082_user_audit_fields.sql +120 -0
  11. package/src/be/modelsdev-cache.json +3413 -1423
  12. package/src/be/seed-skills/index.ts +7 -0
  13. package/src/cli.tsx +18 -0
  14. package/src/commands/runner.ts +153 -22
  15. package/src/commands/x.ts +118 -0
  16. package/src/github/handlers.ts +40 -1
  17. package/src/heartbeat/heartbeat.ts +80 -12
  18. package/src/http/active-sessions.ts +32 -1
  19. package/src/http/auth.ts +36 -0
  20. package/src/http/core.ts +20 -16
  21. package/src/http/db-query.ts +20 -0
  22. package/src/http/index.ts +2 -0
  23. package/src/http/metrics.ts +447 -0
  24. package/src/http/operator-actor.ts +9 -0
  25. package/src/http/poll.ts +11 -1
  26. package/src/http/tasks.ts +6 -1
  27. package/src/http/workflows.ts +5 -1
  28. package/src/metrics/version.ts +26 -0
  29. package/src/prompts/base-prompt.ts +8 -0
  30. package/src/prompts/session-templates.ts +23 -0
  31. package/src/providers/opencode-adapter.ts +22 -6
  32. package/src/server.ts +10 -1
  33. package/src/tasks/worker-follow-up.ts +19 -1
  34. package/src/tests/base-prompt.test.ts +35 -0
  35. package/src/tests/budget-claim-gate.test.ts +26 -0
  36. package/src/tests/core-auth.test.ts +8 -1
  37. package/src/tests/events-http.test.ts +6 -2
  38. package/src/tests/github-handlers-cancel-config.test.ts +262 -0
  39. package/src/tests/heartbeat-supersede-resume.test.ts +91 -1
  40. package/src/tests/heartbeat.test.ts +84 -3
  41. package/src/tests/http-api-integration.test.ts +3 -1
  42. package/src/tests/metrics-http.test.ts +247 -0
  43. package/src/tests/opencode-adapter.test.ts +90 -30
  44. package/src/tests/runner-repo-autostash.test.ts +117 -0
  45. package/src/tests/runner-requester-profile.test.ts +25 -0
  46. package/src/tests/runner-skills-refresh.test.ts +1 -1
  47. package/src/tests/swarm-x-tool.test.ts +90 -0
  48. package/src/tests/system-default-skills.test.ts +3 -0
  49. package/src/tests/ui-logs-parser.test.ts +271 -0
  50. package/src/tests/user-token-rest-auth.test.ts +129 -0
  51. package/src/tests/workflow-async-v2.test.ts +23 -0
  52. package/src/tests/x-composio.test.ts +122 -0
  53. package/src/tools/create-metric.ts +191 -0
  54. package/src/tools/swarm-x.ts +116 -0
  55. package/src/tools/tool-config.ts +6 -0
  56. package/src/types.ts +120 -0
  57. package/src/utils/request-auth-context.ts +28 -0
  58. package/src/utils/skills-refresh.ts +2 -2
  59. package/src/workflows/engine.ts +24 -2
  60. package/src/workflows/executors/agent-task.ts +2 -0
  61. package/src/x/composio.ts +295 -0
  62. package/templates/skills/attio-interaction/SKILL.md +279 -0
  63. package/templates/skills/attio-interaction/config.json +14 -0
  64. package/templates/skills/attio-interaction/content.md +272 -0
@@ -11,6 +11,12 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import artifactsConfig from "../../../templates/skills/artifacts/config.json" with { type: "text" };
13
13
  import artifactsContent from "../../../templates/skills/artifacts/content.md" with { type: "text" };
14
+ import attioInteractionConfig from "../../../templates/skills/attio-interaction/config.json" with {
15
+ type: "text",
16
+ };
17
+ import attioInteractionContent from "../../../templates/skills/attio-interaction/content.md" with {
18
+ type: "text",
19
+ };
14
20
  import kvStorageConfig from "../../../templates/skills/kv-storage/config.json" with {
15
21
  type: "text",
16
22
  };
@@ -61,6 +67,7 @@ export type SeedSkill = {
61
67
  };
62
68
 
63
69
  const BUILT_IN_SKILL_SOURCES = [
70
+ { config: attioInteractionConfig, body: attioInteractionContent },
64
71
  { config: artifactsConfig, body: artifactsContent },
65
72
  { config: kvStorageConfig, body: kvStorageContent },
66
73
  { config: pagesConfig, body: pagesContent },
package/src/cli.tsx CHANGED
@@ -255,6 +255,19 @@ const COMMAND_HELP: Record<
255
255
  options: " -h, --help Show this help",
256
256
  examples: [` ${binName} artifact serve`, ` ${binName} artifact help`].join("\n"),
257
257
  },
258
+ x: {
259
+ usage: `${binName} x <target> [args]`,
260
+ description:
261
+ "Execute external command routes. Prototype target: composio routes HTTP requests to the Composio REST API using COMPOSIO_API_KEY.",
262
+ options: [
263
+ " composio <method> <path> Route to the Composio REST API",
264
+ " -h, --help Show this help",
265
+ ].join("\n"),
266
+ examples: [
267
+ ` ${binName} x composio GET /tools`,
268
+ ` ${binName} x composio POST /tools/execute/GITHUB_CREATE_AN_ISSUE --body '{"arguments":{}}'`,
269
+ ].join("\n"),
270
+ },
258
271
  scripts: {
259
272
  usage: `${binName} scripts reembed`,
260
273
  description: "Maintenance commands for reusable swarm scripts.",
@@ -369,6 +382,7 @@ function printHelp(command?: string) {
369
382
  ["claude", "Run Claude CLI"],
370
383
  ["hook", "Handle Claude Code hook events (stdin)"],
371
384
  ["artifact", "Manage agent artifacts"],
385
+ ["x", "Execute external command routes"],
372
386
  ["scripts", "Reusable scripts maintenance"],
373
387
  ["docs", "Open documentation (--open to launch in browser)"],
374
388
  ["codex-login", "Authenticate Codex via ChatGPT OAuth"],
@@ -612,6 +626,10 @@ if (args.showHelp || args.command === "help" || args.command === undefined) {
612
626
  port: args.port,
613
627
  key: args.key,
614
628
  });
629
+ } else if (args.command === "x") {
630
+ const xArgs = process.argv.slice(process.argv.indexOf("x") + 1);
631
+ const { runXCommand } = await import("./commands/x");
632
+ await runXCommand(xArgs);
615
633
  } else if (args.command === "scripts") {
616
634
  const scriptsArgs = process.argv.slice(process.argv.indexOf("scripts") + 1);
617
635
  if (args.showHelp || scriptsArgs[0] !== "reembed") {
@@ -126,14 +126,88 @@ async function readClaudeMd(clonePath: string, role: string): Promise<string | n
126
126
  return null;
127
127
  }
128
128
 
129
+ export type SwarmAutostash = { ref: string; message: string };
130
+
131
+ async function listSwarmAutostashes(clonePath: string, role: string): Promise<SwarmAutostash[]> {
132
+ try {
133
+ const result = await Bun.$`cd ${clonePath} && git stash list --format=%gd%x09%s`.quiet();
134
+ return result
135
+ .text()
136
+ .split("\n")
137
+ .map((line) => line.trim())
138
+ .filter((line) => line.includes("swarm-autostash"))
139
+ .flatMap((line) => {
140
+ const [ref, ...messageParts] = line.split("\t");
141
+ if (!ref) return [];
142
+ return [{ ref, message: messageParts.join("\t") || line }];
143
+ });
144
+ } catch (err) {
145
+ console.warn(
146
+ `[${role}] Could not inspect git stashes for ${clonePath}: ${scrubSecrets((err as Error).message)}`,
147
+ );
148
+ return [];
149
+ }
150
+ }
151
+
152
+ async function refreshExistingRepoForTask(
153
+ repoConfig: { name: string; clonePath: string; defaultBranch: string },
154
+ role: string,
155
+ ): Promise<string | null> {
156
+ const { name, clonePath, defaultBranch } = repoConfig;
157
+ const statusResult = await Bun.$`cd ${clonePath} && git status --porcelain`.quiet();
158
+ const statusOutput = statusResult.text().trim();
159
+ let stashMessage: string | null = null;
160
+
161
+ if (statusOutput !== "") {
162
+ stashMessage = `swarm-autostash ${defaultBranch} ${new Date().toISOString()}`;
163
+ try {
164
+ console.log(`[${role}] Auto-stashing pending work in ${name}: ${stashMessage}`);
165
+ await Bun.$`cd ${clonePath} && git stash push --include-untracked -m ${stashMessage}`.quiet();
166
+ console.log(`[${role}] Auto-stashed pending work in ${name}`);
167
+ } catch (err) {
168
+ const errorMsg = scrubSecrets((err as Error).message);
169
+ console.warn(`[${role}] Could not auto-stash ${name}, skipping pull: ${errorMsg}`);
170
+ return `The repo "${name}" at ${clonePath} has uncommitted changes, but auto-stash failed: ${errorMsg}. A git pull was skipped to avoid losing work.`;
171
+ }
172
+ }
173
+
174
+ try {
175
+ console.log(`[${role}] Refreshing ${name} from origin/${defaultBranch}...`);
176
+ const fetchSpec = `${defaultBranch}:refs/remotes/origin/${defaultBranch}`;
177
+ const remoteRef = `refs/remotes/origin/${defaultBranch}`;
178
+ await Bun.$`cd ${clonePath} && git fetch origin ${fetchSpec}`.quiet();
179
+ await Bun.$`cd ${clonePath} && git merge --no-edit --no-stat ${remoteRef}`.quiet();
180
+ console.log(`[${role}] Refreshed ${name}`);
181
+ return null;
182
+ } catch (err) {
183
+ const errorMsg = scrubSecrets((err as Error).message);
184
+ console.warn(`[${role}] Could not refresh ${name}: ${errorMsg}`);
185
+ try {
186
+ await Bun.$`cd ${clonePath} && git merge --abort`.quiet();
187
+ } catch {
188
+ // No merge in progress, or abort failed. The original refresh warning is
189
+ // the actionable signal; repo setup remains best-effort.
190
+ }
191
+ const stashNote = stashMessage
192
+ ? ` Pending work was preserved in git stash "${stashMessage}".`
193
+ : "";
194
+ return `The repo "${name}" at ${clonePath} could not be refreshed from origin/${defaultBranch}: ${errorMsg}.${stashNote}`;
195
+ }
196
+ }
197
+
129
198
  /**
130
199
  * Ensure a repo is cloned and up-to-date for a task.
131
200
  * Returns { clonePath, claudeMd, warning }.
132
201
  */
133
- async function ensureRepoForTask(
202
+ export async function ensureRepoForTask(
134
203
  repoConfig: { url: string; name: string; clonePath: string; defaultBranch: string },
135
204
  role: string,
136
- ): Promise<{ clonePath: string; claudeMd: string | null; warning: string | null }> {
205
+ ): Promise<{
206
+ clonePath: string;
207
+ claudeMd: string | null;
208
+ warning: string | null;
209
+ autoStashes: SwarmAutostash[];
210
+ }> {
137
211
  const { url, name, clonePath, defaultBranch } = repoConfig;
138
212
 
139
213
  try {
@@ -158,28 +232,20 @@ async function ensureRepoForTask(
158
232
  console.log(`[${role}] Cloned ${name}`);
159
233
  } else {
160
234
  console.log(`[${role}] Repo ${name} already cloned at ${clonePath}`);
161
- const statusResult = await Bun.$`cd ${clonePath} && git status --porcelain`.quiet();
162
- const statusOutput = statusResult.text().trim();
163
-
164
- if (statusOutput === "") {
165
- console.log(`[${role}] Pulling ${name} (${defaultBranch})...`);
166
- await Bun.$`cd ${clonePath} && git pull origin ${defaultBranch} --ff-only`.quiet();
167
- console.log(`[${role}] Pulled ${name}`);
168
- } else {
169
- console.warn(`[${role}] Repo ${name} has uncommitted changes, skipping pull`);
170
- warning = `The repo "${name}" at ${clonePath} has uncommitted changes. A git pull was skipped to avoid losing work. You may need to commit or stash changes before pulling updates.`;
171
- }
235
+ warning = await refreshExistingRepoForTask({ name, clonePath, defaultBranch }, role);
172
236
  }
173
237
 
174
238
  const claudeMd = await readClaudeMd(clonePath, role);
175
- return { clonePath, claudeMd, warning };
239
+ const autoStashes = await listSwarmAutostashes(clonePath, role);
240
+ return { clonePath, claudeMd, warning, autoStashes };
176
241
  } catch (err) {
177
- const errorMsg = (err as Error).message;
242
+ const errorMsg = scrubSecrets((err as Error).message);
178
243
  console.warn(`[${role}] Error setting up repo ${name}: ${errorMsg}`);
179
244
  const warning = `Failed to clone/setup repo "${name}" at ${clonePath}: ${errorMsg}. The repo may not be available. You may need to clone it manually.`;
180
245
  // Only return clonePath if the directory actually exists (clone may have failed)
181
246
  const cloneExists = existsSync(clonePath);
182
- return { clonePath: cloneExists ? clonePath : "", claudeMd: null, warning };
247
+ const autoStashes = cloneExists ? await listSwarmAutostashes(clonePath, role) : [];
248
+ return { clonePath: cloneExists ? clonePath : "", claudeMd: null, warning, autoStashes };
183
249
  }
184
250
  }
185
251
 
@@ -1692,6 +1758,31 @@ async function cleanupActiveSessions(config: ApiConfig): Promise<void> {
1692
1758
  }
1693
1759
  }
1694
1760
 
1761
+ /** Reset orphaned in-progress tasks for this agent back to pending dispatch. */
1762
+ async function recoverOrphanedInProgressTasks(config: ApiConfig): Promise<number> {
1763
+ const headers: Record<string, string> = {
1764
+ "Content-Type": "application/json",
1765
+ "X-Agent-ID": config.agentId,
1766
+ };
1767
+ if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
1768
+ try {
1769
+ const response = await fetch(`${config.apiUrl}/api/active-sessions/recover-orphaned-tasks`, {
1770
+ method: "POST",
1771
+ headers,
1772
+ body: JSON.stringify({ agentId: config.agentId, minAgeSeconds: 60 }),
1773
+ });
1774
+ if (!response.ok) {
1775
+ console.warn(`[runner] Failed to recover orphaned tasks: ${response.status}`);
1776
+ return 0;
1777
+ }
1778
+ const data = (await response.json()) as { recovered?: number };
1779
+ return data.recovered ?? 0;
1780
+ } catch (error) {
1781
+ console.warn(`[runner] Error recovering orphaned tasks: ${error}`);
1782
+ return 0;
1783
+ }
1784
+ }
1785
+
1695
1786
  /** Trigger a heartbeat sweep via the API (lead startup self-check) */
1696
1787
  async function triggerHeartbeatSweep(config: ApiConfig): Promise<boolean> {
1697
1788
  try {
@@ -1743,7 +1834,7 @@ interface Trigger {
1743
1834
  text?: string;
1744
1835
  }>;
1745
1836
  cursorUpdates?: Array<{ channelId: string; ts: string }>; // Deferred cursor commits for channel_activity
1746
- requestedBy?: { name: string; email?: string };
1837
+ requestedBy?: { name: string; email?: string; role?: string; notes?: string };
1747
1838
  // Phase 4 — budget_refused fields. The server emits this envelope from
1748
1839
  // /api/poll and MCP task-action accept when an admission gate refuses to
1749
1840
  // let the agent claim a task. Worker reads cause + reset/spend/budget for
@@ -1766,6 +1857,26 @@ interface PollOptions {
1766
1857
  since?: string; // Optional: for filtering finished tasks
1767
1858
  }
1768
1859
 
1860
+ type RequesterProfile = NonNullable<Trigger["requestedBy"]>;
1861
+
1862
+ export async function buildRequesterProfilePrompt(
1863
+ requestedBy: RequesterProfile | undefined,
1864
+ ): Promise<string> {
1865
+ if (!requestedBy?.role && !requestedBy?.notes) return "";
1866
+
1867
+ const notes = requestedBy.notes?.trim();
1868
+ const notesSection = notes
1869
+ ? `\nTheir stated notes for how you should respond and act:\n${notes}`
1870
+ : "";
1871
+ const result = await resolveTemplateAsync("task.requester.profile", {
1872
+ requester_name: requestedBy.name,
1873
+ requester_role_suffix: requestedBy.role ? ` (${requestedBy.role})` : "",
1874
+ requester_notes_section: notesSection,
1875
+ });
1876
+
1877
+ return result.skipped ? "" : result.text.trim();
1878
+ }
1879
+
1769
1880
  /** Register agent via HTTP API */
1770
1881
  async function registerAgent(opts: {
1771
1882
  apiUrl: string;
@@ -3608,6 +3719,12 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3608
3719
  // Clean up any stale active sessions from previous runs (crash recovery)
3609
3720
  await cleanupActiveSessions(apiConfig);
3610
3721
  console.log(`[${role}] Cleaned up stale active sessions`);
3722
+ const startupRecoveredOrphans = await recoverOrphanedInProgressTasks(apiConfig);
3723
+ if (startupRecoveredOrphans > 0) {
3724
+ console.log(
3725
+ `[${role}] Recovered ${startupRecoveredOrphans} orphaned in-progress task(s) to pending`,
3726
+ );
3727
+ }
3611
3728
 
3612
3729
  // Fetch full agent profile to get soul/identity content
3613
3730
  try {
@@ -4084,7 +4201,10 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4084
4201
  // state persists across iterations.
4085
4202
  let consecutiveBudgetRefusals = 0;
4086
4203
 
4087
- // Track last finished task check for leads (to avoid re-processing)
4204
+ // Throttle orphan recovery so it runs periodically while the worker is idle or under capacity.
4205
+ let lastOrphanRecoveryAt = 0;
4206
+ const ORPHAN_RECOVERY_INTERVAL_MS = 60_000;
4207
+
4088
4208
  while (true) {
4089
4209
  // Ping server on each iteration to keep status updated
4090
4210
  await pingServer(apiConfig, role);
@@ -4180,6 +4300,16 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4180
4300
 
4181
4301
  // Only poll if we have capacity
4182
4302
  if (state.activeTasks.size < state.maxConcurrent) {
4303
+ if (Date.now() - lastOrphanRecoveryAt > ORPHAN_RECOVERY_INTERVAL_MS) {
4304
+ lastOrphanRecoveryAt = Date.now();
4305
+ const recoveredOrphans = await recoverOrphanedInProgressTasks(apiConfig);
4306
+ if (recoveredOrphans > 0) {
4307
+ console.log(
4308
+ `[${role}] Recovered ${recoveredOrphans} orphaned in-progress task(s) to pending`,
4309
+ );
4310
+ }
4311
+ }
4312
+
4183
4313
  console.log(
4184
4314
  `[${role}] Polling for triggers (${state.activeTasks.size}/${state.maxConcurrent} active)...`,
4185
4315
  );
@@ -4419,10 +4549,11 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4419
4549
 
4420
4550
  // Rebuild system prompt with per-task repo context
4421
4551
  const taskBasePrompt = await buildSystemPrompt();
4422
- const taskSystemPrompt =
4423
- (additionalSystemPrompt
4424
- ? `${taskBasePrompt}\n\n${additionalSystemPrompt}`
4425
- : taskBasePrompt) + cwdWarning;
4552
+ const requesterProfilePrompt = await buildRequesterProfilePrompt(trigger.requestedBy);
4553
+ const taskPromptParts = [taskBasePrompt, requesterProfilePrompt, additionalSystemPrompt]
4554
+ .filter((part): part is string => Boolean(part))
4555
+ .join("\n\n");
4556
+ const taskSystemPrompt = taskPromptParts + cwdWarning;
4426
4557
 
4427
4558
  iteration++;
4428
4559
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
@@ -0,0 +1,118 @@
1
+ import { scrubSecrets } from "../utils/secret-scrubber";
2
+ import {
3
+ DEFAULT_COMPOSIO_BASE_URL,
4
+ executeComposioRequest,
5
+ formatComposioResultForCli,
6
+ parseComposioArgs,
7
+ } from "../x/composio";
8
+
9
+ interface XCommandDeps {
10
+ env?: Record<string, string | undefined>;
11
+ error?: (message: string) => void;
12
+ exit?: (code: number) => void;
13
+ fetch?: typeof fetch;
14
+ log?: (message: string) => void;
15
+ }
16
+
17
+ export { parseComposioArgs } from "../x/composio";
18
+
19
+ export async function runXCommand(argv: string[], deps: XCommandDeps = {}): Promise<void> {
20
+ const [target, ...rest] = argv;
21
+ const log = deps.log ?? console.log;
22
+ const error = deps.error ?? console.error;
23
+ const exit = deps.exit ?? process.exit;
24
+
25
+ if (!target || target === "help" || target === "-h" || target === "--help") {
26
+ printXHelp(log);
27
+ return;
28
+ }
29
+
30
+ switch (target) {
31
+ case "composio":
32
+ await runComposioCommand(rest, deps);
33
+ return;
34
+ default:
35
+ error(`Unknown x target: ${target}`);
36
+ printXHelp(error);
37
+ exit(1);
38
+ }
39
+ }
40
+
41
+ export async function runComposioCommand(argv: string[], deps: XCommandDeps = {}): Promise<void> {
42
+ const log = deps.log ?? console.log;
43
+ const error = deps.error ?? console.error;
44
+ const exit = deps.exit ?? process.exit;
45
+ const env = deps.env ?? process.env;
46
+
47
+ if (argv.length === 0 || argv[0] === "help" || argv[0] === "-h" || argv[0] === "--help") {
48
+ printComposioHelp(log);
49
+ return;
50
+ }
51
+
52
+ let parsed: ReturnType<typeof parseComposioArgs>;
53
+ try {
54
+ parsed = parseComposioArgs(argv, env);
55
+ } catch (err) {
56
+ error(`composio: ${scrubSecrets(errorMessage(err))}`);
57
+ printComposioHelp(error);
58
+ exit(1);
59
+ return;
60
+ }
61
+
62
+ const result = await executeComposioRequest(parsed, { env, fetch: deps.fetch });
63
+
64
+ if (!result.ok) {
65
+ if (result.status > 0) error(`composio: HTTP ${result.status} ${result.statusText}`.trim());
66
+ else error(`composio: ${result.error ?? result.statusText}`);
67
+ if (result.formattedBody) error(result.formattedBody);
68
+ exit(1);
69
+ return;
70
+ }
71
+
72
+ log(formatComposioResultForCli(result));
73
+ }
74
+
75
+ function errorMessage(err: unknown): string {
76
+ return err instanceof Error ? err.message : String(err);
77
+ }
78
+
79
+ function printXHelp(log: (message: string) => void): void {
80
+ log(`Usage: agent-swarm x <target> [args]
81
+
82
+ Targets:
83
+ composio Route a request to the Composio REST API
84
+
85
+ Examples:
86
+ agent-swarm x composio GET /tools
87
+ agent-swarm x composio POST /tools/execute/GITHUB_CREATE_AN_ISSUE --body '{"arguments":{}}'`);
88
+ }
89
+
90
+ function printComposioHelp(log: (message: string) => void): void {
91
+ log(`Usage: agent-swarm x composio <method> <path> [options]
92
+
93
+ Routes an HTTP request to the Composio REST API.
94
+
95
+ Arguments:
96
+ <method> GET, POST, PUT, PATCH, DELETE, or HEAD
97
+ <path> API path relative to ${DEFAULT_COMPOSIO_BASE_URL}
98
+
99
+ Options:
100
+ --body, --data <json> JSON request body
101
+ -q, --query k=v Append a query parameter (repeatable)
102
+ -H, --header k=v Add a header (repeatable)
103
+ --base-url <url> Override base URL (default: COMPOSIO_BASE_URL or v3.1 API)
104
+ --org Use COMPOSIO_ORG_API_KEY and x-org-api-key
105
+ --raw Print response text without JSON pretty formatting
106
+ -h, --help Show this help
107
+
108
+ Environment:
109
+ COMPOSIO_API_KEY Project API key for x-api-key auth
110
+ COMPOSIO_ORG_API_KEY Optional organization key for --org
111
+ COMPOSIO_BASE_URL Optional API base URL override
112
+
113
+ Examples:
114
+ agent-swarm x composio GET /tools
115
+ agent-swarm x composio GET /tools --query limit=10
116
+ agent-swarm x composio POST /tool_router/session --body '{"user_id":"swarm"}'
117
+ agent-swarm x composio POST /tools/execute/GITHUB_CREATE_AN_ISSUE --body '{"arguments":{}}'`);
118
+ }
@@ -1,4 +1,4 @@
1
- import { failTask, findTaskByVcs, getAllAgents, incrKv, upsertKv } from "../be/db";
1
+ import { failTask, findTaskByVcs, getAllAgents, getSwarmConfigs, incrKv, upsertKv } from "../be/db";
2
2
  import { findUserByExternalId } from "../be/users";
3
3
  import { resolveTemplate } from "../prompts/resolver";
4
4
  import { githubContextKey } from "../tasks/context-key";
@@ -46,6 +46,19 @@ function buildGithubContextKey(
46
46
  }
47
47
  }
48
48
 
49
+ /**
50
+ * Runtime-config guards for cancel-on-unassign and cancel-on-review-request-removed.
51
+ * Absent key / any value other than "false" → true (cancel, current behavior).
52
+ * Value "false" → false (skip cancel, leave task untouched).
53
+ */
54
+ function cancelFlagEnabled(key: string): boolean {
55
+ const row = getSwarmConfigs({ scope: "global", key })[0];
56
+ return row?.value !== "false";
57
+ }
58
+ const cancelOnUnassignEnabled = () => cancelFlagEnabled("github.cancelOnUnassign");
59
+ const cancelOnReviewRequestRemovedEnabled = () =>
60
+ cancelFlagEnabled("github.cancelOnReviewRequestRemoved");
61
+
49
62
  /**
50
63
  * Get review state emoji and label
51
64
  */
@@ -278,6 +291,14 @@ export async function handlePullRequest(
278
291
  return { created: false };
279
292
  }
280
293
 
294
+ // Config gate: skip cancel if disabled
295
+ if (!cancelOnUnassignEnabled()) {
296
+ console.log(
297
+ `[GitHub] unassign cancel disabled by config — leaving task untouched (PR #${pr.number})`,
298
+ );
299
+ return { created: false };
300
+ }
301
+
281
302
  // Find the related task
282
303
  const task = findTaskByVcs(repository.full_name, pr.number);
283
304
  if (!task) {
@@ -378,6 +399,14 @@ export async function handlePullRequest(
378
399
  return { created: false };
379
400
  }
380
401
 
402
+ // Config gate: skip cancel if disabled
403
+ if (!cancelOnReviewRequestRemovedEnabled()) {
404
+ console.log(
405
+ `[GitHub] review-request-removed cancel disabled by config — leaving task untouched (PR #${pr.number})`,
406
+ );
407
+ return { created: false };
408
+ }
409
+
381
410
  // Find the related task
382
411
  const task = findTaskByVcs(repository.full_name, pr.number);
383
412
  if (!task) {
@@ -533,6 +562,7 @@ export async function handlePullRequest(
533
562
  vcsUrl: pr.html_url,
534
563
  vcsInstallationId: installation?.id,
535
564
  contextKey: buildGithubContextKey(repository.full_name, "pr", pr.number),
565
+ requestedByUserId,
536
566
  });
537
567
 
538
568
  if (lead) {
@@ -638,6 +668,14 @@ export async function handleIssue(
638
668
  return { created: false };
639
669
  }
640
670
 
671
+ // Config gate: skip cancel if disabled
672
+ if (!cancelOnUnassignEnabled()) {
673
+ console.log(
674
+ `[GitHub] unassign cancel disabled by config — leaving task untouched (issue #${issue.number})`,
675
+ );
676
+ return { created: false };
677
+ }
678
+
641
679
  // Find the related task
642
680
  const task = findTaskByVcs(repository.full_name, issue.number);
643
681
  if (!task) {
@@ -771,6 +809,7 @@ export async function handleIssue(
771
809
  vcsUrl: issue.html_url,
772
810
  vcsInstallationId: installation?.id,
773
811
  contextKey: buildGithubContextKey(repository.full_name, "issue", issue.number),
812
+ requestedByUserId,
774
813
  });
775
814
 
776
815
  if (lead) {
@@ -1,5 +1,6 @@
1
1
  import {
2
- claimTask,
2
+ assignUnassignedTaskPending,
3
+ backfillSupersedeTaskResumeTaskId,
3
4
  cleanupStaleSessions,
4
5
  createTaskExtended,
5
6
  deleteActiveSession,
@@ -25,7 +26,7 @@ import {
25
26
  updateAgentStatus,
26
27
  } from "../be/db";
27
28
  import { resolveTemplate } from "../prompts/resolver";
28
- import { createResumeFollowUp } from "../tasks/worker-follow-up";
29
+ import { createResumeFollowUp, getNextResumeGeneration } from "../tasks/worker-follow-up";
29
30
  import type { AgentTask } from "../types";
30
31
  import { getExecutorRegistry } from "../workflows";
31
32
  import { recoverIncompleteRuns } from "../workflows/recovery";
@@ -60,6 +61,11 @@ const STALE_CLEANUP_THRESHOLD_MINUTES = Number(process.env.HEARTBEAT_STALE_CLEAN
60
61
  /** Max pool tasks to auto-assign per sweep */
61
62
  const MAX_AUTO_ASSIGN_PER_SWEEP = Number(process.env.HEARTBEAT_MAX_AUTO_ASSIGN) || 5;
62
63
 
64
+ /** Max crash-recovery resume generations before failing for lead triage */
65
+ export const MAX_RESUME_GENERATIONS = Number(process.env.HEARTBEAT_MAX_RESUME_GENERATIONS) || 3;
66
+
67
+ export const RESUME_BUDGET_EXHAUSTED_REASON = "resume_budget_exhausted";
68
+
63
69
  /** Heartbeat checklist interval: how often to check HEARTBEAT.md (default: 30 min) */
64
70
  const HEARTBEAT_CHECKLIST_INTERVAL_MS =
65
71
  Number(process.env.HEARTBEAT_CHECKLIST_INTERVAL_MS) || 30 * 60 * 1000;
@@ -98,10 +104,17 @@ export interface HeartbeatFindings {
98
104
  let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
99
105
  let checklistInterval: ReturnType<typeof setInterval> | null = null;
100
106
  let isSweeping = false;
107
+ let beforeHeartbeatSupersedeForTests: ((task: AgentTask) => void) | null = null;
101
108
 
102
109
  /** Tasks auto-failed during the reboot sweep, consumed by boot triage */
103
110
  let rebootAffectedTasks: Array<{ original: AgentTask; retryTaskId: string | null }> = [];
104
111
 
112
+ export function setBeforeHeartbeatSupersedeForTests(
113
+ hook: ((task: AgentTask) => void) | null,
114
+ ): void {
115
+ beforeHeartbeatSupersedeForTests = hook;
116
+ }
117
+
105
118
  // ============================================================================
106
119
  // Tier 1: Preflight Gate
107
120
  // ============================================================================
@@ -300,16 +313,40 @@ function remediateCrashedWorkerTask(
300
313
  return;
301
314
  }
302
315
 
303
- // Supersede + resume path.
316
+ const nextResumeGeneration = getNextResumeGeneration(task);
317
+ if (nextResumeGeneration > MAX_RESUME_GENERATIONS) {
318
+ const failed = failTask(task.id, RESUME_BUDGET_EXHAUSTED_REASON);
319
+ if (failed) {
320
+ findings.autoFailedTasks.push({
321
+ taskId: task.id,
322
+ agentId: task.agentId,
323
+ reason: RESUME_BUDGET_EXHAUSTED_REASON,
324
+ });
325
+ if (opts.cleanupActiveSession) deleteActiveSession(task.id);
326
+ console.warn(
327
+ `[Heartbeat] Auto-failed task ${task.id.slice(0, 8)} — ${RESUME_BUDGET_EXHAUSTED_REASON} (${opts.shortLabel})`,
328
+ );
329
+ const remaining = getActiveTaskCount(task.agentId);
330
+ if (remaining === 0) updateAgentStatus(task.agentId, "idle");
331
+ }
332
+ return;
333
+ }
334
+
335
+ beforeHeartbeatSupersedeForTests?.(task);
336
+
304
337
  const superseded = supersedeTask(task.id, {
305
338
  reason: opts.supersedeReason,
306
339
  resumeTaskId: null,
307
340
  });
308
- if (!superseded) return;
341
+ if (!superseded) {
342
+ return;
343
+ }
309
344
 
310
345
  const resume = createResumeFollowUp({ parentId: task.id, reason: "crash_recovery" });
311
346
 
312
347
  if (resume.kind === "created") {
348
+ backfillSupersedeTaskResumeTaskId(task.id, resume.task.id);
349
+
313
350
  findings.autoResumedTasks.push({
314
351
  taskId: task.id,
315
352
  resumeTaskId: resume.task.id,
@@ -320,10 +357,20 @@ function remediateCrashedWorkerTask(
320
357
  `[Heartbeat] Auto-superseded task ${task.id.slice(0, 8)} — created resume ${resume.task.id.slice(0, 8)} (${opts.shortLabel})`,
321
358
  );
322
359
  } else {
323
- // `workflow-skip` is unreachable here (handled above). `skipped` covers
324
- // parent-not-found / lead-not-found edge cases — just log for operators.
325
- console.log(
326
- `[Heartbeat] Task ${task.id.slice(0, 8)} superseded but no resume created (${
360
+ const reason =
361
+ resume.kind === "skipped"
362
+ ? `resume_creation_skipped_${resume.reason}`
363
+ : "resume_creation_skipped_workflow";
364
+ const failed = failTask(task.id, reason);
365
+ if (failed) {
366
+ findings.autoFailedTasks.push({
367
+ taskId: task.id,
368
+ agentId: task.agentId,
369
+ reason,
370
+ });
371
+ }
372
+ console.warn(
373
+ `[Heartbeat] Task ${task.id.slice(0, 8)} failed because no resume was created (${
327
374
  resume.kind === "skipped" ? resume.reason : "workflow-skip"
328
375
  })`,
329
376
  );
@@ -461,7 +508,7 @@ function checkWorkerHealth(findings: HeartbeatFindings): void {
461
508
 
462
509
  /**
463
510
  * Auto-assign unassigned pool tasks to idle workers with capacity.
464
- * Uses atomic claimTask() to prevent races.
511
+ * Leaves tasks pending so the assigned worker's normal poll dispatches them.
465
512
  */
466
513
  function autoAssignPoolTasks(findings: HeartbeatFindings): void {
467
514
  getDb().transaction(() => {
@@ -472,16 +519,37 @@ function autoAssignPoolTasks(findings: HeartbeatFindings): void {
472
519
  if (poolTasks.length === 0) return;
473
520
 
474
521
  let workerIndex = 0;
522
+ const reservedByWorker = new Map<string, number>();
523
+ const reservedForWorker = (agentId: string): number => {
524
+ const cached = reservedByWorker.get(agentId);
525
+ if (cached !== undefined) return cached;
526
+ const row = getDb()
527
+ .prepare<{ count: number }, [string]>(
528
+ "SELECT COUNT(*) as count FROM agent_tasks WHERE agentId = ? AND status IN ('pending', 'in_progress')",
529
+ )
530
+ .get(agentId);
531
+ const reserved = row?.count ?? 0;
532
+ reservedByWorker.set(agentId, reserved);
533
+ return reserved;
534
+ };
535
+
475
536
  for (const task of poolTasks) {
476
537
  if (workerIndex >= idleWorkers.length) break;
477
538
 
478
539
  const worker = idleWorkers[workerIndex]!;
479
- const claimed = claimTask(task.id, worker.id);
540
+ const maxTasks = worker.maxTasks ?? 1;
541
+ if (reservedForWorker(worker.id) >= maxTasks) {
542
+ workerIndex++;
543
+ continue;
544
+ }
545
+
546
+ const assigned = assignUnassignedTaskPending(task.id, worker.id);
480
547
 
481
- if (claimed) {
548
+ if (assigned) {
482
549
  findings.autoAssigned.push({ taskId: task.id, agentId: worker.id });
550
+ reservedByWorker.set(worker.id, reservedForWorker(worker.id) + 1);
483
551
  // Check if this worker still has capacity for more
484
- const remaining = (worker.maxTasks ?? 1) - getActiveTaskCount(worker.id);
552
+ const remaining = maxTasks - reservedForWorker(worker.id);
485
553
  if (remaining <= 0) {
486
554
  workerIndex++;
487
555
  }