@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
@@ -17,6 +17,7 @@ describe("initTelemetry", () => {
17
17
  // Tests below set MCP_BASE_URL to assert classification — clear between
18
18
  // tests so cases that expect "unset" don't inherit a prior test's value.
19
19
  delete process.env.MCP_BASE_URL;
20
+ delete process.env.DESPLEGA_TELEMETRY_ENV;
20
21
  });
21
22
 
22
23
  test("without generateIfMissing + missing config → installationId stays null (track no-ops)", async () => {
@@ -390,4 +391,72 @@ describe("initTelemetry", () => {
390
391
  expect(properties.is_cloud).toBe(true);
391
392
  });
392
393
  });
394
+
395
+ describe("track() metadata.environment", () => {
396
+ const originalFetch = globalThis.fetch;
397
+ const originalNodeEnv = process.env.NODE_ENV;
398
+ let captured: Record<string, unknown> | null = null;
399
+
400
+ beforeEach(() => {
401
+ captured = null;
402
+ globalThis.fetch = (async (_url: string, init?: { body?: string }) => {
403
+ captured = init?.body ? JSON.parse(init.body) : null;
404
+ return new Response(null, { status: 204 });
405
+ }) as typeof fetch;
406
+ delete process.env.DESPLEGA_TELEMETRY_ENV;
407
+ });
408
+
409
+ afterEach(() => {
410
+ globalThis.fetch = originalFetch;
411
+ delete process.env.DESPLEGA_TELEMETRY_ENV;
412
+ if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
413
+ else process.env.NODE_ENV = originalNodeEnv;
414
+ });
415
+
416
+ test("defaults to production even when NODE_ENV is development", async () => {
417
+ process.env.NODE_ENV = "development";
418
+ await initTelemetry(
419
+ "api-server",
420
+ async () => "install_default_env",
421
+ async () => {},
422
+ );
423
+
424
+ track({ event: "test.event", properties: {} });
425
+ await new Promise((r) => setTimeout(r, 0));
426
+
427
+ const metadata = (captured as { metadata: Record<string, unknown> }).metadata;
428
+ expect(metadata.environment).toBe("production");
429
+ });
430
+
431
+ test("uses DESPLEGA_TELEMETRY_ENV when set", async () => {
432
+ process.env.NODE_ENV = "production";
433
+ process.env.DESPLEGA_TELEMETRY_ENV = "development";
434
+ await initTelemetry(
435
+ "api-server",
436
+ async () => "install_explicit_env",
437
+ async () => {},
438
+ );
439
+
440
+ track({ event: "test.event", properties: {} });
441
+ await new Promise((r) => setTimeout(r, 0));
442
+
443
+ const metadata = (captured as { metadata: Record<string, unknown> }).metadata;
444
+ expect(metadata.environment).toBe("development");
445
+ });
446
+
447
+ test("preserves NODE_ENV=test when telemetry env is unset", async () => {
448
+ process.env.NODE_ENV = "test";
449
+ await initTelemetry(
450
+ "api-server",
451
+ async () => "install_test_env",
452
+ async () => {},
453
+ );
454
+
455
+ track({ event: "test.event", properties: {} });
456
+ await new Promise((r) => setTimeout(r, 0));
457
+
458
+ const metadata = (captured as { metadata: Record<string, unknown> }).metadata;
459
+ expect(metadata.environment).toBe("test");
460
+ });
461
+ });
393
462
  });
@@ -1,11 +1,16 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import {
4
+ cancelTask,
4
5
  closeDb,
6
+ completeTask,
5
7
  createAgent,
6
8
  createTaskExtended,
9
+ failTask,
7
10
  findTaskByVcs,
8
11
  initDb,
12
+ startTask,
13
+ supersedeTask,
9
14
  updateTaskVcs,
10
15
  } from "../be/db";
11
16
 
@@ -133,6 +138,40 @@ describe("updateTaskVcs", () => {
133
138
  expect(found!.id).toBe(task.id);
134
139
  });
135
140
 
141
+ test("findTaskByVcs excludes ALL terminal statuses (completed, failed, cancelled, superseded)", () => {
142
+ // PR #594 review: missing `cancelled` / `superseded` in the filter
143
+ // meant webhooks for a terminated PR/MR still routed to the dead task.
144
+ // Guard against any one of the four terminal statuses being missed.
145
+ const TERMINAL_CASES = [
146
+ { name: "completed", number: 200, terminate: (id: string) => completeTask(id, "done") },
147
+ { name: "failed", number: 201, terminate: (id: string) => failTask(id, "boom") },
148
+ { name: "cancelled", number: 202, terminate: (id: string) => cancelTask(id) },
149
+ {
150
+ name: "superseded",
151
+ number: 203,
152
+ terminate: (id: string) =>
153
+ supersedeTask(id, { reason: "manual_supersede", resumeTaskId: null }),
154
+ },
155
+ ];
156
+ for (const c of TERMINAL_CASES) {
157
+ const task = createTaskExtended(`Terminal=${c.name}`, {
158
+ agentId: "vcs-track-agent-001",
159
+ source: "api",
160
+ });
161
+ updateTaskVcs(task.id, {
162
+ vcsProvider: "github",
163
+ vcsRepo: "owner/terminal",
164
+ vcsNumber: c.number,
165
+ vcsUrl: `https://github.com/owner/terminal/pull/${c.number}`,
166
+ });
167
+ startTask(task.id);
168
+ c.terminate(task.id);
169
+
170
+ const found = findTaskByVcs("owner/terminal", c.number);
171
+ expect(found).toBeNull();
172
+ }
173
+ });
174
+
136
175
  test("idempotent: calling twice with same data both succeed", () => {
137
176
  const task = createTaskExtended("Test idempotency", {
138
177
  agentId: "vcs-track-agent-001",
@@ -14,7 +14,7 @@ import {
14
14
  import { findDuplicateTask } from "@/tools/task-dedup";
15
15
  import { ownerCtx, type ToolCtx } from "@/tools/task-tool-ctx";
16
16
  import { createToolRegistrar } from "@/tools/utils";
17
- import { AgentTaskSchema } from "@/types";
17
+ import { AgentTaskSchema, FollowUpConfigSchema } from "@/types";
18
18
 
19
19
  export const sendTaskInputSchema = z.object({
20
20
  agentId: z
@@ -83,6 +83,9 @@ export const sendTaskInputSchema = z.object({
83
83
  .describe(
84
84
  "ID of the human user who originally requested this task chain. When omitted, inherited from the caller's current task so the attribution flows through multi-hop delegation automatically.",
85
85
  ),
86
+ followUpConfig: FollowUpConfigSchema.optional().describe(
87
+ "Control the lead follow-up created when this task finishes. When to use `followUpConfig`: set `disabled: true` when you'll wait for this task to complete inline and no follow-up is needed; set `onCompleted` / `onFailed` with specific instructions when you need to follow up effectively on a particular outcome of a long-running flow; for normal one-shot tasks, leave it unset because defaults are fine. It is most valuable for long-running / complex flows.",
88
+ ),
86
89
  });
87
90
 
88
91
  export const sendTaskOutputSchema = z.object({
@@ -113,6 +116,7 @@ export async function sendTaskHandler(
113
116
  slackThreadTs,
114
117
  slackUserId,
115
118
  requestedByUserId: inputRequestedByUserId,
119
+ followUpConfig,
116
120
  }: SendTaskArgs,
117
121
  ): Promise<CallToolResult> {
118
122
  if (ctx.kind === "owner" && !ctx.agentId) {
@@ -200,6 +204,10 @@ export async function sendTaskHandler(
200
204
  // interrupted — re-dispatch is the correct response, not a deduped no-op.
201
205
  // Without this bypass, a cancelled worker permanently jams the thread
202
206
  // against re-delegation when an earlier completed sibling exists.
207
+ //
208
+ // NOTE: `taskType === "resume"` (created by createResumeFollowUp on
209
+ // supersede) is intentionally NOT in this guard — a resume IS the legitimate
210
+ // re-dispatch and bypassing the check is correct. Do not add "resume" here.
203
211
  if (sourceTaskId) {
204
212
  const sourceTask = getTaskById(sourceTaskId);
205
213
  if (
@@ -259,6 +267,7 @@ export async function sendTaskHandler(
259
267
  slackChannelId,
260
268
  slackThreadTs,
261
269
  slackUserId,
270
+ followUpConfig,
262
271
  });
263
272
 
264
273
  return {
@@ -311,6 +320,7 @@ export async function sendTaskHandler(
311
320
  slackChannelId,
312
321
  slackThreadTs,
313
322
  slackUserId,
323
+ followUpConfig,
314
324
  });
315
325
 
316
326
  return {
@@ -337,6 +347,7 @@ export async function sendTaskHandler(
337
347
  slackChannelId,
338
348
  slackThreadTs,
339
349
  slackUserId,
350
+ followUpConfig,
340
351
  });
341
352
 
342
353
  return {
@@ -18,7 +18,7 @@ import { getRetrievalsForTask } from "@/be/memory/raters/retrieval";
18
18
  import { runServerRaters } from "@/be/memory/raters/run-server-raters";
19
19
  import { createWorkerTaskFollowUp } from "@/tasks/worker-follow-up";
20
20
  import { createToolRegistrar } from "@/tools/utils";
21
- import { AgentTaskSchema, AttachmentInputSchema } from "@/types";
21
+ import { AgentTaskSchema, AttachmentInputSchema, isTerminalTaskStatus } from "@/types";
22
22
  import { validateJsonSchema } from "@/workflows/json-schema-validator";
23
23
 
24
24
  // Phase 11: the `cost` / `costData` field was removed from this tool's input
@@ -115,7 +115,7 @@ export const registerStoreProgressTool = (server: McpServer) => {
115
115
  }
116
116
 
117
117
  let updatedTask = existingTask;
118
- const isTerminal = ["completed", "failed", "cancelled"].includes(existingTask.status);
118
+ const isTerminal = isTerminalTaskStatus(existingTask.status);
119
119
 
120
120
  // Attachments — pointer-based, append-only. Insert each row inside
121
121
  // this transaction; the helper dedups by sha256 (when present) or by
@@ -52,10 +52,11 @@ registerTemplate({
52
52
  defaultBody: `Worker task completed \u2014 review needed.
53
53
 
54
54
  Agent: {{agent_name}}
55
+ Original task created by agent {{creator_agent}}
55
56
  Task: "{{task_desc}}"
56
57
 
57
58
  Output:
58
- {{output_summary}}
59
+ {{output_summary}}{{follow_up_instructions}}
59
60
 
60
61
  IMPORTANT: Do NOT re-delegate or re-answer the original request. The worker has already handled it. Your job is ONLY to:
61
62
  1. Review the output above
@@ -65,8 +66,13 @@ IMPORTANT: Do NOT re-delegate or re-answer the original request. The worker has
65
66
  Use \`get-task-details\` with taskId "{{task_id}}" for full details.`,
66
67
  variables: [
67
68
  { name: "agent_name", description: "Worker agent name or ID prefix" },
69
+ { name: "creator_agent", description: "Agent ID that originally created the worker task" },
68
70
  { name: "task_desc", description: "Task description (truncated to 200 chars)" },
69
71
  { name: "output_summary", description: "Task output (truncated to 500 chars)" },
72
+ {
73
+ name: "follow_up_instructions",
74
+ description: "Optional per-task instructions from followUpConfig for this completion",
75
+ },
70
76
  { name: "task_id", description: "Original task ID" },
71
77
  ],
72
78
  category: "task_lifecycle",
@@ -106,15 +112,21 @@ registerTemplate({
106
112
  defaultBody: `Worker task failed \u2014 action needed.
107
113
 
108
114
  Agent: {{agent_name}}
115
+ Original task created by agent {{creator_agent}}
109
116
  Task: "{{task_desc}}"
110
117
 
111
- Failure reason: {{failure_reason}}
118
+ Failure reason: {{failure_reason}}{{follow_up_instructions}}
112
119
 
113
120
  Decide whether to reassign, retry, or handle the failure. Use \`get-task-details\` with taskId "{{task_id}}" for full details.`,
114
121
  variables: [
115
122
  { name: "agent_name", description: "Worker agent name or ID prefix" },
123
+ { name: "creator_agent", description: "Agent ID that originally created the worker task" },
116
124
  { name: "task_desc", description: "Task description (truncated to 200 chars)" },
117
125
  { name: "failure_reason", description: "Failure reason text" },
126
+ {
127
+ name: "follow_up_instructions",
128
+ description: "Optional per-task instructions from followUpConfig for this failure",
129
+ },
118
130
  { name: "task_id", description: "Original task ID" },
119
131
  ],
120
132
  category: "task_lifecycle",
package/src/types.ts CHANGED
@@ -8,12 +8,36 @@ export const AgentTaskStatusSchema = z.enum([
8
8
  "reviewing", // Agent is reviewing an offered task
9
9
  "pending", // Assigned/accepted, waiting to start
10
10
  "in_progress",
11
- "paused", // Interrupted by graceful shutdown, can resume
11
+ "paused", // Interrupted by graceful shutdown (legacy), can resume
12
12
  "completed",
13
13
  "failed",
14
14
  "cancelled", // Task was cancelled by lead or creator
15
+ "superseded", // Original terminated, replaced by a follow-up "resume" task
15
16
  ]);
16
17
 
18
+ /**
19
+ * Terminal task statuses — a task in one of these is done. No further state
20
+ * transitions, no re-assignment, no follow-up creation on the same id.
21
+ *
22
+ * Single source of truth for JS-side checks (sync handlers, store-progress,
23
+ * db mutator guards, HTTP cancel guard).
24
+ *
25
+ * **SQL drift watch**: `src/be/db.ts` has ~8 prepared statements that inline
26
+ * these strings — SQL can't import a TS const. When adding a new terminal
27
+ * status, grep across `src/be/db.ts` for:
28
+ * - `status NOT IN ('completed'` — non-terminal filters (findTaskByVcs,
29
+ * findRecentSimilarTasks, mutator guards, hasNonTerminalChildTask)
30
+ * - `status IN ('completed', 'failed'` — intent-terminal lookups
31
+ * - `status = CASE WHEN status IN ('completed'` — setProgress guard
32
+ * and update every site.
33
+ */
34
+ export const TERMINAL_TASK_STATUSES = ["completed", "failed", "cancelled", "superseded"] as const;
35
+ export type TerminalTaskStatus = (typeof TERMINAL_TASK_STATUSES)[number];
36
+
37
+ export function isTerminalTaskStatus(status: string): status is TerminalTaskStatus {
38
+ return (TERMINAL_TASK_STATUSES as readonly string[]).includes(status);
39
+ }
40
+
17
41
  // ============================================================================
18
42
  // Lead Inbox Types
19
43
  // ============================================================================
@@ -103,6 +127,13 @@ export type ProviderMetaMap = {
103
127
  opencode: NoProviderMeta;
104
128
  };
105
129
 
130
+ export const FollowUpConfigSchema = z.object({
131
+ disabled: z.boolean().optional(),
132
+ onCompleted: z.string().max(4000).optional(),
133
+ onFailed: z.string().max(4000).optional(),
134
+ });
135
+ export type FollowUpConfig = z.infer<typeof FollowUpConfigSchema>;
136
+
106
137
  export const AgentTaskSchema = z.object({
107
138
  id: z.uuid(),
108
139
  agentId: z.uuid().nullable(), // Nullable for unassigned tasks
@@ -186,6 +217,9 @@ export const AgentTaskSchema = z.object({
186
217
  // Structured output schema (optional — JSON Schema that task output must conform to)
187
218
  outputSchema: z.record(z.string(), z.unknown()).optional(),
188
219
 
220
+ // Lead follow-up control (optional — null/undefined preserves default behavior)
221
+ followUpConfig: FollowUpConfigSchema.optional(),
222
+
189
223
  // Pause tracking
190
224
  wasPaused: z.boolean().default(false),
191
225
 
@@ -660,7 +694,18 @@ export const AgentLogEventTypeSchema = z.enum([
660
694
  "budget.deleted",
661
695
  "pricing.inserted",
662
696
  "pricing.deleted",
697
+ // Graceful pause/resume via follow-up
698
+ "task_superseded",
699
+ ]);
700
+
701
+ // Reasons a task can be superseded (terminal) and replaced by a "resume" follow-up.
702
+ export const ResumeReasonSchema = z.enum([
703
+ "graceful_shutdown", // Worker received SIGTERM / SIGINT
704
+ "context_limits", // Provider session approaching context-window limits (Phase 6)
705
+ "manual_supersede", // Operator-triggered (e.g. dashboard button)
706
+ "crash_recovery", // Heartbeat sweep detected dead/stalled worker (DES-523)
663
707
  ]);
708
+ export type ResumeReason = z.infer<typeof ResumeReasonSchema>;
664
709
 
665
710
  export const AgentLogSchema = z.object({
666
711
  id: z.uuid(),
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { workflowContextKey } from "../../tasks/context-key";
3
3
  import { withSiblingAwareness } from "../../tasks/sibling-awareness";
4
4
  import type { ExecutorMeta } from "../../types";
5
+ import { FollowUpConfigSchema } from "../../types";
5
6
  import type { ExecutorResult } from "./base";
6
7
  import { BaseExecutor } from "./base";
7
8
 
@@ -18,6 +19,7 @@ const AgentTaskConfigSchema = z.object({
18
19
  model: z.string().min(1).optional(),
19
20
  parentTaskId: z.string().uuid().optional(),
20
21
  outputSchema: z.record(z.string(), z.unknown()).optional(),
22
+ followUpConfig: FollowUpConfigSchema.optional(),
21
23
  });
22
24
 
23
25
  const AgentTaskOutputSchema = z.object({
@@ -94,6 +96,7 @@ export class AgentTaskExecutor extends BaseExecutor<
94
96
  model: config.model,
95
97
  parentTaskId: config.parentTaskId,
96
98
  outputSchema: config.outputSchema,
99
+ followUpConfig: config.followUpConfig,
97
100
  contextKey: workflowContextKey({ workflowRunId: meta.runId }),
98
101
  },
99
102
  );