@desplega.ai/agent-swarm 1.86.0 → 1.87.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 (47) hide show
  1. package/openapi.json +72 -1
  2. package/package.json +3 -1
  3. package/src/be/db-queries/tracker.ts +21 -0
  4. package/src/be/db.ts +235 -14
  5. package/src/be/migrations/079_task_followup_config.sql +1 -0
  6. package/src/be/modelsdev-cache.json +77663 -74073
  7. package/src/cli.tsx +26 -0
  8. package/src/commands/context-preamble.ts +272 -0
  9. package/src/commands/e2b.ts +728 -0
  10. package/src/commands/resume-session.ts +35 -78
  11. package/src/commands/runner.ts +125 -13
  12. package/src/e2b/dispatch.ts +429 -0
  13. package/src/e2b/env.ts +206 -0
  14. package/src/heartbeat/heartbeat.ts +145 -30
  15. package/src/heartbeat/templates.ts +11 -7
  16. package/src/http/session-data.ts +8 -1
  17. package/src/http/tasks.ts +152 -3
  18. package/src/jira/sync.ts +4 -4
  19. package/src/linear/sync.ts +6 -5
  20. package/src/providers/claude-adapter.ts +10 -76
  21. package/src/providers/claude-managed-adapter.ts +61 -75
  22. package/src/providers/codex-adapter.ts +15 -18
  23. package/src/providers/codex-oauth/auth-json.ts +18 -1
  24. package/src/providers/codex-oauth/flow.ts +24 -1
  25. package/src/providers/types.ts +6 -0
  26. package/src/tasks/worker-follow-up.ts +162 -2
  27. package/src/telemetry.ts +11 -1
  28. package/src/tests/claude-adapter.test.ts +5 -27
  29. package/src/tests/claude-managed-adapter.test.ts +38 -52
  30. package/src/tests/codex-adapter.test.ts +6 -31
  31. package/src/tests/codex-oauth.test.ts +149 -3
  32. package/src/tests/codex-pool.test.ts +14 -3
  33. package/src/tests/e2b-dispatch.test.ts +330 -0
  34. package/src/tests/heartbeat-supersede-resume.test.ts +285 -0
  35. package/src/tests/heartbeat.test.ts +26 -16
  36. package/src/tests/prompt-template-remaining.test.ts +4 -0
  37. package/src/tests/resume-session.test.ts +42 -50
  38. package/src/tests/structured-output.test.ts +69 -0
  39. package/src/tests/task-completion-idempotency.test.ts +185 -2
  40. package/src/tests/task-supersede-resume.test.ts +722 -0
  41. package/src/tests/telemetry-init.test.ts +69 -0
  42. package/src/tests/vcs-tracking.test.ts +39 -0
  43. package/src/tools/send-task.ts +12 -1
  44. package/src/tools/store-progress.ts +2 -2
  45. package/src/tools/templates.ts +14 -2
  46. package/src/types.ts +46 -1
  47. package/src/workflows/executors/agent-task.ts +3 -0
@@ -0,0 +1,330 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildDetachedShell,
4
+ buildImageTemplate,
5
+ buildTemplateArgs,
6
+ deleteTemplate,
7
+ type E2BSandboxInfo,
8
+ e2bSdkConnectionOptions,
9
+ sandboxPortHost,
10
+ setTemplateVisibility,
11
+ waitForAgentRegistration,
12
+ } from "../e2b/dispatch";
13
+ import {
14
+ parseDotenv,
15
+ parseKeyValue,
16
+ redactObjectWithEnv,
17
+ redactWithEnv,
18
+ resolveSwarmApiKey,
19
+ selectEnv,
20
+ } from "../e2b/env";
21
+
22
+ describe("E2B env helpers", () => {
23
+ test("parses common dotenv forms", () => {
24
+ expect(
25
+ parseDotenv(`
26
+ # ignored
27
+ export API_KEY=abc123
28
+ QUOTED="hello\\nworld"
29
+ SINGLE='literal value'
30
+ INLINE=value # comment
31
+ QUOTED_COMMENT="bar" # comment
32
+ SINGLE_COMMENT='baz' # comment
33
+ QUOTED_HASH="bar # keep"
34
+ `),
35
+ ).toEqual({
36
+ API_KEY: "abc123",
37
+ QUOTED: "hello\nworld",
38
+ SINGLE: "literal value",
39
+ INLINE: "value",
40
+ QUOTED_COMMENT: "bar",
41
+ SINGLE_COMMENT: "baz",
42
+ QUOTED_HASH: "bar # keep",
43
+ });
44
+ });
45
+
46
+ test("validates KEY=VALUE inputs", () => {
47
+ expect(parseKeyValue("FOO=bar=baz", "--secret")).toEqual(["FOO", "bar=baz"]);
48
+ expect(() => parseKeyValue("bad-key=value", "--secret")).toThrow("invalid env key");
49
+ expect(() => parseKeyValue("NOVALUE", "--secret")).toThrow("KEY=VALUE");
50
+ });
51
+
52
+ test("resolves swarm API key with env-file precedence before process default", () => {
53
+ const previousApiKey = process.env.API_KEY;
54
+ const previousNamespacedKey = process.env.AGENT_SWARM_API_KEY;
55
+ delete process.env.API_KEY;
56
+ delete process.env.AGENT_SWARM_API_KEY;
57
+ try {
58
+ expect(resolveSwarmApiKey({ API_KEY: "legacy", AGENT_SWARM_API_KEY: "preferred" })).toBe(
59
+ "preferred",
60
+ );
61
+ expect(resolveSwarmApiKey({ API_KEY: "legacy" }, "explicit")).toBe("explicit");
62
+ expect(() => resolveSwarmApiKey({})).toThrow("Missing swarm API key");
63
+ } finally {
64
+ if (previousApiKey === undefined) {
65
+ delete process.env.API_KEY;
66
+ } else {
67
+ process.env.API_KEY = previousApiKey;
68
+ }
69
+ if (previousNamespacedKey === undefined) {
70
+ delete process.env.AGENT_SWARM_API_KEY;
71
+ } else {
72
+ process.env.AGENT_SWARM_API_KEY = previousNamespacedKey;
73
+ }
74
+ }
75
+ });
76
+
77
+ test("selectEnv and redactWithEnv keep secrets out of logs", () => {
78
+ const selected = selectEnv(
79
+ {
80
+ API_KEY: "super-secret-value",
81
+ E2B_API_KEY: "controller-secret",
82
+ PATH: "/bin",
83
+ },
84
+ ["API_KEY", "PATH"],
85
+ );
86
+ expect(selected).toEqual({ API_KEY: "super-secret-value", PATH: "/bin" });
87
+ expect(redactWithEnv("token=super-secret-value", selected)).toContain("[REDACTED:API_KEY]");
88
+ });
89
+
90
+ test("redactObjectWithEnv redacts token-like response fields", () => {
91
+ expect(
92
+ redactObjectWithEnv(
93
+ {
94
+ sandboxID: "sbx123",
95
+ envdAccessToken: "controller-token-that-should-not-print",
96
+ nested: { trafficAccessToken: "traffic-token-that-should-not-print" },
97
+ },
98
+ {},
99
+ ),
100
+ ).toEqual({
101
+ sandboxID: "sbx123",
102
+ envdAccessToken: "[REDACTED:envdAccessToken]",
103
+ nested: { trafficAccessToken: "[REDACTED:trafficAccessToken]" },
104
+ });
105
+ });
106
+ });
107
+
108
+ describe("E2B dispatch helpers", () => {
109
+ test("computes public port host from sandbox domain shapes", () => {
110
+ const bareDomain: E2BSandboxInfo = {
111
+ sandboxID: "sbx123",
112
+ templateID: "tpl",
113
+ domain: "e2b.app",
114
+ };
115
+ const sandboxDomain: E2BSandboxInfo = {
116
+ sandboxID: "sbx123",
117
+ templateID: "tpl",
118
+ domain: "sbx123.e2b.app",
119
+ };
120
+
121
+ expect(sandboxPortHost(bareDomain, 3013)).toBe("3013-sbx123.e2b.app");
122
+ expect(sandboxPortHost(sandboxDomain, 3013)).toBe("3013-sbx123.e2b.app");
123
+ });
124
+
125
+ test("computes public port host from configured E2B endpoints when API domain is absent", () => {
126
+ const missingDomain: E2BSandboxInfo = {
127
+ sandboxID: "sbx123",
128
+ templateID: "tpl",
129
+ domain: null,
130
+ };
131
+
132
+ expect(sandboxPortHost(missingDomain, 3013, { E2B_DOMAIN: "self-hosted.e2b.test" })).toBe(
133
+ "3013-sbx123.self-hosted.e2b.test",
134
+ );
135
+ expect(
136
+ sandboxPortHost(missingDomain, 3013, {
137
+ E2B_SANDBOX_URL: "https://sandbox.private.e2b.test",
138
+ }),
139
+ ).toBe("3013-sbx123.private.e2b.test");
140
+ expect(
141
+ sandboxPortHost(missingDomain, 3013, {
142
+ E2B_SANDBOX_URL: "https://sandboxes.internal:8443",
143
+ }),
144
+ ).toBe("3013-sbx123.sandboxes.internal:8443");
145
+ });
146
+
147
+ test("buildDetachedShell backgrounds command and captures pid without invalid shell chaining", () => {
148
+ const shell = buildDetachedShell("/api-entrypoint.sh", "/tmp/api.log", "/tmp/api.pid");
149
+
150
+ expect(shell).toContain("nohup /api-entrypoint.sh >/tmp/api.log 2>&1 </dev/null & pid=$!");
151
+ expect(shell).toContain("sleep 2");
152
+ expect(shell).toContain('kill -0 "$pid"');
153
+ expect(shell).toContain("cat /tmp/api.log >&2");
154
+ expect(shell).toContain("pid=$!");
155
+ expect(shell).not.toContain("&;");
156
+ expect(shell).not.toContain("& &&");
157
+ });
158
+
159
+ test("E2B SDK connection options preserve loaded controller endpoints", () => {
160
+ expect(
161
+ e2bSdkConnectionOptions(
162
+ "controller-key",
163
+ {
164
+ E2B_DOMAIN: "sandbox.example.com",
165
+ E2B_SANDBOX_URL: "https://sandbox.sandbox.example.com",
166
+ },
167
+ "https://api.sandbox.example.com",
168
+ ),
169
+ ).toEqual({
170
+ apiKey: "controller-key",
171
+ domain: "sandbox.example.com",
172
+ sandboxUrl: "https://sandbox.sandbox.example.com",
173
+ apiUrl: "https://api.sandbox.example.com",
174
+ });
175
+ });
176
+
177
+ test("waitForAgentRegistration checks the worker registration endpoint with bearer auth", async () => {
178
+ const originalFetch = globalThis.fetch;
179
+ const calls: Array<{ url: string; authorization: string | null }> = [];
180
+
181
+ try {
182
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
183
+ calls.push({
184
+ url: String(input),
185
+ authorization: new Headers(init?.headers).get("authorization"),
186
+ });
187
+ return new Response("{}", { status: 200 });
188
+ }) as typeof fetch;
189
+
190
+ await waitForAgentRegistration("https://api.example.com/", "worker/id", "swarm-secret", 10);
191
+ } finally {
192
+ globalThis.fetch = originalFetch;
193
+ }
194
+
195
+ expect(calls).toEqual([
196
+ {
197
+ url: "https://api.example.com/api/agents/worker%2Fid",
198
+ authorization: "Bearer swarm-secret",
199
+ },
200
+ ]);
201
+ });
202
+
203
+ test("buildTemplateArgs uses current Dockerfile template create command", () => {
204
+ expect(
205
+ buildTemplateArgs({
206
+ role: "worker",
207
+ name: "agent-swarm-worker",
208
+ dockerfile: "Dockerfile.worker",
209
+ cwd: "/repo",
210
+ cpuCount: 4,
211
+ memoryMb: 8192,
212
+ noCache: true,
213
+ e2bEnv: { E2B_API_KEY: "secret" },
214
+ }),
215
+ ).toEqual([
216
+ "template",
217
+ "create",
218
+ "-p",
219
+ "/repo",
220
+ "-d",
221
+ "Dockerfile.worker",
222
+ "-c",
223
+ "sleep infinity",
224
+ "--ready-cmd",
225
+ "sleep 0",
226
+ "--cpu-count",
227
+ "4",
228
+ "--memory-mb",
229
+ "8192",
230
+ "--no-cache",
231
+ "agent-swarm-worker",
232
+ ]);
233
+ });
234
+
235
+ test("deleteTemplate supports dry-run cleanup", async () => {
236
+ await expect(
237
+ deleteTemplate({
238
+ name: "agent-swarm-worker-e2e",
239
+ e2bEnv: { E2B_API_KEY: "secret" },
240
+ dryRun: true,
241
+ }),
242
+ ).resolves.toMatchObject({
243
+ exitCode: 0,
244
+ stdout: "e2b template delete agent-swarm-worker-e2e -y\n",
245
+ });
246
+ });
247
+
248
+ test("setTemplateVisibility supports dry-run publish and unpublish", async () => {
249
+ await expect(
250
+ setTemplateVisibility({
251
+ name: "agent-swarm-worker-latest",
252
+ public: true,
253
+ e2bEnv: { E2B_API_KEY: "secret" },
254
+ dryRun: true,
255
+ }),
256
+ ).resolves.toMatchObject({
257
+ exitCode: 0,
258
+ stdout: 'PATCH /v2/templates/agent-swarm-worker-latest {"public":true}\n',
259
+ });
260
+
261
+ await expect(
262
+ setTemplateVisibility({
263
+ name: "agent-swarm-worker-latest",
264
+ public: false,
265
+ e2bEnv: { E2B_API_KEY: "secret" },
266
+ dryRun: true,
267
+ }),
268
+ ).resolves.toMatchObject({
269
+ exitCode: 0,
270
+ stdout: 'PATCH /v2/templates/agent-swarm-worker-latest {"public":false}\n',
271
+ });
272
+ });
273
+
274
+ test("setTemplateVisibility updates templates through the E2B API key path", async () => {
275
+ const originalFetch = globalThis.fetch;
276
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
277
+ globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) => {
278
+ calls.push({ url: String(url), init });
279
+ return new Response(JSON.stringify({ names: ["workspace/agent-swarm-worker-latest"] }), {
280
+ status: 200,
281
+ headers: { "Content-Type": "application/json" },
282
+ });
283
+ };
284
+
285
+ try {
286
+ const result = await setTemplateVisibility({
287
+ name: "agent swarm/worker",
288
+ public: true,
289
+ e2bEnv: {
290
+ E2B_API_KEY: "controller-secret",
291
+ E2B_API_URL: "https://api.e2b.example",
292
+ },
293
+ });
294
+
295
+ expect(calls).toHaveLength(1);
296
+ expect(calls[0]?.url).toBe("https://api.e2b.example/v2/templates/agent%20swarm%2Fworker");
297
+ expect(calls[0]?.init?.method).toBe("PATCH");
298
+ expect(calls[0]?.init?.headers).toMatchObject({
299
+ "Content-Type": "application/json",
300
+ "X-API-Key": "controller-secret",
301
+ });
302
+ expect(calls[0]?.init?.body).toBe(JSON.stringify({ public: true }));
303
+ expect(result.stdout).toBe(
304
+ "Set E2B template agent swarm/worker visibility to public (workspace/agent-swarm-worker-latest)\n",
305
+ );
306
+ } finally {
307
+ globalThis.fetch = originalFetch;
308
+ }
309
+ });
310
+
311
+ test("buildImageTemplate dry-run uses the Dockerless SDK path", async () => {
312
+ await expect(
313
+ buildImageTemplate({
314
+ role: "api",
315
+ name: "agent-swarm-api",
316
+ image: "ghcr.io/desplega-ai/agent-swarm:latest",
317
+ cpuCount: 2,
318
+ memoryMb: 2048,
319
+ noCache: false,
320
+ e2bEnv: { E2B_API_KEY: "secret" },
321
+ dryRun: true,
322
+ }),
323
+ ).resolves.toMatchObject({
324
+ exitCode: 0,
325
+ stdout: expect.stringContaining(
326
+ "e2b-sdk template build --from-image ghcr.io/desplega-ai/agent-swarm:latest",
327
+ ),
328
+ });
329
+ });
330
+ });
@@ -0,0 +1,285 @@
1
+ /**
2
+ * DES-523: heartbeat sweep should auto-supersede + resume crashed-worker tasks.
3
+ *
4
+ * Mirrors the test-setup pattern from `heartbeat.test.ts` (own sqlite file,
5
+ * full DB reset between tests). Each test exercises one branch of
6
+ * `remediateCrashedWorkerTask` inside `detectAndRemediateStalledTasks`.
7
+ */
8
+
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { unlink } from "node:fs/promises";
11
+ import {
12
+ closeDb,
13
+ createAgent,
14
+ createTaskExtended,
15
+ getChildTasks,
16
+ getDb,
17
+ getTaskById,
18
+ initDb,
19
+ insertActiveSession,
20
+ startTask,
21
+ } from "../be/db";
22
+ import { codeLevelTriage } from "../heartbeat/heartbeat";
23
+
24
+ const TEST_DB_PATH = "./test-heartbeat-supersede-resume.sqlite";
25
+
26
+ describe("Heartbeat — supersede + resume (DES-523)", () => {
27
+ beforeAll(async () => {
28
+ try {
29
+ await unlink(TEST_DB_PATH);
30
+ } catch {
31
+ // File doesn't exist
32
+ }
33
+ closeDb();
34
+ initDb(TEST_DB_PATH);
35
+ });
36
+
37
+ afterAll(async () => {
38
+ closeDb();
39
+ for (const path of [TEST_DB_PATH, `${TEST_DB_PATH}-wal`, `${TEST_DB_PATH}-shm`]) {
40
+ try {
41
+ await unlink(path);
42
+ } catch {
43
+ // Files may not exist
44
+ }
45
+ }
46
+ });
47
+
48
+ beforeEach(() => {
49
+ getDb().run("DELETE FROM agent_tasks");
50
+ getDb().run("DELETE FROM agents");
51
+ getDb().run("DELETE FROM active_sessions");
52
+ });
53
+
54
+ // --------------------------------------------------------------------------
55
+ // Case A — no active session
56
+ // --------------------------------------------------------------------------
57
+
58
+ test("Case A: regular task with no active session is auto-superseded and resumed", async () => {
59
+ const agent = createAgent({ name: "dead-worker", isLead: false, status: "busy" });
60
+ const parent = createTaskExtended("Long-running parent work", { agentId: agent.id });
61
+ startTask(parent.id);
62
+
63
+ // 10 min stale — past the 5 min no-session threshold.
64
+ const oldTime = new Date(Date.now() - 10 * 60 * 1000).toISOString();
65
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, parent.id]);
66
+
67
+ const findings = await codeLevelTriage();
68
+
69
+ expect(findings.autoResumedTasks.length).toBe(1);
70
+ expect(findings.autoResumedTasks[0]!.taskId).toBe(parent.id);
71
+ expect(findings.autoFailedTasks.length).toBe(0);
72
+
73
+ // Parent transitioned to `superseded` (NOT `failed`).
74
+ const updatedParent = getTaskById(parent.id);
75
+ expect(updatedParent?.status).toBe("superseded");
76
+
77
+ // A resume follow-up child exists.
78
+ const children = getChildTasks(parent.id);
79
+ expect(children.length).toBe(1);
80
+ const resume = children[0]!;
81
+ expect(resume.taskType).toBe("resume");
82
+ expect(resume.tags).toContain("auto-resume");
83
+ expect(resume.tags).toContain("reason:crash_recovery");
84
+ expect(resume.id).toBe(findings.autoResumedTasks[0]!.resumeTaskId);
85
+ });
86
+
87
+ // --------------------------------------------------------------------------
88
+ // Case A — system task: must fall back to failTask, never resume
89
+ // --------------------------------------------------------------------------
90
+
91
+ test("Case A: system task (taskType=heartbeat) is failed, not resumed", async () => {
92
+ const lead = createAgent({ name: "lead", isLead: true, status: "busy" });
93
+ const parent = createTaskExtended("Periodic heartbeat checklist", {
94
+ agentId: lead.id,
95
+ taskType: "heartbeat",
96
+ });
97
+ startTask(parent.id);
98
+
99
+ const oldTime = new Date(Date.now() - 10 * 60 * 1000).toISOString();
100
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, parent.id]);
101
+
102
+ const findings = await codeLevelTriage();
103
+
104
+ expect(findings.autoFailedTasks.length).toBe(1);
105
+ expect(findings.autoFailedTasks[0]!.taskId).toBe(parent.id);
106
+ expect(findings.autoResumedTasks.length).toBe(0);
107
+
108
+ const updatedParent = getTaskById(parent.id);
109
+ expect(updatedParent?.status).toBe("failed");
110
+
111
+ // No resume child was created.
112
+ const children = getChildTasks(parent.id);
113
+ expect(children.length).toBe(0);
114
+ });
115
+
116
+ // --------------------------------------------------------------------------
117
+ // Case A — idempotency: a non-terminal child already exists → fail, no 2nd resume
118
+ // --------------------------------------------------------------------------
119
+
120
+ test("Case A: idempotency — non-terminal child already exists, parent is failed (no 2nd resume)", async () => {
121
+ const agent = createAgent({ name: "dead-worker", isLead: false, status: "busy" });
122
+ const parent = createTaskExtended("Parent with existing child", { agentId: agent.id });
123
+ startTask(parent.id);
124
+
125
+ // Pre-insert a non-terminal child. `createTaskExtended` defaults to
126
+ // `unassigned` without an agentId — we assign the same agent so the child
127
+ // lands in `pending`, mirroring what a prior sweep would have produced.
128
+ const preexisting = createTaskExtended("Existing pending child", {
129
+ parentTaskId: parent.id,
130
+ agentId: agent.id,
131
+ tags: ["auto-resume", "reason:crash_recovery"],
132
+ taskType: "resume",
133
+ });
134
+ expect(preexisting.status).toBe("pending");
135
+
136
+ const oldTime = new Date(Date.now() - 10 * 60 * 1000).toISOString();
137
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, parent.id]);
138
+
139
+ const findings = await codeLevelTriage();
140
+
141
+ // Idempotency path: parent gets the legacy `failTask` treatment.
142
+ expect(findings.autoFailedTasks.length).toBe(1);
143
+ expect(findings.autoFailedTasks[0]!.taskId).toBe(parent.id);
144
+ expect(findings.autoResumedTasks.length).toBe(0);
145
+
146
+ const updatedParent = getTaskById(parent.id);
147
+ expect(updatedParent?.status).toBe("failed");
148
+
149
+ // Only the original pre-existing child remains — no second resume was created.
150
+ const children = getChildTasks(parent.id);
151
+ expect(children.length).toBe(1);
152
+ expect(children[0]!.id).toBe(preexisting.id);
153
+ });
154
+
155
+ // --------------------------------------------------------------------------
156
+ // Case A — delegation children must NOT block the resume path
157
+ // --------------------------------------------------------------------------
158
+
159
+ test("Case A: ordinary delegation child does NOT block resume (only taskType=resume children count)", async () => {
160
+ // PR #594 review: `send-task` auto-defaults `parentTaskId` to the
161
+ // caller's current task. So a crashed worker that had delegated subtasks
162
+ // has non-terminal children that are NOT resume tasks. The idempotency
163
+ // guard must only count taskType=resume children — otherwise the parent
164
+ // is silently failed and the original work is dropped.
165
+ const agent = createAgent({ name: "dead-delegator", isLead: false, status: "busy" });
166
+ const otherAgent = createAgent({ name: "subtask-worker", isLead: false, status: "busy" });
167
+ const parent = createTaskExtended("Parent that delegated", { agentId: agent.id });
168
+ startTask(parent.id);
169
+
170
+ // A delegated subtask — `taskType` is NOT "resume". `send-task` auto-sets
171
+ // parentTaskId to the delegator's current task, so this models reality.
172
+ const delegated = createTaskExtended("Delegated subtask", {
173
+ parentTaskId: parent.id,
174
+ agentId: otherAgent.id,
175
+ taskType: "delegation",
176
+ });
177
+ expect(delegated.status).toBe("pending");
178
+
179
+ const oldTime = new Date(Date.now() - 10 * 60 * 1000).toISOString();
180
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, parent.id]);
181
+
182
+ const findings = await codeLevelTriage();
183
+
184
+ // Resume path should fire — the delegated child does not count.
185
+ expect(findings.autoResumedTasks.length).toBe(1);
186
+ expect(findings.autoResumedTasks[0]!.taskId).toBe(parent.id);
187
+ expect(findings.autoFailedTasks.length).toBe(0);
188
+
189
+ const updatedParent = getTaskById(parent.id);
190
+ expect(updatedParent?.status).toBe("superseded");
191
+
192
+ // Children now: the original delegation + the new resume.
193
+ const children = getChildTasks(parent.id);
194
+ expect(children.length).toBe(2);
195
+ const resumeChild = children.find((c) => c.taskType === "resume");
196
+ expect(resumeChild).not.toBeUndefined();
197
+ });
198
+
199
+ // --------------------------------------------------------------------------
200
+ // Case B — stale session heartbeat
201
+ // --------------------------------------------------------------------------
202
+
203
+ test("Case B: stale session heartbeat is auto-superseded and resumed", async () => {
204
+ const agent = createAgent({ name: "crashed-worker", isLead: false, status: "busy" });
205
+ const parent = createTaskExtended("Crashed worker's task", { agentId: agent.id });
206
+ startTask(parent.id);
207
+
208
+ insertActiveSession({
209
+ agentId: agent.id,
210
+ taskId: parent.id,
211
+ triggerType: "task_assigned",
212
+ });
213
+
214
+ // Make both task and session heartbeat 20 min stale — past the 15 min threshold.
215
+ const oldTime = new Date(Date.now() - 20 * 60 * 1000).toISOString();
216
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, parent.id]);
217
+ getDb().run("UPDATE active_sessions SET lastHeartbeatAt = ? WHERE taskId = ?", [
218
+ oldTime,
219
+ parent.id,
220
+ ]);
221
+
222
+ const findings = await codeLevelTriage();
223
+
224
+ expect(findings.autoResumedTasks.length).toBe(1);
225
+ expect(findings.autoResumedTasks[0]!.taskId).toBe(parent.id);
226
+ expect(findings.autoFailedTasks.length).toBe(0);
227
+
228
+ const updatedParent = getTaskById(parent.id);
229
+ expect(updatedParent?.status).toBe("superseded");
230
+
231
+ const children = getChildTasks(parent.id);
232
+ expect(children.length).toBe(1);
233
+ const resume = children[0]!;
234
+ expect(resume.taskType).toBe("resume");
235
+ expect(resume.tags).toContain("auto-resume");
236
+ expect(resume.tags).toContain("reason:crash_recovery");
237
+
238
+ // Orphan active_session row was cleaned up.
239
+ const remainingSessions = getDb()
240
+ .query("SELECT COUNT(*) as count FROM active_sessions WHERE taskId = ?")
241
+ .get(parent.id) as { count: number };
242
+ expect(remainingSessions.count).toBe(0);
243
+ });
244
+
245
+ // --------------------------------------------------------------------------
246
+ // Workflow carve-out — workflowRunStepId set → workflow-skip → failTask path
247
+ // --------------------------------------------------------------------------
248
+
249
+ test("Workflow-step parent: failed with workflow reason, no supersede or resume", async () => {
250
+ const agent = createAgent({ name: "workflow-worker", isLead: false, status: "busy" });
251
+ const parent = createTaskExtended("Workflow-step task", { agentId: agent.id });
252
+ startTask(parent.id);
253
+
254
+ // Backfill workflowRunStepId — createTaskExtended doesn't accept it.
255
+ // FKs are toggled off because this test only exercises the heartbeat path,
256
+ // not the workflow engine itself (same pattern as task-supersede-resume.test.ts).
257
+ const stepId = crypto.randomUUID();
258
+ getDb().exec("PRAGMA foreign_keys = OFF");
259
+ try {
260
+ getDb().run("UPDATE agent_tasks SET workflowRunStepId = ? WHERE id = ?", [stepId, parent.id]);
261
+ } finally {
262
+ getDb().exec("PRAGMA foreign_keys = ON");
263
+ }
264
+
265
+ const oldTime = new Date(Date.now() - 10 * 60 * 1000).toISOString();
266
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, parent.id]);
267
+
268
+ const findings = await codeLevelTriage();
269
+
270
+ // Workflow carve-out: the engine owns retry. Skip the supersede flow and
271
+ // mark the dedicated workflow-task failure so the engine can react.
272
+ expect(findings.autoResumedTasks.length).toBe(0);
273
+ expect(findings.autoFailedTasks.length).toBe(1);
274
+ expect(findings.autoFailedTasks[0]!.taskId).toBe(parent.id);
275
+ expect(findings.autoFailedTasks[0]!.reason).toBe("superseded_workflow_task");
276
+
277
+ // No resume child was created.
278
+ const children = getChildTasks(parent.id);
279
+ expect(children.length).toBe(0);
280
+
281
+ const updatedParent = getTaskById(parent.id);
282
+ expect(updatedParent?.status).toBe("failed");
283
+ expect(updatedParent?.failureReason).toBe("superseded_workflow_task");
284
+ });
285
+ });