@desplega.ai/agent-swarm 1.74.0 → 1.74.2

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 (43) hide show
  1. package/openapi.json +199 -1
  2. package/package.json +1 -1
  3. package/src/be/db.ts +278 -0
  4. package/src/be/migrations/049_wait_states.sql +30 -0
  5. package/src/be/migrations/050_wait_states_scope.sql +19 -0
  6. package/src/http/index.ts +2 -0
  7. package/src/http/trackers/jira.ts +84 -27
  8. package/src/http/trackers/linear.ts +67 -11
  9. package/src/http/utils.ts +15 -0
  10. package/src/http/workflow-events.ts +107 -0
  11. package/src/http/workflows.ts +55 -6
  12. package/src/jira/sync.ts +20 -7
  13. package/src/linear/gate.ts +122 -0
  14. package/src/linear/sync.ts +128 -0
  15. package/src/oauth/keepalive.ts +34 -13
  16. package/src/tests/ensure-token.test.ts +33 -0
  17. package/src/tests/linear-webhook.test.ts +383 -0
  18. package/src/tests/workflow-executors.test.ts +4 -2
  19. package/src/tests/workflow-mcp-trigger-schema.test.ts +617 -0
  20. package/src/tests/workflow-patch.test.ts +14 -14
  21. package/src/tests/workflow-wait-builtin-events.test.ts +279 -0
  22. package/src/tests/workflow-wait-event.test.ts +384 -0
  23. package/src/tests/workflow-wait-filter.test.ts +200 -0
  24. package/src/tests/workflow-wait-http.test.ts +177 -0
  25. package/src/tests/workflow-wait-recovery.test.ts +178 -0
  26. package/src/tests/workflow-wait-state-queries.test.ts +419 -0
  27. package/src/tests/workflow-wait-time.test.ts +255 -0
  28. package/src/tools/tracker/tracker-status.ts +7 -1
  29. package/src/tools/workflows/create-workflow.ts +16 -2
  30. package/src/tools/workflows/patch-workflow.ts +26 -6
  31. package/src/tools/workflows/trigger-workflow.ts +26 -1
  32. package/src/tools/workflows/update-workflow.ts +28 -2
  33. package/src/types.ts +48 -3
  34. package/src/workflows/definition.ts +2 -5
  35. package/src/workflows/executors/index.ts +1 -0
  36. package/src/workflows/executors/registry.ts +2 -0
  37. package/src/workflows/executors/wait.ts +170 -0
  38. package/src/workflows/index.ts +18 -2
  39. package/src/workflows/json-schema-validator.ts +8 -1
  40. package/src/workflows/recovery.ts +55 -1
  41. package/src/workflows/resume.ts +272 -0
  42. package/src/workflows/wait-filter.ts +311 -0
  43. package/src/workflows/wait-poller.ts +63 -0
@@ -10,6 +10,12 @@ import { ensureToken } from "../oauth/ensure-token";
10
10
  import { resolveTemplate } from "../prompts/resolver";
11
11
  import { linearContextKey } from "../tasks/context-key";
12
12
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
13
+ import {
14
+ buildSkipMessage,
15
+ getLinearGateConfig,
16
+ type LinearGateInput,
17
+ shouldCreateTaskFromLinearEvent,
18
+ } from "./gate";
13
19
  // Side-effect import: registers all Linear event templates in the in-memory registry
14
20
  import "./templates";
15
21
 
@@ -228,6 +234,104 @@ export function mapLinearStatusToSwarm(linearStateName: string): string | null {
228
234
  return LINEAR_STATUS_MAP[linearStateName] ?? null;
229
235
  }
230
236
 
237
+ /**
238
+ * Try to read state.type and label names directly from the webhook payload.
239
+ *
240
+ * Returns null if neither field is present so the caller can fetch via
241
+ * GraphQL. As of the Linear AgentSessionEvent webhook schema (April 2026),
242
+ * `agentSession.issue` only includes `id`, `identifier`, `title`, `url`,
243
+ * `description`, `team`, and `teamId` — but we still try inline extraction
244
+ * to keep tests hermetic and to be forward-compatible if Linear ever extends
245
+ * the payload.
246
+ */
247
+ function extractInlineGateInput(issue: Record<string, unknown>): LinearGateInput | null {
248
+ const stateRaw = issue.state as Record<string, unknown> | undefined;
249
+ const stateType =
250
+ stateRaw && typeof stateRaw.type === "string" ? (stateRaw.type as string) : null;
251
+
252
+ const labelsRaw = issue.labels;
253
+ let labelNames: string[] | null = null;
254
+ if (Array.isArray(labelsRaw)) {
255
+ labelNames = labelsRaw
256
+ .map((l) =>
257
+ l && typeof l === "object" ? String((l as Record<string, unknown>).name ?? "") : "",
258
+ )
259
+ .filter((n) => n.length > 0);
260
+ } else if (
261
+ labelsRaw &&
262
+ typeof labelsRaw === "object" &&
263
+ Array.isArray((labelsRaw as { nodes?: unknown }).nodes)
264
+ ) {
265
+ labelNames = ((labelsRaw as { nodes: unknown[] }).nodes ?? [])
266
+ .map((l) =>
267
+ l && typeof l === "object" ? String((l as Record<string, unknown>).name ?? "") : "",
268
+ )
269
+ .filter((n) => n.length > 0);
270
+ }
271
+
272
+ if (stateType === null && labelNames === null) return null;
273
+ return { stateType, labelNames: labelNames ?? [] };
274
+ }
275
+
276
+ const ISSUE_GATE_QUERY = `
277
+ query AgentSwarmIssueGate($id: String!) {
278
+ issue(id: $id) {
279
+ state { type }
280
+ labels { nodes { name } }
281
+ }
282
+ }
283
+ `;
284
+
285
+ /**
286
+ * Fetch the workflow-state type and label names for an issue via GraphQL.
287
+ * Used as a fallback when the AgentSessionEvent payload doesn't include them
288
+ * inline (today, it never does).
289
+ *
290
+ * Exported for testing — not part of the public API.
291
+ */
292
+ export async function _fetchIssueGatingInfo(issueId: string): Promise<LinearGateInput> {
293
+ await ensureToken("linear");
294
+ const tokens = getOAuthTokens("linear");
295
+ if (!tokens) {
296
+ console.log(
297
+ `[Linear Sync] No OAuth tokens; cannot fetch issue ${issueId} gating info — defaulting to allow.`,
298
+ );
299
+ return { stateType: null, labelNames: [] };
300
+ }
301
+ const res = await fetch("https://api.linear.app/graphql", {
302
+ method: "POST",
303
+ headers: {
304
+ "Content-Type": "application/json",
305
+ Authorization: `Bearer ${tokens.accessToken}`,
306
+ },
307
+ body: JSON.stringify({ query: ISSUE_GATE_QUERY, variables: { id: issueId } }),
308
+ });
309
+ if (!res.ok) {
310
+ console.error(
311
+ `[Linear Sync] Failed to fetch issue ${issueId} gating info: ${res.status} ${res.statusText}`,
312
+ );
313
+ return { stateType: null, labelNames: [] };
314
+ }
315
+ const json = (await res.json()) as {
316
+ data?: {
317
+ issue?: {
318
+ state?: { type?: string };
319
+ labels?: { nodes?: Array<{ name?: string }> };
320
+ };
321
+ };
322
+ errors?: unknown[];
323
+ };
324
+ if (json.errors) {
325
+ console.error("[Linear Sync] GraphQL errors fetching issue gating info:", json.errors);
326
+ return { stateType: null, labelNames: [] };
327
+ }
328
+ const stateType = json.data?.issue?.state?.type ?? null;
329
+ const labelNames = (json.data?.issue?.labels?.nodes ?? [])
330
+ .map((n) => n?.name ?? "")
331
+ .filter((n) => n.length > 0);
332
+ return { stateType, labelNames };
333
+ }
334
+
231
335
  /**
232
336
  * Find the lead agent to receive Linear tasks.
233
337
  * Returns null if no lead is available (task will go to pool).
@@ -320,6 +424,30 @@ export async function handleAgentSessionEvent(event: Record<string, unknown>): P
320
424
  );
321
425
  }
322
426
 
427
+ // State gate: only trigger task creation when the issue is in an allowed
428
+ // workflow state (Todo / In Progress / etc). States outside the allowlist
429
+ // (Backlog, Triage by default) are skipped unless the swarm-ready label
430
+ // override is attached. Both the allowlist and the override label name are
431
+ // configurable via LINEAR_ALLOWED_STATES and LINEAR_SWARM_READY_LABEL.
432
+ const gateConfig = getLinearGateConfig();
433
+ const inlineGate = extractInlineGateInput(issue);
434
+ const gateInput = inlineGate ?? (await _fetchIssueGatingInfo(issueId));
435
+ const decision = shouldCreateTaskFromLinearEvent(gateInput, gateConfig);
436
+ if (!decision.create) {
437
+ console.log(
438
+ `[Linear Sync] Issue ${issueIdentifier} skipped — workflow state "${decision.reason}" is gated (labels: [${gateInput.labelNames.join(", ")}])`,
439
+ );
440
+ if (sessionId) {
441
+ const skipMsg = buildSkipMessage(decision.reason, gateConfig.swarmReadyLabel);
442
+ // Use response so the AgentSession auto-completes — leaves a visible
443
+ // comment on the issue without orphaning the session in pending state.
444
+ endAgentSession(sessionId, skipMsg, "response").catch((err) => {
445
+ console.error("[Linear Sync] Failed to post skip response on AgentSession:", err);
446
+ });
447
+ }
448
+ return;
449
+ }
450
+
323
451
  const lead = findLeadAgent();
324
452
 
325
453
  const sessionSection = sessionUrl ? `\nSession: ${sessionUrl}` : "";
@@ -1,7 +1,20 @@
1
1
  import { ensureTokenOrThrow } from "./ensure-token";
2
2
 
3
- const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
4
- const THIRTEEN_HOURS_MS = 13 * 60 * 60 * 1000;
3
+ // Tick every 50 minutes with a 65-minute "expiring soon" buffer.
4
+ //
5
+ // Atlassian (and Linear) issue 1h access tokens. With this cadence the DB row
6
+ // is always rotated before its current access token expires, so anything that
7
+ // reads oauth_tokens.accessToken directly without going through jiraFetch /
8
+ // linear-outbound (e.g. agents using the read-only db-query MCP) sees a
9
+ // not-yet-expired token. The 65-min buffer is wider than the access-token
10
+ // lifetime, so isTokenExpiringSoon always returns true and every tick rotates.
11
+ //
12
+ // Touching the row this often also serves the original "keep the refresh
13
+ // token alive" goal — Atlassian expires inactive refresh tokens after 90 days,
14
+ // and Linear's behavior is similar; refreshing every 50 min trivially keeps
15
+ // both providers active.
16
+ const KEEPALIVE_INTERVAL_MS = 50 * 60 * 1000;
17
+ const KEEPALIVE_BUFFER_MS = 65 * 60 * 1000;
5
18
  const SLACK_ALERTS_CHANNEL = process.env.SLACK_ALERTS_CHANNEL || "C08JCRURPBV";
6
19
 
7
20
  const KEEPALIVE_PROVIDERS = ["linear", "jira"] as const;
@@ -9,20 +22,25 @@ const KEEPALIVE_PROVIDERS = ["linear", "jira"] as const;
9
22
  let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
10
23
 
11
24
  /**
12
- * Proactively refresh OAuth tokens on a schedule to prevent expiry.
13
- * If refresh fails, posts a Slack notification so someone can re-auth manually.
25
+ * Proactively refresh OAuth tokens on a schedule.
14
26
  *
15
- * Why both Linear and Jira: Atlassian rotates refresh tokens and expires them
16
- * after 90 days of inactivity, so a swarm that doesn't touch Jira for a long
17
- * stretch will silently lose the ability to refresh. Touching the token every
18
- * 12h keeps the refresh token alive and surfaces a dead one as an alert
19
- * instead of a runtime 401.
27
+ * Two purposes, both served by the same tick:
28
+ *
29
+ * 1. Access-token freshness in the DB. Anything that reads
30
+ * `oauth_tokens.accessToken` directly (db-query MCP, future MCP servers,
31
+ * `tracker-status`) needs a not-yet-expired value. The 50-min cadence
32
+ * keeps the row ahead of the 1h access-token lifetime.
33
+ * 2. Refresh-token liveness. Atlassian rotates refresh tokens and expires
34
+ * them after ~90 days of inactivity, so silent gaps in usage would kill
35
+ * the integration. Refreshing on every tick keeps the refresh token
36
+ * active and surfaces a dead one as a Slack alert instead of a runtime
37
+ * 401 in the middle of an agent task.
20
38
  */
21
39
  async function runKeepalive(): Promise<void> {
22
40
  for (const provider of KEEPALIVE_PROVIDERS) {
23
41
  console.log(`[OAuth Keepalive] Running scheduled token refresh for ${provider}...`);
24
42
  try {
25
- await ensureTokenOrThrow(provider, THIRTEEN_HOURS_MS);
43
+ await ensureTokenOrThrow(provider, KEEPALIVE_BUFFER_MS);
26
44
  console.log(`[OAuth Keepalive] ${provider} token check completed successfully`);
27
45
  } catch (err) {
28
46
  const message = err instanceof Error ? err.message : String(err);
@@ -56,7 +74,8 @@ async function notifySlack(text: string): Promise<void> {
56
74
  }
57
75
 
58
76
  /**
59
- * Start the OAuth keepalive timer. Runs immediately then every 12 hours.
77
+ * Start the OAuth keepalive timer. Runs once shortly after startup, then on
78
+ * KEEPALIVE_INTERVAL_MS thereafter.
60
79
  */
61
80
  export function startOAuthKeepalive(): void {
62
81
  if (keepaliveInterval) {
@@ -64,14 +83,16 @@ export function startOAuthKeepalive(): void {
64
83
  return;
65
84
  }
66
85
 
67
- console.log("[OAuth Keepalive] Starting (12h interval)");
86
+ console.log(
87
+ `[OAuth Keepalive] Starting (interval ${Math.round(KEEPALIVE_INTERVAL_MS / 60_000)}min, buffer ${Math.round(KEEPALIVE_BUFFER_MS / 60_000)}min)`,
88
+ );
68
89
 
69
90
  // Run once after a short delay (let server finish startup)
70
91
  setTimeout(() => runKeepalive(), 10_000);
71
92
 
72
93
  keepaliveInterval = setInterval(() => {
73
94
  runKeepalive();
74
- }, TWELVE_HOURS_MS);
95
+ }, KEEPALIVE_INTERVAL_MS);
75
96
  }
76
97
 
77
98
  /**
@@ -233,4 +233,37 @@ describe("ensureTokenOrThrow", () => {
233
233
  test("stays silent (no throw) when provider is not configured", async () => {
234
234
  await expect(ensureTokenOrThrow("nonexistent-provider")).resolves.toBeUndefined();
235
235
  });
236
+
237
+ test("forces a refresh when bufferMs is wider than any plausible expiry", async () => {
238
+ // Pattern used by the POST /api/trackers/{provider}/refresh route to
239
+ // guarantee a rotation regardless of how far the current token is from
240
+ // expiry.
241
+ storeOAuthTokens("test-provider", {
242
+ accessToken: "old-token",
243
+ refreshToken: "refresh-token",
244
+ expiresAt: new Date(Date.now() + 50 * 60 * 1000).toISOString(), // 50 min ahead
245
+ });
246
+
247
+ const fetchSpy = mock(() =>
248
+ Promise.resolve(
249
+ new Response(
250
+ JSON.stringify({
251
+ access_token: "rotated-token",
252
+ token_type: "Bearer",
253
+ expires_in: 3600,
254
+ refresh_token: "rotated-refresh",
255
+ }),
256
+ { status: 200, headers: { "Content-Type": "application/json" } },
257
+ ),
258
+ ),
259
+ );
260
+ globalThis.fetch = fetchSpy;
261
+
262
+ await ensureTokenOrThrow("test-provider", Number.MAX_SAFE_INTEGER);
263
+
264
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
265
+ const tokens = getOAuthTokens("test-provider");
266
+ expect(tokens?.accessToken).toBe("rotated-token");
267
+ expect(tokens?.refreshToken).toBe("rotated-refresh");
268
+ });
236
269
  });
@@ -3,6 +3,14 @@ import { createHmac } from "node:crypto";
3
3
  import { unlink } from "node:fs/promises";
4
4
  import { closeDb, createTaskExtended, getTaskById, initDb } from "../be/db";
5
5
  import { createTrackerSync, getTrackerSyncByExternalId } from "../be/db-queries/tracker";
6
+ import {
7
+ buildSkipMessage,
8
+ DEFAULT_ALLOWED_STATE_TYPES,
9
+ DEFAULT_SWARM_READY_LABEL,
10
+ getLinearGateConfig,
11
+ SWARM_READY_LABEL,
12
+ shouldCreateTaskFromLinearEvent,
13
+ } from "../linear/gate";
6
14
  import {
7
15
  handleAgentSessionEvent,
8
16
  handleIssueDelete,
@@ -505,3 +513,378 @@ describe("handleIssueDelete", () => {
505
513
  expect(updated!.status).toBe("completed");
506
514
  });
507
515
  });
516
+
517
+ // ─── shouldCreateTaskFromLinearEvent (gate) ──────────────────────────────────
518
+
519
+ describe("shouldCreateTaskFromLinearEvent (default config)", () => {
520
+ test("creates task for unstarted (Todo) state", () => {
521
+ const decision = shouldCreateTaskFromLinearEvent({
522
+ stateType: "unstarted",
523
+ labelNames: [],
524
+ });
525
+ expect(decision).toEqual({ create: true, reason: "ready" });
526
+ });
527
+
528
+ test("creates task for started (In Progress) state", () => {
529
+ const decision = shouldCreateTaskFromLinearEvent({
530
+ stateType: "started",
531
+ labelNames: ["bug"],
532
+ });
533
+ expect(decision.create).toBe(true);
534
+ });
535
+
536
+ test("skips task for backlog state", () => {
537
+ const decision = shouldCreateTaskFromLinearEvent({
538
+ stateType: "backlog",
539
+ labelNames: [],
540
+ });
541
+ expect(decision).toEqual({ create: false, reason: "backlog" });
542
+ });
543
+
544
+ test("skips task for triage state", () => {
545
+ const decision = shouldCreateTaskFromLinearEvent({
546
+ stateType: "triage",
547
+ labelNames: ["needs-review"],
548
+ });
549
+ expect(decision).toEqual({ create: false, reason: "triage" });
550
+ });
551
+
552
+ test("swarm-ready label overrides backlog gate", () => {
553
+ const decision = shouldCreateTaskFromLinearEvent({
554
+ stateType: "backlog",
555
+ labelNames: [SWARM_READY_LABEL],
556
+ });
557
+ expect(decision).toEqual({ create: true, reason: "label-override" });
558
+ });
559
+
560
+ test("swarm-ready label overrides triage gate", () => {
561
+ const decision = shouldCreateTaskFromLinearEvent({
562
+ stateType: "triage",
563
+ labelNames: ["bug", SWARM_READY_LABEL, "p0"],
564
+ });
565
+ expect(decision).toEqual({ create: true, reason: "label-override" });
566
+ });
567
+
568
+ test("label match is case-insensitive", () => {
569
+ const decision = shouldCreateTaskFromLinearEvent({
570
+ stateType: "backlog",
571
+ labelNames: ["Swarm-Ready"],
572
+ });
573
+ expect(decision.create).toBe(true);
574
+ });
575
+
576
+ test("state matching is case-insensitive (defensive)", () => {
577
+ // Linear's enum is lowercase but be defensive in case payload casing varies.
578
+ const decision = shouldCreateTaskFromLinearEvent({
579
+ stateType: "Backlog",
580
+ labelNames: [],
581
+ });
582
+ expect(decision).toEqual({ create: false, reason: "backlog" });
583
+ });
584
+
585
+ test("allowed state types pass through", () => {
586
+ // E.g. completed, canceled — included in default allowlist, trigger as today.
587
+ expect(shouldCreateTaskFromLinearEvent({ stateType: "completed", labelNames: [] }).create).toBe(
588
+ true,
589
+ );
590
+ expect(shouldCreateTaskFromLinearEvent({ stateType: "canceled", labelNames: [] }).create).toBe(
591
+ true,
592
+ );
593
+ });
594
+
595
+ test("null state type allows task creation (fail-open)", () => {
596
+ // If we couldn't resolve the state for any reason, default to today's
597
+ // behavior rather than silently swallowing assignments.
598
+ const decision = shouldCreateTaskFromLinearEvent({
599
+ stateType: null,
600
+ labelNames: [],
601
+ });
602
+ expect(decision.create).toBe(true);
603
+ });
604
+
605
+ test("DEFAULT_SWARM_READY_LABEL matches today's hardcoded value", () => {
606
+ expect(DEFAULT_SWARM_READY_LABEL).toBe("swarm-ready");
607
+ expect(SWARM_READY_LABEL).toBe(DEFAULT_SWARM_READY_LABEL);
608
+ });
609
+
610
+ test("DEFAULT_ALLOWED_STATE_TYPES covers Linear enum minus triage/backlog", () => {
611
+ expect([...DEFAULT_ALLOWED_STATE_TYPES].sort()).toEqual(
612
+ ["canceled", "completed", "started", "unstarted"].sort(),
613
+ );
614
+ });
615
+ });
616
+
617
+ describe("shouldCreateTaskFromLinearEvent (env-driven overrides)", () => {
618
+ const envKeys = ["LINEAR_ALLOWED_STATES", "LINEAR_SWARM_READY_LABEL"] as const;
619
+ const saved: Record<string, string | undefined> = {};
620
+
621
+ beforeEach(() => {
622
+ for (const k of envKeys) {
623
+ saved[k] = process.env[k];
624
+ delete process.env[k];
625
+ }
626
+ });
627
+
628
+ // Re-snapshot only this scope's env state on cleanup.
629
+ function restore() {
630
+ for (const k of envKeys) {
631
+ if (saved[k] === undefined) delete process.env[k];
632
+ else process.env[k] = saved[k];
633
+ }
634
+ }
635
+
636
+ test("LINEAR_ALLOWED_STATES expands the allowlist (e.g. include backlog)", () => {
637
+ process.env.LINEAR_ALLOWED_STATES = "unstarted,started,backlog";
638
+ const decision = shouldCreateTaskFromLinearEvent({
639
+ stateType: "backlog",
640
+ labelNames: [],
641
+ });
642
+ expect(decision).toEqual({ create: true, reason: "ready" });
643
+ restore();
644
+ });
645
+
646
+ test("LINEAR_ALLOWED_STATES restricts the allowlist (drop completed)", () => {
647
+ process.env.LINEAR_ALLOWED_STATES = "unstarted,started";
648
+ const decision = shouldCreateTaskFromLinearEvent({
649
+ stateType: "completed",
650
+ labelNames: [],
651
+ });
652
+ expect(decision).toEqual({ create: false, reason: "completed" });
653
+ restore();
654
+ });
655
+
656
+ test("LINEAR_ALLOWED_STATES handles whitespace and case", () => {
657
+ process.env.LINEAR_ALLOWED_STATES = " Unstarted , STARTED ,backlog";
658
+ expect(shouldCreateTaskFromLinearEvent({ stateType: "backlog", labelNames: [] }).create).toBe(
659
+ true,
660
+ );
661
+ expect(shouldCreateTaskFromLinearEvent({ stateType: "completed", labelNames: [] }).create).toBe(
662
+ false,
663
+ );
664
+ restore();
665
+ });
666
+
667
+ test("empty LINEAR_ALLOWED_STATES locks down everything but label override", () => {
668
+ process.env.LINEAR_ALLOWED_STATES = "";
669
+ expect(shouldCreateTaskFromLinearEvent({ stateType: "started", labelNames: [] })).toEqual({
670
+ create: false,
671
+ reason: "started",
672
+ });
673
+ expect(
674
+ shouldCreateTaskFromLinearEvent({
675
+ stateType: "started",
676
+ labelNames: [SWARM_READY_LABEL],
677
+ }).create,
678
+ ).toBe(true);
679
+ restore();
680
+ });
681
+
682
+ test("LINEAR_SWARM_READY_LABEL overrides the bypass label", () => {
683
+ process.env.LINEAR_SWARM_READY_LABEL = "go-now";
684
+ // The default label no longer triggers.
685
+ expect(
686
+ shouldCreateTaskFromLinearEvent({
687
+ stateType: "backlog",
688
+ labelNames: [SWARM_READY_LABEL],
689
+ }),
690
+ ).toEqual({ create: false, reason: "backlog" });
691
+ // The configured label does.
692
+ expect(
693
+ shouldCreateTaskFromLinearEvent({
694
+ stateType: "backlog",
695
+ labelNames: ["go-now"],
696
+ }),
697
+ ).toEqual({ create: true, reason: "label-override" });
698
+ restore();
699
+ });
700
+
701
+ test("LINEAR_SWARM_READY_LABEL match is case-insensitive", () => {
702
+ process.env.LINEAR_SWARM_READY_LABEL = "GO-NOW";
703
+ expect(
704
+ shouldCreateTaskFromLinearEvent({
705
+ stateType: "backlog",
706
+ labelNames: ["Go-Now"],
707
+ }).create,
708
+ ).toBe(true);
709
+ restore();
710
+ });
711
+
712
+ test("getLinearGateConfig reflects env at call time", () => {
713
+ process.env.LINEAR_ALLOWED_STATES = "started";
714
+ process.env.LINEAR_SWARM_READY_LABEL = "ship-it";
715
+ const cfg = getLinearGateConfig();
716
+ expect(cfg.allowedStateTypes).toEqual(new Set(["started"]));
717
+ expect(cfg.swarmReadyLabel).toBe("ship-it");
718
+ restore();
719
+ });
720
+
721
+ test("getLinearGateConfig falls back to defaults when env is unset", () => {
722
+ const cfg = getLinearGateConfig();
723
+ expect(cfg.allowedStateTypes).toEqual(new Set(DEFAULT_ALLOWED_STATE_TYPES));
724
+ expect(cfg.swarmReadyLabel).toBe(DEFAULT_SWARM_READY_LABEL);
725
+ });
726
+
727
+ test("buildSkipMessage uses the configured override label", () => {
728
+ process.env.LINEAR_SWARM_READY_LABEL = "go-now";
729
+ const msg = buildSkipMessage("backlog", getLinearGateConfig().swarmReadyLabel);
730
+ expect(msg).toContain("Backlog");
731
+ expect(msg).toContain("`go-now`");
732
+ expect(msg).not.toContain("`swarm-ready`");
733
+ restore();
734
+ });
735
+ });
736
+
737
+ // ─── handleAgentSessionEvent — state-gate skip path ──────────────────────────
738
+
739
+ describe("handleAgentSessionEvent — state gate", () => {
740
+ test("skips task creation when issue.state.type is backlog", async () => {
741
+ const event = {
742
+ type: "AgentSession",
743
+ action: "create",
744
+ agentSession: {
745
+ id: "session-gate-backlog-001",
746
+ url: "https://linear.app/team/issue/ENG-500/agent",
747
+ issue: {
748
+ id: "issue-gate-backlog-001",
749
+ identifier: "ENG-500",
750
+ title: "Backlog ticket",
751
+ url: "https://linear.app/team/issue/ENG-500",
752
+ // Inline state/labels so the test doesn't need to mock GraphQL.
753
+ state: { type: "backlog" },
754
+ labels: [],
755
+ },
756
+ },
757
+ };
758
+
759
+ await handleAgentSessionEvent(event);
760
+
761
+ // No tracker_sync should have been created.
762
+ const sync = getTrackerSyncByExternalId("linear", "task", "issue-gate-backlog-001");
763
+ expect(sync).toBeNull();
764
+ });
765
+
766
+ test("skips task creation when issue.state.type is triage", async () => {
767
+ const event = {
768
+ type: "AgentSession",
769
+ action: "create",
770
+ agentSession: {
771
+ id: "session-gate-triage-001",
772
+ issue: {
773
+ id: "issue-gate-triage-001",
774
+ identifier: "ENG-501",
775
+ title: "Triage ticket",
776
+ url: "https://linear.app/team/issue/ENG-501",
777
+ state: { type: "triage" },
778
+ labels: { nodes: [] },
779
+ },
780
+ },
781
+ };
782
+
783
+ await handleAgentSessionEvent(event);
784
+
785
+ const sync = getTrackerSyncByExternalId("linear", "task", "issue-gate-triage-001");
786
+ expect(sync).toBeNull();
787
+ });
788
+
789
+ test("creates task when issue is in backlog but has swarm-ready label", async () => {
790
+ const event = {
791
+ type: "AgentSession",
792
+ action: "create",
793
+ agentSession: {
794
+ id: "session-gate-override-001",
795
+ issue: {
796
+ id: "issue-gate-override-001",
797
+ identifier: "ENG-502",
798
+ title: "Pre-staged backlog ticket",
799
+ url: "https://linear.app/team/issue/ENG-502",
800
+ state: { type: "backlog" },
801
+ labels: { nodes: [{ name: "bug" }, { name: SWARM_READY_LABEL }] },
802
+ },
803
+ },
804
+ };
805
+
806
+ await handleAgentSessionEvent(event);
807
+
808
+ const sync = getTrackerSyncByExternalId("linear", "task", "issue-gate-override-001");
809
+ expect(sync).not.toBeNull();
810
+ expect(sync!.externalIdentifier).toBe("ENG-502");
811
+ const task = getTaskById(sync!.swarmId);
812
+ expect(task).not.toBeNull();
813
+ expect(task!.source).toBe("linear");
814
+ });
815
+
816
+ test("LINEAR_ALLOWED_STATES env override widens the allowlist end-to-end", async () => {
817
+ process.env.LINEAR_ALLOWED_STATES = "unstarted,started,backlog";
818
+ try {
819
+ const event = {
820
+ type: "AgentSession",
821
+ action: "create",
822
+ agentSession: {
823
+ id: "session-gate-env-allow-001",
824
+ issue: {
825
+ id: "issue-gate-env-allow-001",
826
+ identifier: "ENG-510",
827
+ title: "Backlog ticket allowed via env",
828
+ url: "https://linear.app/team/issue/ENG-510",
829
+ state: { type: "backlog" },
830
+ labels: [],
831
+ },
832
+ },
833
+ };
834
+ await handleAgentSessionEvent(event);
835
+ const sync = getTrackerSyncByExternalId("linear", "task", "issue-gate-env-allow-001");
836
+ expect(sync).not.toBeNull();
837
+ } finally {
838
+ delete process.env.LINEAR_ALLOWED_STATES;
839
+ }
840
+ });
841
+
842
+ test("LINEAR_SWARM_READY_LABEL env override is honored end-to-end", async () => {
843
+ process.env.LINEAR_SWARM_READY_LABEL = "go-now";
844
+ try {
845
+ const event = {
846
+ type: "AgentSession",
847
+ action: "create",
848
+ agentSession: {
849
+ id: "session-gate-env-label-001",
850
+ issue: {
851
+ id: "issue-gate-env-label-001",
852
+ identifier: "ENG-511",
853
+ title: "Backlog ticket with custom override label",
854
+ url: "https://linear.app/team/issue/ENG-511",
855
+ state: { type: "backlog" },
856
+ labels: { nodes: [{ name: "go-now" }] },
857
+ },
858
+ },
859
+ };
860
+ await handleAgentSessionEvent(event);
861
+ const sync = getTrackerSyncByExternalId("linear", "task", "issue-gate-env-label-001");
862
+ expect(sync).not.toBeNull();
863
+ } finally {
864
+ delete process.env.LINEAR_SWARM_READY_LABEL;
865
+ }
866
+ });
867
+
868
+ test("creates task when issue is in unstarted (Todo) state", async () => {
869
+ const event = {
870
+ type: "AgentSession",
871
+ action: "create",
872
+ agentSession: {
873
+ id: "session-gate-todo-001",
874
+ issue: {
875
+ id: "issue-gate-todo-001",
876
+ identifier: "ENG-503",
877
+ title: "Ready ticket",
878
+ url: "https://linear.app/team/issue/ENG-503",
879
+ state: { type: "unstarted" },
880
+ labels: [],
881
+ },
882
+ },
883
+ };
884
+
885
+ await handleAgentSessionEvent(event);
886
+
887
+ const sync = getTrackerSyncByExternalId("linear", "task", "issue-gate-todo-001");
888
+ expect(sync).not.toBeNull();
889
+ });
890
+ });
@@ -697,7 +697,7 @@ describe("ValidateExecutor", () => {
697
697
  // ─── Registry Wiring ─────────────────────────────────────────
698
698
 
699
699
  describe("createExecutorRegistry", () => {
700
- test("registers all 9 executors (7 instant + 2 async)", () => {
700
+ test("registers all 10 executors (7 instant + 3 async)", () => {
701
701
  const registry = createExecutorRegistry(mockDeps);
702
702
  const types = registry.types();
703
703
 
@@ -710,7 +710,8 @@ describe("createExecutorRegistry", () => {
710
710
  expect(types).toContain("validate");
711
711
  expect(types).toContain("agent-task");
712
712
  expect(types).toContain("human-in-the-loop");
713
- expect(types).toHaveLength(9);
713
+ expect(types).toContain("wait");
714
+ expect(types).toHaveLength(10);
714
715
  });
715
716
 
716
717
  test("instant executors have mode instant, async executors have mode async", () => {
@@ -729,6 +730,7 @@ describe("createExecutorRegistry", () => {
729
730
  }
730
731
  expect(registry.get("agent-task").mode).toBe("async");
731
732
  expect(registry.get("human-in-the-loop").mode).toBe("async");
733
+ expect(registry.get("wait").mode).toBe("async");
732
734
  });
733
735
 
734
736
  test("get() retrieves the correct executor by type", () => {