@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.
- package/README.md +7 -0
- package/openapi.json +41 -1
- package/package.json +3 -2
- package/plugin/skills/composio/SKILL.md +173 -0
- package/plugin/skills/composio-gmail/SKILL.md +83 -0
- package/plugin/skills/composio-google-calendar/SKILL.md +81 -0
- package/plugin/skills/composio-google-docs/SKILL.md +71 -0
- package/src/be/db.ts +353 -2
- package/src/be/migrations/081_metrics.sql +39 -0
- package/src/be/migrations/082_user_audit_fields.sql +120 -0
- package/src/be/modelsdev-cache.json +3413 -1423
- package/src/be/seed-skills/index.ts +7 -0
- package/src/cli.tsx +18 -0
- package/src/commands/runner.ts +153 -22
- package/src/commands/x.ts +118 -0
- package/src/github/handlers.ts +40 -1
- package/src/heartbeat/heartbeat.ts +80 -12
- package/src/http/active-sessions.ts +32 -1
- package/src/http/auth.ts +36 -0
- package/src/http/core.ts +20 -16
- package/src/http/db-query.ts +20 -0
- package/src/http/index.ts +2 -0
- package/src/http/metrics.ts +447 -0
- package/src/http/operator-actor.ts +9 -0
- package/src/http/poll.ts +11 -1
- package/src/http/tasks.ts +6 -1
- package/src/http/workflows.ts +5 -1
- package/src/metrics/version.ts +26 -0
- package/src/prompts/base-prompt.ts +8 -0
- package/src/prompts/session-templates.ts +23 -0
- package/src/providers/opencode-adapter.ts +22 -6
- package/src/server.ts +10 -1
- package/src/tasks/worker-follow-up.ts +19 -1
- package/src/tests/base-prompt.test.ts +35 -0
- package/src/tests/budget-claim-gate.test.ts +26 -0
- package/src/tests/core-auth.test.ts +8 -1
- package/src/tests/events-http.test.ts +6 -2
- package/src/tests/github-handlers-cancel-config.test.ts +262 -0
- package/src/tests/heartbeat-supersede-resume.test.ts +91 -1
- package/src/tests/heartbeat.test.ts +84 -3
- package/src/tests/http-api-integration.test.ts +3 -1
- package/src/tests/metrics-http.test.ts +247 -0
- package/src/tests/opencode-adapter.test.ts +90 -30
- package/src/tests/runner-repo-autostash.test.ts +117 -0
- package/src/tests/runner-requester-profile.test.ts +25 -0
- package/src/tests/runner-skills-refresh.test.ts +1 -1
- package/src/tests/swarm-x-tool.test.ts +90 -0
- package/src/tests/system-default-skills.test.ts +3 -0
- package/src/tests/ui-logs-parser.test.ts +271 -0
- package/src/tests/user-token-rest-auth.test.ts +129 -0
- package/src/tests/workflow-async-v2.test.ts +23 -0
- package/src/tests/x-composio.test.ts +122 -0
- package/src/tools/create-metric.ts +191 -0
- package/src/tools/swarm-x.ts +116 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/types.ts +120 -0
- package/src/utils/request-auth-context.ts +28 -0
- package/src/utils/skills-refresh.ts +2 -2
- package/src/workflows/engine.ts +24 -2
- package/src/workflows/executors/agent-task.ts +2 -0
- package/src/x/composio.ts +295 -0
- package/templates/skills/attio-interaction/SKILL.md +279 -0
- package/templates/skills/attio-interaction/config.json +14 -0
- 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 {
|
|
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
|
|
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("
|
|
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 = '
|
|
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:
|
|
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: "",
|