@agent-native/core 0.119.5 → 0.119.6

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 (89) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/durable-background.ts +1 -0
  5. package/corpus/core/src/deploy/build.ts +144 -2
  6. package/corpus/core/src/deploy/workspace-deploy.ts +123 -1
  7. package/corpus/core/src/integrations/integration-durable-dispatch-config.ts +22 -0
  8. package/corpus/core/src/integrations/integration-durable-dispatch.ts +190 -0
  9. package/corpus/core/src/integrations/pending-tasks-retry-job.ts +175 -87
  10. package/corpus/core/src/integrations/pending-tasks-store.ts +114 -11
  11. package/corpus/core/src/integrations/plugin.ts +113 -60
  12. package/corpus/core/src/integrations/task-queue-stats.ts +68 -15
  13. package/corpus/core/src/integrations/webhook-handler.ts +21 -46
  14. package/corpus/core/src/server/auth.ts +6 -0
  15. package/corpus/core/src/templates/default/.agents/skills/integration-webhooks/SKILL.md +63 -22
  16. package/corpus/core/src/templates/headless/.agents/skills/integration-webhooks/SKILL.md +63 -22
  17. package/corpus/core/src/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md +63 -22
  18. package/corpus/templates/dispatch/changelog/2026-07-21-recover-stalled-slack-agent-work.md +6 -0
  19. package/dist/agent/durable-background.d.ts +1 -0
  20. package/dist/agent/durable-background.d.ts.map +1 -1
  21. package/dist/agent/durable-background.js +1 -0
  22. package/dist/agent/durable-background.js.map +1 -1
  23. package/dist/collab/routes.d.ts +1 -1
  24. package/dist/collab/struct-routes.d.ts +1 -1
  25. package/dist/deploy/build.d.ts +3 -0
  26. package/dist/deploy/build.d.ts.map +1 -1
  27. package/dist/deploy/build.js +106 -3
  28. package/dist/deploy/build.js.map +1 -1
  29. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  30. package/dist/deploy/workspace-deploy.js +103 -2
  31. package/dist/deploy/workspace-deploy.js.map +1 -1
  32. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  33. package/dist/integrations/integration-durable-dispatch-config.d.ts +7 -0
  34. package/dist/integrations/integration-durable-dispatch-config.d.ts.map +1 -0
  35. package/dist/integrations/integration-durable-dispatch-config.js +16 -0
  36. package/dist/integrations/integration-durable-dispatch-config.js.map +1 -0
  37. package/dist/integrations/integration-durable-dispatch.d.ts +23 -0
  38. package/dist/integrations/integration-durable-dispatch.d.ts.map +1 -0
  39. package/dist/integrations/integration-durable-dispatch.js +115 -0
  40. package/dist/integrations/integration-durable-dispatch.js.map +1 -0
  41. package/dist/integrations/pending-tasks-retry-job.d.ts +13 -1
  42. package/dist/integrations/pending-tasks-retry-job.d.ts.map +1 -1
  43. package/dist/integrations/pending-tasks-retry-job.js +133 -74
  44. package/dist/integrations/pending-tasks-retry-job.js.map +1 -1
  45. package/dist/integrations/pending-tasks-store.d.ts +14 -2
  46. package/dist/integrations/pending-tasks-store.d.ts.map +1 -1
  47. package/dist/integrations/pending-tasks-store.js +75 -11
  48. package/dist/integrations/pending-tasks-store.js.map +1 -1
  49. package/dist/integrations/plugin.d.ts.map +1 -1
  50. package/dist/integrations/plugin.js +88 -55
  51. package/dist/integrations/plugin.js.map +1 -1
  52. package/dist/integrations/task-queue-stats.d.ts +15 -3
  53. package/dist/integrations/task-queue-stats.d.ts.map +1 -1
  54. package/dist/integrations/task-queue-stats.js +45 -15
  55. package/dist/integrations/task-queue-stats.js.map +1 -1
  56. package/dist/integrations/webhook-handler.d.ts +1 -1
  57. package/dist/integrations/webhook-handler.d.ts.map +1 -1
  58. package/dist/integrations/webhook-handler.js +18 -42
  59. package/dist/integrations/webhook-handler.js.map +1 -1
  60. package/dist/notifications/routes.d.ts +3 -3
  61. package/dist/observability/routes.d.ts +5 -5
  62. package/dist/progress/routes.d.ts +1 -1
  63. package/dist/provider-api/actions/custom-provider-registration.d.ts +5 -5
  64. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  65. package/dist/resources/handlers.d.ts +1 -1
  66. package/dist/secrets/routes.d.ts +3 -3
  67. package/dist/server/auth.d.ts.map +1 -1
  68. package/dist/server/auth.js +5 -0
  69. package/dist/server/auth.js.map +1 -1
  70. package/dist/server/realtime-token.d.ts +1 -1
  71. package/dist/server/transcribe-voice.d.ts +1 -1
  72. package/dist/templates/default/.agents/skills/integration-webhooks/SKILL.md +63 -22
  73. package/dist/templates/headless/.agents/skills/integration-webhooks/SKILL.md +63 -22
  74. package/dist/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md +63 -22
  75. package/package.json +1 -1
  76. package/src/agent/durable-background.ts +1 -0
  77. package/src/deploy/build.ts +144 -2
  78. package/src/deploy/workspace-deploy.ts +123 -1
  79. package/src/integrations/integration-durable-dispatch-config.ts +22 -0
  80. package/src/integrations/integration-durable-dispatch.ts +190 -0
  81. package/src/integrations/pending-tasks-retry-job.ts +175 -87
  82. package/src/integrations/pending-tasks-store.ts +114 -11
  83. package/src/integrations/plugin.ts +113 -60
  84. package/src/integrations/task-queue-stats.ts +68 -15
  85. package/src/integrations/webhook-handler.ts +21 -46
  86. package/src/server/auth.ts +6 -0
  87. package/src/templates/default/.agents/skills/integration-webhooks/SKILL.md +63 -22
  88. package/src/templates/headless/.agents/skills/integration-webhooks/SKILL.md +63 -22
  89. package/src/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md +63 -22
@@ -9,6 +9,10 @@ import {
9
9
  import { getRequestHeader } from "h3";
10
10
  import { createRemoteJWKSet, jwtVerify } from "jose";
11
11
 
12
+ import {
13
+ AGENT_BACKGROUND_PROCESSOR_FIELD,
14
+ AGENT_BACKGROUND_PROCESSOR_INTEGRATION,
15
+ } from "../agent/durable-background.js";
12
16
  import { abortRun } from "../agent/run-manager.js";
13
17
  import { getOrgContext, resolveOrgIdForEmail } from "../org/context.js";
14
18
  import { loadResourcesForPrompt } from "../server/agent-chat-plugin.js";
@@ -65,22 +69,27 @@ import {
65
69
  updateIntegrationInstallation,
66
70
  upsertIntegrationInstallation,
67
71
  } from "./installations-store.js";
72
+ import {
73
+ dispatchPendingIntegrationTask,
74
+ INTEGRATION_RETRY_SWEEP_TOKEN_SUBJECT,
75
+ integrationDispatchScopeValue,
76
+ isIntegrationDurableDispatchConfigured,
77
+ } from "./integration-durable-dispatch.js";
68
78
  import {
69
79
  forgetIntegrationMemory,
70
80
  integrationMemoryActions,
71
81
  listIntegrationMemory,
72
82
  rememberForIntegrationScope,
73
83
  } from "./integration-memory.js";
84
+ import { extractBearerToken, verifyInternalToken } from "./internal-token.js";
74
85
  import {
75
- extractBearerToken,
76
- signInternalToken,
77
- verifyInternalToken,
78
- } from "./internal-token.js";
79
- import { startPendingTasksRetryJob } from "./pending-tasks-retry-job.js";
86
+ retryStuckPendingTasks,
87
+ startPendingTasksRetryJob,
88
+ } from "./pending-tasks-retry-job.js";
80
89
  import {
81
90
  claimPendingTask,
82
91
  failTaskDeliveryTransition,
83
- getNextPendingTaskIdForThread,
92
+ getNextPendingTaskForThread,
84
93
  insertPendingTask,
85
94
  isDuplicateEventError,
86
95
  MAX_PENDING_TASK_ATTEMPTS,
@@ -725,40 +734,29 @@ export function createIntegrationsPlugin(
725
734
  externalEventKey: opts?.dedupeKey
726
735
  ? systemNoticeEventKey(opts.dedupeKey, dedupeTtlMs)
727
736
  : undefined,
737
+ dispatchScope: integrationDispatchScopeValue({
738
+ platform: incoming.platform,
739
+ externalThreadId: noticeThreadId,
740
+ platformContext: incoming.platformContext,
741
+ }),
728
742
  });
729
743
  } catch (err) {
730
744
  if (isDuplicateEventError(err)) return;
731
745
  throw err;
732
746
  }
733
747
 
734
- // The SQL row is the durable source of truth. This best-effort self-call
735
- // only reduces latency; the recurring pending-task sweep retries a row
736
- // if the serverless host freezes this webhook execution immediately.
737
- let token: string | undefined;
738
- try {
739
- token = signInternalToken(taskId);
740
- } catch (err) {
741
- if (process.env.NODE_ENV === "production") throw err;
742
- }
743
- void fetch(`${getBaseUrl(event)}${P}/process-task`, {
744
- method: "POST",
745
- headers: {
746
- "Content-Type": "application/json",
747
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
748
+ await dispatchPendingIntegrationTask({
749
+ taskId,
750
+ task: {
751
+ platform: incoming.platform,
752
+ externalThreadId: noticeThreadId,
753
+ platformContext: incoming.platformContext,
748
754
  },
749
- body: JSON.stringify({ taskId }),
750
- }).catch((err) => {
751
- console.warn("[integrations] System notice dispatch failed:", err);
755
+ event,
756
+ baseUrl: getBaseUrl(event),
752
757
  });
753
758
  }
754
759
 
755
- async function requireSession(event: any): Promise<boolean> {
756
- const session = await getSession(event).catch(() => null);
757
- if (session?.email) return true;
758
- setResponseStatus(event, 401);
759
- return false;
760
- }
761
-
762
760
  async function requireSessionContext(
763
761
  event: any,
764
762
  ): Promise<{ ownerEmail: string; orgId: string | null } | null> {
@@ -903,9 +901,10 @@ export function createIntegrationsPlugin(
903
901
  setResponseStatus(event, 405);
904
902
  return { error: "Method not allowed" };
905
903
  }
906
- if (!(await requireSession(event))) return { error: "unauthorized" };
904
+ const scope = await requireSessionContext(event);
905
+ if (!scope) return { error: "unauthorized" };
907
906
  try {
908
- return await getTaskQueueStats();
907
+ return await getTaskQueueStats(scope);
909
908
  } catch (err: any) {
910
909
  setResponseStatus(event, 500);
911
910
  return { error: err?.message ?? String(err) };
@@ -1595,10 +1594,53 @@ export function createIntegrationsPlugin(
1595
1594
  }),
1596
1595
  );
1597
1596
 
1597
+ // ─── Durable pending-task recovery sweep ─────────────────────
1598
+ h3.use(
1599
+ `${P}/retry-stuck-tasks`,
1600
+ defineEventHandler(async (event) => {
1601
+ if (getMethod(event) !== "POST") {
1602
+ setResponseStatus(event, 405);
1603
+ return { error: "Method not allowed" };
1604
+ }
1605
+ const body = (await readBody(event)) as {
1606
+ taskId?: string;
1607
+ [AGENT_BACKGROUND_PROCESSOR_FIELD]?: string;
1608
+ };
1609
+ if (body?.taskId !== INTEGRATION_RETRY_SWEEP_TOKEN_SUBJECT) {
1610
+ setResponseStatus(event, 400);
1611
+ return { error: "invalid sweep subject" };
1612
+ }
1613
+ if (!process.env.A2A_SECRET) {
1614
+ setResponseStatus(event, 503);
1615
+ return { error: "durable integration recovery is not configured" };
1616
+ }
1617
+ const token = extractBearerToken(
1618
+ getRequestHeader(event, "authorization"),
1619
+ );
1620
+ if (
1621
+ !token ||
1622
+ !verifyInternalToken(INTEGRATION_RETRY_SWEEP_TOKEN_SUBJECT, token)
1623
+ ) {
1624
+ setResponseStatus(event, 401);
1625
+ return { error: "Invalid or expired internal token" };
1626
+ }
1627
+ if (!isIntegrationDurableDispatchConfigured()) {
1628
+ return { ok: true, disabled: true };
1629
+ }
1630
+ const result = await retryStuckPendingTasks({
1631
+ webhookBaseUrl: getBaseUrl(event),
1632
+ limit: 20,
1633
+ durableOnly: true,
1634
+ });
1635
+ return { ok: true, ...result };
1636
+ }),
1637
+ );
1638
+
1598
1639
  // ─── Process pending task (cross-platform task queue) ────────
1599
1640
  // POST /_agent-native/integrations/process-task
1600
- // Internal endpoint invoked via fire-and-forget self-webhook from the
1601
- // public webhook handler. Auth: HMAC bearer signed with A2A_SECRET.
1641
+ // Internal endpoint invoked from the public webhook handler through either
1642
+ // the portable self-dispatch path or an acknowledged background handoff.
1643
+ // Auth: HMAC bearer signed with A2A_SECRET.
1602
1644
  // Each invocation runs the agent loop in a fresh function execution.
1603
1645
  h3.use(
1604
1646
  `${P}/process-task`,
@@ -1608,7 +1650,10 @@ export function createIntegrationsPlugin(
1608
1650
  return { error: "Method not allowed" };
1609
1651
  }
1610
1652
 
1611
- const body = (await readBody(event)) as { taskId?: string };
1653
+ const body = (await readBody(event)) as {
1654
+ taskId?: string;
1655
+ [AGENT_BACKGROUND_PROCESSOR_FIELD]?: string;
1656
+ };
1612
1657
  const taskId = body?.taskId;
1613
1658
  if (!taskId) {
1614
1659
  setResponseStatus(event, 400);
@@ -1645,7 +1690,12 @@ export function createIntegrationsPlugin(
1645
1690
  }
1646
1691
 
1647
1692
  // Atomic claim: only one invocation gets to process this task
1648
- const task = await claimPendingTask(taskId);
1693
+ const dispatchOutcome =
1694
+ body[AGENT_BACKGROUND_PROCESSOR_FIELD] ===
1695
+ AGENT_BACKGROUND_PROCESSOR_INTEGRATION
1696
+ ? "background-acknowledged"
1697
+ : "portable-unconfirmed";
1698
+ const task = await claimPendingTask(taskId, { dispatchOutcome });
1649
1699
  if (!task) {
1650
1700
  setResponseStatus(event, 200);
1651
1701
  return { ok: true, skipped: "already-claimed-or-missing" };
@@ -1779,24 +1829,22 @@ export function createIntegrationsPlugin(
1779
1829
  return { ok: true, taskId, retrying: "response-delivery" };
1780
1830
  }
1781
1831
  await markTaskCompleted(taskId);
1782
- const nextTaskId = await getNextPendingTaskIdForThread(
1832
+ const nextTask = await getNextPendingTaskForThread(
1783
1833
  task.platform,
1784
1834
  task.externalThreadId,
1785
1835
  );
1786
- if (nextTaskId) {
1787
- const nextToken = signInternalToken(nextTaskId);
1788
- void fetch(`${getBaseUrl(event)}${P}/process-task`, {
1789
- method: "POST",
1790
- headers: {
1791
- "Content-Type": "application/json",
1792
- ...(nextToken ? { Authorization: `Bearer ${nextToken}` } : {}),
1836
+ if (nextTask) {
1837
+ await dispatchPendingIntegrationTask({
1838
+ taskId: nextTask.id,
1839
+ task: {
1840
+ platform: task.platform,
1841
+ externalThreadId: task.externalThreadId,
1842
+ platformContext: nextTask.dispatchScope
1843
+ ? { channelId: nextTask.dispatchScope }
1844
+ : undefined,
1793
1845
  },
1794
- body: JSON.stringify({ taskId: nextTaskId }),
1795
- }).catch((err) => {
1796
- console.error(
1797
- "[integrations] Failed to dispatch queued thread turn:",
1798
- err,
1799
- );
1846
+ event,
1847
+ baseUrl: getBaseUrl(event),
1800
1848
  });
1801
1849
  }
1802
1850
  await processDueA2AContinuations({
@@ -2018,18 +2066,21 @@ export function createIntegrationsPlugin(
2018
2066
  ownerEmail: control.ownerEmail,
2019
2067
  orgId: control.orgId,
2020
2068
  externalEventKey: `control:${control.id}`,
2069
+ dispatchScope: integrationDispatchScopeValue({
2070
+ platform: incoming.platform,
2071
+ externalThreadId: incoming.externalThreadId,
2072
+ platformContext: incoming.platformContext,
2073
+ }),
2021
2074
  });
2022
- const processUrl = `${getBaseUrl(event)}${P}/process-task`;
2023
- const token = signInternalToken(taskId);
2024
- void fetch(processUrl, {
2025
- method: "POST",
2026
- headers: {
2027
- "Content-Type": "application/json",
2028
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
2075
+ await dispatchPendingIntegrationTask({
2076
+ taskId,
2077
+ task: {
2078
+ platform: incoming.platform,
2079
+ externalThreadId: incoming.externalThreadId,
2080
+ platformContext: incoming.platformContext,
2029
2081
  },
2030
- body: JSON.stringify({ taskId }),
2031
- }).catch((err) => {
2032
- console.error("[slack] Approval dispatch failed:", err);
2082
+ event,
2083
+ baseUrl: getBaseUrl(event),
2033
2084
  });
2034
2085
  } catch (err) {
2035
2086
  console.error("[slack] Interaction handling failed:", err);
@@ -2480,6 +2531,8 @@ export function createIntegrationsPlugin(
2480
2531
  if (parts[0] === "remote") return;
2481
2532
  // Already handled by the dedicated /process-task route above
2482
2533
  if (parts[0] === "process-task") return;
2534
+ // Already handled by the signed durable recovery route above
2535
+ if (parts[0] === "retry-stuck-tasks") return;
2483
2536
  // Already handled by the dedicated /process-a2a-continuation route above
2484
2537
  if (parts[0] === "process-a2a-continuation") return;
2485
2538
  // These are framework-owned control-plane routes, not integration
@@ -3,11 +3,12 @@
3
3
  *
4
4
  * Lives in its own file so it stays out of `pending-tasks-store.ts`, which is
5
5
  * actively being edited by the agent that owns the queue itself. These
6
- * helpers only SELECT they never write and they degrade gracefully if
7
- * the `integration_pending_tasks` table doesn't exist yet (returning zeroed
8
- * stats instead of throwing).
6
+ * Queue reads never expose payloads or user text. The helper first runs the
7
+ * additive schema guard so older deployments gain the dispatch diagnostic
8
+ * columns before the SELECTs execute.
9
9
  */
10
10
  import { getDbExec } from "../db/client.js";
11
+ import { ensurePendingTasksTable } from "./pending-tasks-store.js";
11
12
 
12
13
  export interface RecentFailure {
13
14
  id: string;
@@ -23,6 +24,20 @@ export interface TaskQueueStats {
23
24
  failed_last_hour: number;
24
25
  oldest_pending_age_seconds: number;
25
26
  recent_failures: RecentFailure[];
27
+ recent_tasks: Array<{
28
+ id: string;
29
+ platform: string;
30
+ status: string;
31
+ attempts: number;
32
+ dispatch_attempts: number;
33
+ last_dispatch_outcome: string | null;
34
+ age_seconds: number;
35
+ }>;
36
+ }
37
+
38
+ export interface TaskQueueStatsScope {
39
+ ownerEmail: string;
40
+ orgId: string | null;
26
41
  }
27
42
 
28
43
  const ZERO_STATS: TaskQueueStats = {
@@ -32,6 +47,7 @@ const ZERO_STATS: TaskQueueStats = {
32
47
  failed_last_hour: 0,
33
48
  oldest_pending_age_seconds: 0,
34
49
  recent_failures: [],
50
+ recent_tasks: [],
35
51
  };
36
52
 
37
53
  function isMissingTableError(err: unknown): boolean {
@@ -44,21 +60,27 @@ function isMissingTableError(err: unknown): boolean {
44
60
  /**
45
61
  * Get a snapshot of the integration task queue health.
46
62
  *
47
- * Returns zeros if the table doesn't exist yet safe to call before the
48
- * pending-tasks store has initialised the schema.
63
+ * Safe to call before the pending-tasks store has initialized the schema.
49
64
  */
50
- export async function getTaskQueueStats(): Promise<TaskQueueStats> {
65
+ export async function getTaskQueueStats(
66
+ scope: TaskQueueStatsScope,
67
+ ): Promise<TaskQueueStats> {
68
+ await ensurePendingTasksTable();
51
69
  const client = getDbExec();
52
70
  const now = Date.now();
53
71
  const oneHourAgo = now - 60 * 60 * 1000;
72
+ const scopeSql = `owner_email = ?
73
+ AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)`;
74
+ const scopeArgs = [scope.ownerEmail, scope.orgId, scope.orgId];
54
75
 
55
76
  try {
56
77
  // Status counts (pending, processing) — only need the live ones.
57
78
  const liveCounts = await client.execute({
58
79
  sql: `SELECT status, COUNT(*) AS c FROM integration_pending_tasks
59
- WHERE status IN ('pending', 'processing')
80
+ WHERE ${scopeSql}
81
+ AND status IN ('pending', 'processing')
60
82
  GROUP BY status`,
61
- args: [],
83
+ args: scopeArgs,
62
84
  });
63
85
 
64
86
  let pending = 0;
@@ -75,9 +97,10 @@ export async function getTaskQueueStats(): Promise<TaskQueueStats> {
75
97
  // miss tasks queued >1h ago that just finished now.
76
98
  const lastHourCounts = await client.execute({
77
99
  sql: `SELECT status, COUNT(*) AS c FROM integration_pending_tasks
78
- WHERE status IN ('completed', 'failed') AND updated_at >= ?
100
+ WHERE ${scopeSql}
101
+ AND status IN ('completed', 'failed') AND updated_at >= ?
79
102
  GROUP BY status`,
80
- args: [oneHourAgo],
103
+ args: [...scopeArgs, oneHourAgo],
81
104
  });
82
105
 
83
106
  let completedLastHour = 0;
@@ -94,10 +117,11 @@ export async function getTaskQueueStats(): Promise<TaskQueueStats> {
94
117
  if (pending > 0) {
95
118
  const oldest = await client.execute({
96
119
  sql: `SELECT created_at FROM integration_pending_tasks
97
- WHERE status = 'pending'
120
+ WHERE ${scopeSql}
121
+ AND status = 'pending'
98
122
  ORDER BY created_at ASC
99
123
  LIMIT 1`,
100
- args: [],
124
+ args: scopeArgs,
101
125
  });
102
126
  const oldestRow = oldest.rows[0] as Record<string, unknown> | undefined;
103
127
  if (oldestRow) {
@@ -113,10 +137,11 @@ export async function getTaskQueueStats(): Promise<TaskQueueStats> {
113
137
  // blowing up the response payload.
114
138
  const failures = await client.execute({
115
139
  sql: `SELECT id, platform, error_message, attempts FROM integration_pending_tasks
116
- WHERE status = 'failed' AND updated_at >= ?
140
+ WHERE ${scopeSql}
141
+ AND status = 'failed' AND updated_at >= ?
117
142
  ORDER BY updated_at DESC
118
143
  LIMIT 5`,
119
- args: [oneHourAgo],
144
+ args: [...scopeArgs, oneHourAgo],
120
145
  });
121
146
  const recentFailures: RecentFailure[] = (
122
147
  failures.rows as Array<Record<string, unknown>>
@@ -127,6 +152,33 @@ export async function getTaskQueueStats(): Promise<TaskQueueStats> {
127
152
  attempts: Number(row.attempts ?? 0),
128
153
  }));
129
154
 
155
+ const recent = await client.execute({
156
+ sql: `SELECT id, platform, status, attempts, dispatch_attempts,
157
+ last_dispatch_outcome, created_at
158
+ FROM integration_pending_tasks
159
+ WHERE ${scopeSql}
160
+ ORDER BY created_at DESC
161
+ LIMIT 10`,
162
+ args: scopeArgs,
163
+ });
164
+ const recentTasks = (recent.rows as Array<Record<string, unknown>>).map(
165
+ (row) => ({
166
+ id: String(row.id ?? ""),
167
+ platform: String(row.platform ?? ""),
168
+ status: String(row.status ?? ""),
169
+ attempts: Number(row.attempts ?? 0),
170
+ dispatch_attempts: Number(row.dispatch_attempts ?? 0),
171
+ last_dispatch_outcome:
172
+ row.last_dispatch_outcome == null
173
+ ? null
174
+ : String(row.last_dispatch_outcome),
175
+ age_seconds: Math.max(
176
+ 0,
177
+ Math.floor((now - Number(row.created_at ?? now)) / 1000),
178
+ ),
179
+ }),
180
+ );
181
+
130
182
  return {
131
183
  pending,
132
184
  processing,
@@ -134,10 +186,11 @@ export async function getTaskQueueStats(): Promise<TaskQueueStats> {
134
186
  failed_last_hour: failedLastHour,
135
187
  oldest_pending_age_seconds: oldestPendingAgeSeconds,
136
188
  recent_failures: recentFailures,
189
+ recent_tasks: recentTasks,
137
190
  };
138
191
  } catch (err) {
139
192
  if (isMissingTableError(err)) {
140
- return { ...ZERO_STATS, recent_failures: [] };
193
+ return { ...ZERO_STATS, recent_failures: [], recent_tasks: [] };
141
194
  }
142
195
  throw err;
143
196
  }
@@ -46,7 +46,6 @@ import { updateThreadData } from "../chat-threads/store.js";
46
46
  import { isLocalDatabase } from "../db/client.js";
47
47
  import { getOrgA2ASecret, resolveOrgIdForEmail } from "../org/context.js";
48
48
  import { withConfiguredAppBasePath } from "../server/app-base-path.js";
49
- import { FRAMEWORK_ROUTE_PREFIX } from "../server/core-routes-plugin.js";
50
49
  import {
51
50
  canUseDeployCredentialFallbackForRequest,
52
51
  readDeployCredentialEnv,
@@ -58,8 +57,11 @@ import {
58
57
  clearIntegrationAwaitingInput,
59
58
  setIntegrationAwaitingInput,
60
59
  } from "./awaiting-input-store.js";
60
+ import {
61
+ dispatchPendingIntegrationTask,
62
+ integrationDispatchScopeValue,
63
+ } from "./integration-durable-dispatch.js";
61
64
  import { loadIntegrationMemoryPrompt } from "./integration-memory.js";
62
- import { signInternalToken } from "./internal-token.js";
63
65
  import {
64
66
  insertPendingTask,
65
67
  isDuplicateEventError,
@@ -307,7 +309,7 @@ export async function resolveIntegrationApiKey(
307
309
  * 2. Verify webhook signature
308
310
  * 3. Parse incoming message (null = ignored event)
309
311
  * 4. Persist task to SQL
310
- * 5. Fire-and-forget POST to /_agent-native/integrations/process-task
312
+ * 5. Dispatch the queued task through the configured processor handoff
311
313
  * (a fresh function execution with its own timeout budget)
312
314
  * 6. Return HTTP 200 immediately (within Slack's 3s SLA)
313
315
  *
@@ -477,48 +479,14 @@ async function enqueueAndDispatch(
477
479
  // platform produce the same key, so the unique index rejects the
478
480
  // second insert (H3 in the webhook security audit).
479
481
  externalEventKey: buildEventDedupKey(incoming),
482
+ dispatchScope: integrationDispatchScopeValue({
483
+ platform: incoming.platform,
484
+ externalThreadId: incoming.externalThreadId,
485
+ platformContext: incoming.platformContext,
486
+ }),
480
487
  });
481
488
 
482
489
  const baseUrl = resolveBaseUrl(event);
483
- const processUrl = `${baseUrl}${FRAMEWORK_ROUTE_PREFIX}/integrations/process-task`;
484
-
485
- // Sign the dispatch with an HMAC token so the processor endpoint can
486
- // verify the request came from us and not the public internet. The
487
- // processor refuses unsigned requests in production (C3 in the webhook
488
- // security audit). In dev, dispatching unsigned is allowed and falls
489
- // through to the SQL atomic claim for double-processing protection.
490
- const headers: Record<string, string> = {
491
- "Content-Type": "application/json",
492
- };
493
- try {
494
- headers["Authorization"] = `Bearer ${signInternalToken(taskId)}`;
495
- } catch (err) {
496
- // Distinguish "secret not configured" (the documented dev path) from
497
- // a real signing failure — silently swallowing both made malformed
498
- // secrets fail invisibly (L5 in the audit).
499
- if (err instanceof Error && !/A2A_SECRET/i.test(err.message)) {
500
- console.error(
501
- `[integrations] signInternalToken failed unexpectedly for ${taskId}:`,
502
- err,
503
- );
504
- }
505
- }
506
-
507
- // Fire-and-forget: do NOT await the full response (the processor's run
508
- // takes minutes — we don't want to block the caller). BUT on Netlify
509
- // Lambda, when we return immediately, the runtime can freeze the function
510
- // before the outbound TCP handshake even starts, which leaves the dispatch
511
- // request stuck waiting for the 60s retry-sweep job. Race the fetch
512
- // against a short timer so the request gets a reasonable chance to leave
513
- // the box; the trade-off is at most a couple seconds of added webhook
514
- // latency, still inside Slack's timeout window.
515
- const dispatchPromise = fetch(processUrl, {
516
- method: "POST",
517
- headers,
518
- body: JSON.stringify({ taskId }),
519
- }).catch((err) => {
520
- console.error("[integrations] Failed to dispatch processor request:", err);
521
- });
522
490
  const settleWaitMs = options.adapter.capabilities?.deferredWebhookResponse
523
491
  ? Math.min(
524
492
  DEFERRED_RESPONSE_DISPATCH_SETTLE_WAIT_MS,
@@ -528,10 +496,17 @@ async function enqueueAndDispatch(
528
496
  ),
529
497
  )
530
498
  : PROCESSOR_DISPATCH_SETTLE_WAIT_MS;
531
- await Promise.race([
532
- dispatchPromise,
533
- new Promise<void>((resolve) => setTimeout(resolve, settleWaitMs)),
534
- ]);
499
+ await dispatchPendingIntegrationTask({
500
+ taskId,
501
+ task: {
502
+ platform: incoming.platform,
503
+ externalThreadId: incoming.externalThreadId,
504
+ platformContext: incoming.platformContext,
505
+ },
506
+ event,
507
+ baseUrl,
508
+ portableSettleMs: settleWaitMs,
509
+ });
535
510
  }
536
511
 
537
512
  /**
@@ -1740,6 +1740,12 @@ function createAuthGuardFn(): (
1740
1740
  return;
1741
1741
  }
1742
1742
 
1743
+ // External durable-recovery scheduler. The route verifies a short-lived
1744
+ // HMAC token bound to its fixed sweep subject before touching the queue.
1745
+ if (p === "/_agent-native/integrations/retry-stuck-tasks") {
1746
+ return;
1747
+ }
1748
+
1743
1749
  // Internal processor endpoint for deferred A2A continuations created by
1744
1750
  // integration tasks. It uses the same HMAC internal-token scheme as the
1745
1751
  // primary integration processor, so it must bypass cookie/session auth.