@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
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Tests for the runtime-config flags that control whether GitHub unassign and
3
+ * review-request-removed events terminate the linked swarm task.
4
+ *
5
+ * Flags (scope "global"):
6
+ * github.cancelOnUnassign — PR unassigned + issue unassigned
7
+ * github.cancelOnReviewRequestRemoved — PR review_request_removed
8
+ *
9
+ * Absent / any value ≠ "false" → cancel (current behavior, default).
10
+ * Value "false" → leave task untouched, return { created: false }.
11
+ */
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { unlink } from "node:fs/promises";
14
+ import {
15
+ closeDb,
16
+ createAgent,
17
+ createTaskExtended,
18
+ deleteSwarmConfig,
19
+ getDb,
20
+ getSwarmConfigs,
21
+ getTaskById,
22
+ initDb,
23
+ upsertSwarmConfig,
24
+ } from "../be/db";
25
+ import { handleIssue, handlePullRequest } from "../github/handlers";
26
+ import { GITHUB_BOT_NAME } from "../github/mentions";
27
+ import type { IssueEvent, PullRequestEvent } from "../github/types";
28
+
29
+ const TEST_DB_PATH = "./test-github-handlers-cancel-config.sqlite";
30
+
31
+ const BASE_REPO = { full_name: "test/repo", html_url: "https://github.com/test/repo" };
32
+ const BASE_PR = {
33
+ number: 1,
34
+ title: "Test PR",
35
+ body: null as string | null,
36
+ html_url: "https://github.com/test/repo/pull/1",
37
+ user: { login: "sender" },
38
+ head: { ref: "feature", sha: "abc1234567890" },
39
+ base: { ref: "main" },
40
+ merged: false,
41
+ merged_by: undefined,
42
+ };
43
+ const BASE_ISSUE = {
44
+ number: 10,
45
+ title: "Test Issue",
46
+ body: null as string | null,
47
+ html_url: "https://github.com/test/repo/issues/10",
48
+ user: { login: "sender" },
49
+ };
50
+
51
+ // ── Setup ──
52
+
53
+ beforeAll(async () => {
54
+ await unlink(TEST_DB_PATH).catch(() => {});
55
+ await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
56
+ await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
57
+ initDb(TEST_DB_PATH);
58
+ createAgent({
59
+ id: "lead-cancel-config-test",
60
+ name: "CancelConfigTestLead",
61
+ status: "idle",
62
+ isLead: true,
63
+ });
64
+ });
65
+
66
+ afterAll(async () => {
67
+ closeDb();
68
+ await unlink(TEST_DB_PATH).catch(() => {});
69
+ await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
70
+ await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
71
+ });
72
+
73
+ // Clear tasks and config rows between tests.
74
+ beforeEach(() => {
75
+ const db = getDb();
76
+ db.prepare("DELETE FROM agent_tasks").run();
77
+ // Remove both flag rows so each test starts from a clean slate.
78
+ for (const key of ["github.cancelOnUnassign", "github.cancelOnReviewRequestRemoved"]) {
79
+ const rows = getSwarmConfigs({ scope: "global", key });
80
+ for (const row of rows) deleteSwarmConfig(row.id);
81
+ }
82
+ });
83
+
84
+ // ── Helpers ──
85
+
86
+ function seedTask(vcsNumber: number, kind: "pr" | "issue"): string {
87
+ const task = createTaskExtended("test task", {
88
+ agentId: "lead-cancel-config-test",
89
+ source: "github",
90
+ vcsProvider: "github",
91
+ vcsRepo: BASE_REPO.full_name,
92
+ vcsNumber,
93
+ vcsEventType: kind === "pr" ? "pull_request" : "issues",
94
+ });
95
+ return task.id;
96
+ }
97
+
98
+ function setConfigFlag(key: string, value: string) {
99
+ upsertSwarmConfig({ scope: "global", key, value });
100
+ }
101
+
102
+ function getTaskStatus(taskId: string): string | undefined {
103
+ return getTaskById(taskId)?.status;
104
+ }
105
+
106
+ // ── github.cancelOnUnassign — PR unassigned ──
107
+
108
+ describe("PR unassigned — github.cancelOnUnassign", () => {
109
+ test("default (no config row): unassign cancels the task", async () => {
110
+ const taskId = seedTask(BASE_PR.number, "pr");
111
+ expect(getTaskStatus(taskId)).toBe("pending");
112
+
113
+ const event: PullRequestEvent = {
114
+ action: "unassigned",
115
+ pull_request: { ...BASE_PR },
116
+ repository: BASE_REPO,
117
+ sender: { login: "someone" },
118
+ assignee: { login: GITHUB_BOT_NAME, id: 1 },
119
+ };
120
+ const result = await handlePullRequest(event);
121
+
122
+ expect(result.created).toBe(false);
123
+ expect(getTaskStatus(taskId)).toBe("failed");
124
+ });
125
+
126
+ test("config = 'false': unassign leaves task untouched", async () => {
127
+ setConfigFlag("github.cancelOnUnassign", "false");
128
+ const taskId = seedTask(BASE_PR.number, "pr");
129
+ expect(getTaskStatus(taskId)).toBe("pending");
130
+
131
+ const event: PullRequestEvent = {
132
+ action: "unassigned",
133
+ pull_request: { ...BASE_PR },
134
+ repository: BASE_REPO,
135
+ sender: { login: "someone" },
136
+ assignee: { login: GITHUB_BOT_NAME, id: 1 },
137
+ };
138
+ const result = await handlePullRequest(event);
139
+
140
+ expect(result.created).toBe(false);
141
+ // Task must NOT have been failed — still pending.
142
+ expect(getTaskStatus(taskId)).toBe("pending");
143
+ });
144
+ });
145
+
146
+ // ── github.cancelOnUnassign — issue unassigned ──
147
+
148
+ describe("issue unassigned — github.cancelOnUnassign", () => {
149
+ test("default (no config row): unassign cancels the task", async () => {
150
+ const taskId = seedTask(BASE_ISSUE.number, "issue");
151
+ expect(getTaskStatus(taskId)).toBe("pending");
152
+
153
+ const event: IssueEvent = {
154
+ action: "unassigned",
155
+ issue: { ...BASE_ISSUE },
156
+ repository: BASE_REPO,
157
+ sender: { login: "someone" },
158
+ assignee: { login: GITHUB_BOT_NAME, id: 1 },
159
+ };
160
+ const result = await handleIssue(event);
161
+
162
+ expect(result.created).toBe(false);
163
+ expect(getTaskStatus(taskId)).toBe("failed");
164
+ });
165
+
166
+ test("config = 'false': unassign leaves task untouched", async () => {
167
+ setConfigFlag("github.cancelOnUnassign", "false");
168
+ const taskId = seedTask(BASE_ISSUE.number, "issue");
169
+ expect(getTaskStatus(taskId)).toBe("pending");
170
+
171
+ const event: IssueEvent = {
172
+ action: "unassigned",
173
+ issue: { ...BASE_ISSUE },
174
+ repository: BASE_REPO,
175
+ sender: { login: "someone" },
176
+ assignee: { login: GITHUB_BOT_NAME, id: 1 },
177
+ };
178
+ const result = await handleIssue(event);
179
+
180
+ expect(result.created).toBe(false);
181
+ expect(getTaskStatus(taskId)).toBe("pending");
182
+ });
183
+ });
184
+
185
+ // ── github.cancelOnReviewRequestRemoved ──
186
+
187
+ describe("PR review_request_removed — github.cancelOnReviewRequestRemoved", () => {
188
+ test("default (no config row): review removal cancels the task", async () => {
189
+ const taskId = seedTask(BASE_PR.number, "pr");
190
+ expect(getTaskStatus(taskId)).toBe("pending");
191
+
192
+ const event: PullRequestEvent = {
193
+ action: "review_request_removed",
194
+ pull_request: { ...BASE_PR },
195
+ repository: BASE_REPO,
196
+ sender: { login: "someone" },
197
+ requested_reviewer: { login: GITHUB_BOT_NAME, id: 1 },
198
+ };
199
+ const result = await handlePullRequest(event);
200
+
201
+ expect(result.created).toBe(false);
202
+ expect(getTaskStatus(taskId)).toBe("failed");
203
+ });
204
+
205
+ test("config = 'false': review removal leaves task untouched", async () => {
206
+ setConfigFlag("github.cancelOnReviewRequestRemoved", "false");
207
+ const taskId = seedTask(BASE_PR.number, "pr");
208
+ expect(getTaskStatus(taskId)).toBe("pending");
209
+
210
+ const event: PullRequestEvent = {
211
+ action: "review_request_removed",
212
+ pull_request: { ...BASE_PR },
213
+ repository: BASE_REPO,
214
+ sender: { login: "someone" },
215
+ requested_reviewer: { login: GITHUB_BOT_NAME, id: 1 },
216
+ };
217
+ const result = await handlePullRequest(event);
218
+
219
+ expect(result.created).toBe(false);
220
+ expect(getTaskStatus(taskId)).toBe("pending");
221
+ });
222
+ });
223
+
224
+ // ── Independence: flags do not bleed into each other ──
225
+
226
+ describe("flag independence", () => {
227
+ test("cancelOnUnassign=false does NOT affect review_request_removed (still cancels)", async () => {
228
+ // Only disable the unassign flag; leave review-request flag absent (default = cancel).
229
+ setConfigFlag("github.cancelOnUnassign", "false");
230
+ const taskId = seedTask(BASE_PR.number, "pr");
231
+
232
+ const event: PullRequestEvent = {
233
+ action: "review_request_removed",
234
+ pull_request: { ...BASE_PR },
235
+ repository: BASE_REPO,
236
+ sender: { login: "someone" },
237
+ requested_reviewer: { login: GITHUB_BOT_NAME, id: 1 },
238
+ };
239
+ await handlePullRequest(event);
240
+
241
+ // review_request_removed STILL cancels because its own flag is absent (default true).
242
+ expect(getTaskStatus(taskId)).toBe("failed");
243
+ });
244
+
245
+ test("cancelOnReviewRequestRemoved=false does NOT affect unassign (still cancels)", async () => {
246
+ // Only disable the review-request flag; leave unassign flag absent (default = cancel).
247
+ setConfigFlag("github.cancelOnReviewRequestRemoved", "false");
248
+ const taskId = seedTask(BASE_PR.number, "pr");
249
+
250
+ const event: PullRequestEvent = {
251
+ action: "unassigned",
252
+ pull_request: { ...BASE_PR },
253
+ repository: BASE_REPO,
254
+ sender: { login: "someone" },
255
+ assignee: { login: GITHUB_BOT_NAME, id: 1 },
256
+ };
257
+ await handlePullRequest(event);
258
+
259
+ // unassigned STILL cancels because its own flag is absent (default true).
260
+ expect(getTaskStatus(taskId)).toBe("failed");
261
+ });
262
+ });
@@ -10,16 +10,29 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
10
10
  import { unlink } from "node:fs/promises";
11
11
  import {
12
12
  closeDb,
13
+ completeTask,
13
14
  createAgent,
14
15
  createTaskExtended,
15
16
  getChildTasks,
16
17
  getDb,
18
+ getLogsByTaskId,
17
19
  getTaskById,
18
20
  initDb,
19
21
  insertActiveSession,
20
22
  startTask,
21
23
  } from "../be/db";
22
- import { codeLevelTriage } from "../heartbeat/heartbeat";
24
+ import {
25
+ createTrackerSync,
26
+ getTrackerSync,
27
+ getTrackerSyncByExternalId,
28
+ } from "../be/db-queries/tracker";
29
+ import {
30
+ codeLevelTriage,
31
+ MAX_RESUME_GENERATIONS,
32
+ RESUME_BUDGET_EXHAUSTED_REASON,
33
+ setBeforeHeartbeatSupersedeForTests,
34
+ } from "../heartbeat/heartbeat";
35
+ import { RESUME_GENERATION_TAG_PREFIX } from "../tasks/worker-follow-up";
23
36
 
24
37
  const TEST_DB_PATH = "./test-heartbeat-supersede-resume.sqlite";
25
38
 
@@ -46,6 +59,8 @@ describe("Heartbeat — supersede + resume (DES-523)", () => {
46
59
  });
47
60
 
48
61
  beforeEach(() => {
62
+ setBeforeHeartbeatSupersedeForTests(null);
63
+ getDb().run("DELETE FROM tracker_sync");
49
64
  getDb().run("DELETE FROM agent_tasks");
50
65
  getDb().run("DELETE FROM agents");
51
66
  getDb().run("DELETE FROM active_sessions");
@@ -81,7 +96,82 @@ describe("Heartbeat — supersede + resume (DES-523)", () => {
81
96
  expect(resume.taskType).toBe("resume");
82
97
  expect(resume.tags).toContain("auto-resume");
83
98
  expect(resume.tags).toContain("reason:crash_recovery");
99
+ expect(resume.tags).toContain(`${RESUME_GENERATION_TAG_PREFIX}1`);
84
100
  expect(resume.id).toBe(findings.autoResumedTasks[0]!.resumeTaskId);
101
+
102
+ const supersedeLog = getLogsByTaskId(parent.id).find(
103
+ (log) => log.eventType === "task_superseded",
104
+ );
105
+ expect(supersedeLog).toBeTruthy();
106
+ const metadata = JSON.parse(supersedeLog!.metadata ?? "{}") as { resumeTaskId?: string };
107
+ expect(metadata.resumeTaskId).toBe(resume.id);
108
+ });
109
+
110
+ test("Case A: crash-recovery resume chain stops at the generation cap", async () => {
111
+ const agent = createAgent({ name: "dead-resume-worker", isLead: false, status: "busy" });
112
+ const parent = createTaskExtended("Resume at generation cap", {
113
+ agentId: agent.id,
114
+ taskType: "resume",
115
+ tags: [
116
+ "auto-resume",
117
+ "reason:crash_recovery",
118
+ `${RESUME_GENERATION_TAG_PREFIX}${MAX_RESUME_GENERATIONS}`,
119
+ ],
120
+ });
121
+ startTask(parent.id);
122
+
123
+ const oldTime = new Date(Date.now() - 10 * 60 * 1000).toISOString();
124
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, parent.id]);
125
+
126
+ const findings = await codeLevelTriage();
127
+
128
+ expect(findings.autoResumedTasks.length).toBe(0);
129
+ expect(findings.autoFailedTasks.length).toBe(1);
130
+ expect(findings.autoFailedTasks[0]!.taskId).toBe(parent.id);
131
+ expect(findings.autoFailedTasks[0]!.reason).toBe(RESUME_BUDGET_EXHAUSTED_REASON);
132
+
133
+ const updatedParent = getTaskById(parent.id);
134
+ expect(updatedParent?.status).toBe("failed");
135
+ expect(updatedParent?.failureReason).toBe(RESUME_BUDGET_EXHAUSTED_REASON);
136
+ expect(getChildTasks(parent.id).length).toBe(0);
137
+ });
138
+
139
+ test("Case A: supersede race does not create a resume child or repoint tracker_sync", async () => {
140
+ const agent = createAgent({ name: "dead-worker-race", isLead: false, status: "busy" });
141
+ const parent = createTaskExtended("Tracked parent that finishes during heartbeat", {
142
+ agentId: agent.id,
143
+ });
144
+ startTask(parent.id);
145
+
146
+ createTrackerSync({
147
+ provider: "linear",
148
+ entityType: "task",
149
+ swarmId: parent.id,
150
+ externalId: "linear-race-issue",
151
+ externalIdentifier: "ENG-637",
152
+ externalUrl: "https://linear.app/test/issue/ENG-637",
153
+ });
154
+
155
+ const oldTime = new Date(Date.now() - 10 * 60 * 1000).toISOString();
156
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, parent.id]);
157
+
158
+ setBeforeHeartbeatSupersedeForTests((task) => {
159
+ expect(task.id).toBe(parent.id);
160
+ completeTask(parent.id, "finished by racing worker");
161
+ });
162
+
163
+ const findings = await codeLevelTriage();
164
+
165
+ expect(findings.autoResumedTasks.length).toBe(0);
166
+ expect(findings.autoFailedTasks.length).toBe(0);
167
+
168
+ const updatedParent = getTaskById(parent.id);
169
+ expect(updatedParent?.status).toBe("completed");
170
+ expect(getChildTasks(parent.id).length).toBe(0);
171
+
172
+ expect(getTrackerSync("linear", "task", parent.id)).not.toBeNull();
173
+ const byExternal = getTrackerSyncByExternalId("linear", "task", "linear-race-issue");
174
+ expect(byExternal?.swarmId).toBe(parent.id);
85
175
  });
86
176
 
87
177
  // --------------------------------------------------------------------------
@@ -7,13 +7,17 @@ import {
7
7
  getActiveSessionForTask,
8
8
  getDb,
9
9
  getIdleWorkersWithCapacity,
10
+ getOrphanedInProgressTasksForAgent,
11
+ getPendingTaskForAgent,
10
12
  getStalledInProgressTasks,
11
13
  getTaskById,
12
14
  getUnassignedPoolTasks,
13
15
  initDb,
14
16
  insertActiveSession,
17
+ resetOrphanedInProgressTasksForAgent,
15
18
  startTask,
16
19
  updateAgentStatus,
20
+ updateTaskClaudeSessionId,
17
21
  } from "../be/db";
18
22
  import {
19
23
  codeLevelTriage,
@@ -157,6 +161,60 @@ describe("Heartbeat Triage", () => {
157
161
  });
158
162
  });
159
163
 
164
+ describe("orphaned in_progress recovery", () => {
165
+ test("resets stale in_progress task with no session and no claudeSessionId to pending", () => {
166
+ const agent = createAgent({ name: "orphan-worker", isLead: false, status: "idle" });
167
+ const task = createTaskExtended("Orphaned task", { agentId: agent.id });
168
+ startTask(task.id);
169
+
170
+ const oldTime = new Date(Date.now() - 2 * 60 * 1000).toISOString();
171
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, task.id]);
172
+
173
+ const orphaned = getOrphanedInProgressTasksForAgent(agent.id, 60);
174
+ expect(orphaned.map((t) => t.id)).toContain(task.id);
175
+
176
+ const reset = resetOrphanedInProgressTasksForAgent(agent.id, 60);
177
+ expect(reset.map((t) => t.id)).toContain(task.id);
178
+
179
+ const updated = getTaskById(task.id);
180
+ expect(updated?.status).toBe("pending");
181
+ expect(getPendingTaskForAgent(agent.id)?.id).toBe(task.id);
182
+ });
183
+
184
+ test("does not reset tasks with active session, provider session, or fresh update", () => {
185
+ const agent = createAgent({ name: "live-worker", isLead: false, status: "idle" });
186
+ const withActiveSession = createTaskExtended("Live session task", { agentId: agent.id });
187
+ const withProviderSession = createTaskExtended("Provider session task", {
188
+ agentId: agent.id,
189
+ });
190
+ const fresh = createTaskExtended("Fresh task", { agentId: agent.id });
191
+
192
+ startTask(withActiveSession.id);
193
+ startTask(withProviderSession.id);
194
+ startTask(fresh.id);
195
+
196
+ const oldTime = new Date(Date.now() - 2 * 60 * 1000).toISOString();
197
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id IN (?, ?)", [
198
+ oldTime,
199
+ withActiveSession.id,
200
+ withProviderSession.id,
201
+ ]);
202
+ insertActiveSession({
203
+ agentId: agent.id,
204
+ taskId: withActiveSession.id,
205
+ triggerType: "task_assigned",
206
+ });
207
+ updateTaskClaudeSessionId(withProviderSession.id, "claude-live-session");
208
+
209
+ const reset = resetOrphanedInProgressTasksForAgent(agent.id, 60);
210
+ expect(reset.length).toBe(0);
211
+
212
+ expect(getTaskById(withActiveSession.id)?.status).toBe("in_progress");
213
+ expect(getTaskById(withProviderSession.id)?.status).toBe("in_progress");
214
+ expect(getTaskById(fresh.id)?.status).toBe("in_progress");
215
+ });
216
+ });
217
+
160
218
  describe("getIdleWorkersWithCapacity", () => {
161
219
  test("returns idle non-lead agents", () => {
162
220
  createAgent({ name: "idle-worker", isLead: false, status: "idle" });
@@ -306,10 +364,13 @@ describe("Heartbeat Triage", () => {
306
364
  expect(findings.autoAssigned.length).toBe(1);
307
365
  expect(findings.autoAssigned[0]!.agentId).toBe(worker.id);
308
366
 
309
- // Verify task is now in_progress
367
+ // Verify task is pending so the worker's normal poll returns task_assigned.
310
368
  const task = getTaskById(findings.autoAssigned[0]!.taskId);
311
- expect(task?.status).toBe("in_progress");
369
+ expect(task?.status).toBe("pending");
312
370
  expect(task?.agentId).toBe(worker.id);
371
+
372
+ const dispatchable = getPendingTaskForAgent(worker.id);
373
+ expect(dispatchable?.id).toBe(task?.id);
313
374
  });
314
375
 
315
376
  test("auto-assignment skips lead agents", async () => {
@@ -340,6 +401,26 @@ describe("Heartbeat Triage", () => {
340
401
  expect(findings.autoAssigned.length).toBe(0);
341
402
  });
342
403
 
404
+ test("auto-assignment counts pending reservations when assigning pool tasks", async () => {
405
+ const worker = createAgent({ name: "single-slot-worker", isLead: false, status: "idle" });
406
+ createTaskExtended("Pool task 1");
407
+ createTaskExtended("Pool task 2");
408
+
409
+ const findings = await codeLevelTriage();
410
+ expect(findings.autoAssigned.length).toBe(1);
411
+ expect(findings.autoAssigned[0]!.agentId).toBe(worker.id);
412
+
413
+ const assigned = getDb()
414
+ .query("SELECT COUNT(*) as count FROM agent_tasks WHERE agentId = ? AND status = 'pending'")
415
+ .get(worker.id) as { count: number };
416
+ const remaining = getDb()
417
+ .query("SELECT COUNT(*) as count FROM agent_tasks WHERE status = 'unassigned'")
418
+ .get() as { count: number };
419
+
420
+ expect(assigned.count).toBe(1);
421
+ expect(remaining.count).toBe(1);
422
+ });
423
+
343
424
  test("fixes worker with busy status but no active tasks", async () => {
344
425
  createAgent({ name: "ghost-busy", isLead: false, status: "busy" });
345
426
 
@@ -408,7 +489,7 @@ describe("Heartbeat Triage", () => {
408
489
 
409
490
  // Verify task was auto-assigned
410
491
  const tasks = getDb()
411
- .query("SELECT * FROM agent_tasks WHERE status = 'in_progress' AND agentId = ?")
492
+ .query("SELECT * FROM agent_tasks WHERE status = 'pending' AND agentId = ?")
412
493
  .all(worker.id) as Array<{ id: string }>;
413
494
  expect(tasks.length).toBe(1);
414
495
  });
@@ -13,6 +13,7 @@ import { Webhook } from "svix";
13
13
  const TEST_PORT = 19876;
14
14
  const TEST_DB_PATH = `/tmp/test-http-integration-${Date.now()}.sqlite`;
15
15
  const BASE = `http://localhost:${TEST_PORT}`;
16
+ const TEST_API_KEY = "test-http-integration-key";
16
17
 
17
18
  let serverProc: Subprocess;
18
19
 
@@ -28,6 +29,7 @@ async function api(
28
29
  ): Promise<{ status: number; body: any; ok: boolean }> {
29
30
  const headers: Record<string, string> = {
30
31
  "Content-Type": "application/json",
32
+ Authorization: `Bearer ${TEST_API_KEY}`,
31
33
  ...opts.headers,
32
34
  };
33
35
  if (opts.agentId) headers["x-agent-id"] = opts.agentId;
@@ -106,7 +108,7 @@ beforeAll(async () => {
106
108
  ...process.env,
107
109
  PORT: String(TEST_PORT),
108
110
  DATABASE_PATH: TEST_DB_PATH,
109
- API_KEY: "", // no auth required
111
+ API_KEY: TEST_API_KEY,
110
112
  CAPABILITIES: "core,task-pool,messaging,profiles,services,scheduling,memory",
111
113
  // Disable optional integrations
112
114
  SLACK_BOT_TOKEN: "",