@agent-native/core 0.119.5 → 0.120.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 (118) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +18 -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/cli/index.ts +64 -3
  6. package/corpus/core/src/deploy/build.ts +144 -2
  7. package/corpus/core/src/deploy/workspace-deploy.ts +123 -1
  8. package/corpus/core/src/integrations/integration-durable-dispatch-config.ts +22 -0
  9. package/corpus/core/src/integrations/integration-durable-dispatch.ts +190 -0
  10. package/corpus/core/src/integrations/pending-tasks-retry-job.ts +175 -87
  11. package/corpus/core/src/integrations/pending-tasks-store.ts +114 -11
  12. package/corpus/core/src/integrations/plugin.ts +113 -60
  13. package/corpus/core/src/integrations/task-queue-stats.ts +68 -15
  14. package/corpus/core/src/integrations/webhook-handler.ts +21 -46
  15. package/corpus/core/src/server/auth.ts +6 -0
  16. package/corpus/core/src/templates/default/.agents/skills/integration-webhooks/SKILL.md +63 -22
  17. package/corpus/core/src/templates/default/package.json +1 -1
  18. package/corpus/core/src/templates/headless/.agents/skills/integration-webhooks/SKILL.md +63 -22
  19. package/corpus/core/src/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md +63 -22
  20. package/corpus/templates/assets/package.json +1 -1
  21. package/corpus/templates/brain/package.json +1 -1
  22. package/corpus/templates/calendar/package.json +1 -1
  23. package/corpus/templates/clips/package.json +1 -1
  24. package/corpus/templates/content/actions/add-database-item.ts +1 -0
  25. package/corpus/templates/content/actions/set-document-property.ts +0 -4
  26. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +10 -1
  27. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +10 -4
  28. package/corpus/templates/content/app/components/editor/DocumentInfoPanel.tsx +5 -1
  29. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +6 -0
  30. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +52 -33
  31. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +16 -4
  32. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +16 -7
  33. package/corpus/templates/content/app/hooks/use-create-page.ts +8 -3
  34. package/corpus/templates/content/app/hooks/use-document-properties.ts +1 -0
  35. package/corpus/templates/content/app/hooks/use-documents.ts +3 -1
  36. package/corpus/templates/content/app/lib/optimistic-document.ts +29 -0
  37. package/corpus/templates/content/changelog/2026-07-21-editing-a-database-property-no-longer-refreshes-unrelated-co.md +6 -0
  38. package/corpus/templates/content/changelog/2026-07-23-new-database-ready.md +6 -0
  39. package/corpus/templates/content/package.json +1 -1
  40. package/corpus/templates/content/shared/api.ts +1 -0
  41. package/corpus/templates/design/package.json +1 -1
  42. package/corpus/templates/dispatch/changelog/2026-07-21-recover-stalled-slack-agent-work.md +6 -0
  43. package/corpus/templates/dispatch/package.json +1 -1
  44. package/corpus/templates/forms/package.json +1 -1
  45. package/corpus/templates/macros/package.json +1 -1
  46. package/corpus/templates/mail/package.json +1 -1
  47. package/corpus/templates/slides/package.json +1 -1
  48. package/dist/agent/durable-background.d.ts +1 -0
  49. package/dist/agent/durable-background.d.ts.map +1 -1
  50. package/dist/agent/durable-background.js +1 -0
  51. package/dist/agent/durable-background.js.map +1 -1
  52. package/dist/cli/index.js +53 -2
  53. package/dist/cli/index.js.map +1 -1
  54. package/dist/collab/routes.d.ts +1 -1
  55. package/dist/deploy/build.d.ts +3 -0
  56. package/dist/deploy/build.d.ts.map +1 -1
  57. package/dist/deploy/build.js +106 -3
  58. package/dist/deploy/build.js.map +1 -1
  59. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  60. package/dist/deploy/workspace-deploy.js +103 -2
  61. package/dist/deploy/workspace-deploy.js.map +1 -1
  62. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  63. package/dist/integrations/integration-durable-dispatch-config.d.ts +7 -0
  64. package/dist/integrations/integration-durable-dispatch-config.d.ts.map +1 -0
  65. package/dist/integrations/integration-durable-dispatch-config.js +16 -0
  66. package/dist/integrations/integration-durable-dispatch-config.js.map +1 -0
  67. package/dist/integrations/integration-durable-dispatch.d.ts +23 -0
  68. package/dist/integrations/integration-durable-dispatch.d.ts.map +1 -0
  69. package/dist/integrations/integration-durable-dispatch.js +115 -0
  70. package/dist/integrations/integration-durable-dispatch.js.map +1 -0
  71. package/dist/integrations/pending-tasks-retry-job.d.ts +13 -1
  72. package/dist/integrations/pending-tasks-retry-job.d.ts.map +1 -1
  73. package/dist/integrations/pending-tasks-retry-job.js +133 -74
  74. package/dist/integrations/pending-tasks-retry-job.js.map +1 -1
  75. package/dist/integrations/pending-tasks-store.d.ts +14 -2
  76. package/dist/integrations/pending-tasks-store.d.ts.map +1 -1
  77. package/dist/integrations/pending-tasks-store.js +75 -11
  78. package/dist/integrations/pending-tasks-store.js.map +1 -1
  79. package/dist/integrations/plugin.d.ts.map +1 -1
  80. package/dist/integrations/plugin.js +88 -55
  81. package/dist/integrations/plugin.js.map +1 -1
  82. package/dist/integrations/task-queue-stats.d.ts +15 -3
  83. package/dist/integrations/task-queue-stats.d.ts.map +1 -1
  84. package/dist/integrations/task-queue-stats.js +45 -15
  85. package/dist/integrations/task-queue-stats.js.map +1 -1
  86. package/dist/integrations/webhook-handler.d.ts +1 -1
  87. package/dist/integrations/webhook-handler.d.ts.map +1 -1
  88. package/dist/integrations/webhook-handler.js +18 -42
  89. package/dist/integrations/webhook-handler.js.map +1 -1
  90. package/dist/observability/routes.d.ts +6 -6
  91. package/dist/provider-api/actions/custom-provider-registration.d.ts +5 -5
  92. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  93. package/dist/resources/handlers.d.ts +1 -1
  94. package/dist/server/auth.d.ts.map +1 -1
  95. package/dist/server/auth.js +5 -0
  96. package/dist/server/auth.js.map +1 -1
  97. package/dist/server/transcribe-voice.d.ts +1 -1
  98. package/dist/templates/default/.agents/skills/integration-webhooks/SKILL.md +63 -22
  99. package/dist/templates/default/package.json +1 -1
  100. package/dist/templates/headless/.agents/skills/integration-webhooks/SKILL.md +63 -22
  101. package/dist/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md +63 -22
  102. package/package.json +1 -1
  103. package/src/agent/durable-background.ts +1 -0
  104. package/src/cli/index.ts +64 -3
  105. package/src/deploy/build.ts +144 -2
  106. package/src/deploy/workspace-deploy.ts +123 -1
  107. package/src/integrations/integration-durable-dispatch-config.ts +22 -0
  108. package/src/integrations/integration-durable-dispatch.ts +190 -0
  109. package/src/integrations/pending-tasks-retry-job.ts +175 -87
  110. package/src/integrations/pending-tasks-store.ts +114 -11
  111. package/src/integrations/plugin.ts +113 -60
  112. package/src/integrations/task-queue-stats.ts +68 -15
  113. package/src/integrations/webhook-handler.ts +21 -46
  114. package/src/server/auth.ts +6 -0
  115. package/src/templates/default/.agents/skills/integration-webhooks/SKILL.md +63 -22
  116. package/src/templates/default/package.json +1 -1
  117. package/src/templates/headless/.agents/skills/integration-webhooks/SKILL.md +63 -22
  118. package/src/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md +63 -22
@@ -0,0 +1,190 @@
1
+ import { hasConfiguredA2ASecret } from "../a2a/auth-policy.js";
2
+ import {
3
+ AGENT_BACKGROUND_PROCESSOR_FIELD,
4
+ AGENT_BACKGROUND_PROCESSOR_INTEGRATION,
5
+ dispatchPathTargetsNetlifyBackgroundFunction,
6
+ resolveDurableBackgroundDispatchPath,
7
+ } from "../agent/durable-background.js";
8
+ import { fireInternalDispatch } from "../server/self-dispatch.js";
9
+ import {
10
+ INTEGRATION_DURABLE_DISPATCH_ENV,
11
+ INTEGRATION_DURABLE_DISPATCH_SCOPES_ENV,
12
+ INTEGRATION_PROCESS_TASK_PATH,
13
+ INTEGRATION_RETRY_SWEEP_PATH,
14
+ INTEGRATION_RETRY_SWEEP_TOKEN_SUBJECT,
15
+ isIntegrationDurableDispatchConfigured,
16
+ } from "./integration-durable-dispatch-config.js";
17
+ import { recordPendingTaskDispatchAttempt } from "./pending-tasks-store.js";
18
+
19
+ export {
20
+ INTEGRATION_DURABLE_DISPATCH_ENV,
21
+ INTEGRATION_DURABLE_DISPATCH_SCOPES_ENV,
22
+ INTEGRATION_PROCESS_TASK_PATH,
23
+ INTEGRATION_RETRY_SWEEP_PATH,
24
+ INTEGRATION_RETRY_SWEEP_TOKEN_SUBJECT,
25
+ isIntegrationDurableDispatchConfigured,
26
+ };
27
+
28
+ export type IntegrationDispatchOutcome =
29
+ | "background-acknowledged"
30
+ | "portable-unconfirmed"
31
+ | "failed";
32
+
33
+ export interface IntegrationDispatchTaskScope {
34
+ platform: string;
35
+ externalThreadId: string;
36
+ platformContext?: Record<string, unknown>;
37
+ }
38
+
39
+ export interface IntegrationDurableDispatchScope {
40
+ platform: string;
41
+ value: string;
42
+ }
43
+
44
+ export function integrationDispatchScopeValue(
45
+ task: IntegrationDispatchTaskScope,
46
+ ): string | null {
47
+ const value = task.platformContext?.channelId;
48
+ return typeof value === "string" && value.trim() ? value.trim() : null;
49
+ }
50
+
51
+ export function configuredIntegrationDurableDispatchScopes():
52
+ | IntegrationDurableDispatchScope[]
53
+ | null {
54
+ const raw = process.env.AGENT_INTEGRATION_DURABLE_DISPATCH_SCOPES;
55
+ if (!raw?.trim()) return null;
56
+ return raw
57
+ .split(",")
58
+ .map((value) => value.trim())
59
+ .filter(Boolean)
60
+ .map((scope) => {
61
+ const separator = scope.indexOf(":");
62
+ return {
63
+ platform: separator > 0 ? scope.slice(0, separator) : "",
64
+ value: separator > 0 ? scope.slice(separator + 1) : "",
65
+ };
66
+ })
67
+ .filter((scope) => scope.platform && scope.value);
68
+ }
69
+
70
+ function taskScopeCandidates(task: IntegrationDispatchTaskScope): string[] {
71
+ const candidates = [
72
+ `${task.platform}:*`,
73
+ `${task.platform}:${task.externalThreadId}`,
74
+ ];
75
+ const explicitScope = integrationDispatchScopeValue(task);
76
+ if (explicitScope) {
77
+ candidates.push(`${task.platform}:${explicitScope}`);
78
+ } else if (task.platform === "slack") {
79
+ const channelId = task.externalThreadId.split(":")[2];
80
+ if (channelId) candidates.push(`slack:${channelId}`);
81
+ }
82
+ return candidates;
83
+ }
84
+
85
+ export function isIntegrationDurableDispatchEnabledForTask(
86
+ task: IntegrationDispatchTaskScope,
87
+ ): boolean {
88
+ if (!isIntegrationDurableDispatchConfigured()) return false;
89
+ const scopes = configuredIntegrationDurableDispatchScopes();
90
+ if (
91
+ scopes &&
92
+ !taskScopeCandidates(task).some((candidate) =>
93
+ scopes.some((scope) => `${scope.platform}:${scope.value}` === candidate),
94
+ )
95
+ ) {
96
+ return false;
97
+ }
98
+ const path = resolveDurableBackgroundDispatchPath(
99
+ INTEGRATION_PROCESS_TASK_PATH,
100
+ );
101
+ return (
102
+ dispatchPathTargetsNetlifyBackgroundFunction(path) &&
103
+ hasConfiguredA2ASecret()
104
+ );
105
+ }
106
+
107
+ async function recordDispatch(
108
+ taskId: string,
109
+ outcome: IntegrationDispatchOutcome,
110
+ ): Promise<void> {
111
+ try {
112
+ await recordPendingTaskDispatchAttempt(taskId, outcome);
113
+ } catch (error) {
114
+ console.error(
115
+ `[integrations] Failed to record dispatch outcome for ${taskId}:`,
116
+ error,
117
+ );
118
+ }
119
+ }
120
+
121
+ function logDispatch(
122
+ taskId: string,
123
+ outcome: IntegrationDispatchOutcome,
124
+ startedAt: number,
125
+ ): void {
126
+ console.info("[integrations] pending task dispatch", {
127
+ taskId,
128
+ outcome,
129
+ durationMs: Date.now() - startedAt,
130
+ });
131
+ }
132
+
133
+ export async function dispatchPendingIntegrationTask(input: {
134
+ taskId: string;
135
+ task: IntegrationDispatchTaskScope;
136
+ event?: unknown;
137
+ baseUrl?: string;
138
+ portableSettleMs?: number;
139
+ }): Promise<IntegrationDispatchOutcome> {
140
+ const startedAt = Date.now();
141
+ const durable = isIntegrationDurableDispatchEnabledForTask(input.task);
142
+ if (durable) {
143
+ const backgroundPath = resolveDurableBackgroundDispatchPath(
144
+ INTEGRATION_PROCESS_TASK_PATH,
145
+ );
146
+ try {
147
+ await fireInternalDispatch({
148
+ event: input.event,
149
+ baseUrl: input.baseUrl,
150
+ path: backgroundPath,
151
+ taskId: input.taskId,
152
+ body: {
153
+ [AGENT_BACKGROUND_PROCESSOR_FIELD]:
154
+ AGENT_BACKGROUND_PROCESSOR_INTEGRATION,
155
+ },
156
+ awaitResponse: true,
157
+ responseTimeoutMs: 2_000,
158
+ });
159
+ await recordDispatch(input.taskId, "background-acknowledged");
160
+ logDispatch(input.taskId, "background-acknowledged", startedAt);
161
+ return "background-acknowledged";
162
+ } catch (error) {
163
+ console.error(
164
+ `[integrations] Background dispatch failed for ${input.taskId}; trying the portable processor:`,
165
+ error,
166
+ );
167
+ }
168
+ }
169
+
170
+ try {
171
+ await fireInternalDispatch({
172
+ event: input.event,
173
+ baseUrl: input.baseUrl,
174
+ path: INTEGRATION_PROCESS_TASK_PATH,
175
+ taskId: input.taskId,
176
+ settleMs: input.portableSettleMs,
177
+ });
178
+ await recordDispatch(input.taskId, "portable-unconfirmed");
179
+ logDispatch(input.taskId, "portable-unconfirmed", startedAt);
180
+ return "portable-unconfirmed";
181
+ } catch (error) {
182
+ console.error(
183
+ `[integrations] Portable dispatch failed for ${input.taskId}:`,
184
+ error,
185
+ );
186
+ await recordDispatch(input.taskId, "failed");
187
+ logDispatch(input.taskId, "failed", startedAt);
188
+ return "failed";
189
+ }
190
+ }
@@ -1,8 +1,13 @@
1
1
  import { getDbExec } from "../db/client.js";
2
- import { withConfiguredAppBasePath } from "../server/app-base-path.js";
3
- import { FRAMEWORK_ROUTE_PREFIX } from "../server/core-routes-plugin.js";
4
- import { signInternalToken } from "./internal-token.js";
5
- import { MAX_PENDING_TASK_ATTEMPTS } from "./pending-tasks-store.js";
2
+ import {
3
+ configuredIntegrationDurableDispatchScopes,
4
+ dispatchPendingIntegrationTask,
5
+ isIntegrationDurableDispatchEnabledForTask,
6
+ } from "./integration-durable-dispatch.js";
7
+ import {
8
+ ensurePendingTasksTable,
9
+ MAX_PENDING_TASK_ATTEMPTS,
10
+ } from "./pending-tasks-store.js";
6
11
 
7
12
  /**
8
13
  * Retries stuck integration webhook tasks.
@@ -10,12 +15,16 @@ import { MAX_PENDING_TASK_ATTEMPTS } from "./pending-tasks-store.js";
10
15
  * The integration webhook flow enqueues work into `integration_pending_tasks`
11
16
  * (see `pending-tasks-store.ts`) and then fires a self-webhook to the
12
17
  * `/_agent-native/integrations/process-task` endpoint to drain the queue.
13
- * If that fire-and-forget dispatch fails (e.g. transient network blip), the
18
+ * If that initial dispatch is lost (e.g. transient network blip), the
14
19
  * row stays in `pending` forever. Likewise, if the processor is killed mid-
15
20
  * processing (function timeout, container shutdown), a row can remain in
16
21
  * `processing` forever.
17
22
  *
18
- * This job runs every 60s and re-fires the processor endpoint for tasks that
23
+ * The in-process fallback runs every 60s. Durable deployments also invoke the
24
+ * same bounded sweep from an external scheduler so recovery does not depend on
25
+ * a serverless process remaining alive.
26
+ *
27
+ * Each sweep re-fires the processor endpoint for tasks that
19
28
  * look stuck:
20
29
  * - status='pending' AND created_at older than 90s (initial dispatch lost)
21
30
  * - status='processing' AND updated_at older than the host-specific
@@ -35,8 +44,9 @@ const PENDING_STUCK_AFTER_MS = 90_000;
35
44
  /** Tasks "processing" longer than this are considered killed mid-flight. */
36
45
  const DEFAULT_PROCESSING_STUCK_AFTER_MS = 5 * 60 * 1000;
37
46
  const SERVERLESS_PROCESSING_STUCK_AFTER_MS = 75_000;
47
+ const DURABLE_BACKGROUND_PROCESSING_STUCK_AFTER_MS = 16 * 60 * 1000;
38
48
  /** After this many attempts we give up and mark the task failed */
39
- const PROCESSOR_PATH = `${FRAMEWORK_ROUTE_PREFIX}/integrations/process-task`;
49
+ const DEFAULT_SWEEP_LIMIT = 100;
40
50
 
41
51
  let retryInterval: ReturnType<typeof setInterval> | null = null;
42
52
  let initialTimer: ReturnType<typeof setTimeout> | null = null;
@@ -49,8 +59,74 @@ let tableExists: boolean | null = null;
49
59
 
50
60
  interface StuckTaskRow {
51
61
  id: string;
62
+ platform: string;
63
+ externalThreadId: string;
52
64
  status: string;
53
65
  attempts: number;
66
+ updatedAt: number;
67
+ dispatchScope: string | null;
68
+ }
69
+
70
+ export interface PendingTasksSweepResult {
71
+ selected: number;
72
+ dispatched: number;
73
+ markedFailed: number;
74
+ skipped: number;
75
+ dispatchFailed: number;
76
+ }
77
+
78
+ export interface PendingTasksSweepOptions {
79
+ webhookBaseUrl?: string;
80
+ limit?: number;
81
+ durableOnly?: boolean;
82
+ }
83
+
84
+ function affectedRows(result: unknown): number {
85
+ return Number(
86
+ (result as { rowsAffected?: number }).rowsAffected ??
87
+ (result as { rowCount?: number }).rowCount ??
88
+ 0,
89
+ );
90
+ }
91
+
92
+ function isMissingPendingTasksTableError(error: unknown): boolean {
93
+ const message = error instanceof Error ? error.message : String(error ?? "");
94
+ return /no such table|relation .* does not exist|undefined_table/i.test(
95
+ message,
96
+ );
97
+ }
98
+
99
+ function durableScopeSql(): { clause: string; args: string[] } {
100
+ const scopes = configuredIntegrationDurableDispatchScopes();
101
+ if (scopes === null) return { clause: "", args: [] };
102
+ if (scopes.length === 0) return { clause: " AND 1 = 0", args: [] };
103
+
104
+ const clauses: string[] = [];
105
+ const args: string[] = [];
106
+ for (const scope of scopes) {
107
+ if (scope.value === "*") {
108
+ clauses.push("platform = ?");
109
+ args.push(scope.platform);
110
+ continue;
111
+ }
112
+ if (scope.platform === "slack" && /^[A-Za-z0-9]+$/.test(scope.value)) {
113
+ clauses.push(
114
+ "(platform = ? AND (external_thread_id = ? OR dispatch_scope = ? OR external_thread_id LIKE ?))",
115
+ );
116
+ args.push(
117
+ scope.platform,
118
+ scope.value,
119
+ scope.value,
120
+ `%:%:${scope.value}:%`,
121
+ );
122
+ continue;
123
+ }
124
+ clauses.push(
125
+ "(platform = ? AND (external_thread_id = ? OR dispatch_scope = ?))",
126
+ );
127
+ args.push(scope.platform, scope.value, scope.value);
128
+ }
129
+ return { clause: ` AND (${clauses.join(" OR ")})`, args };
54
130
  }
55
131
 
56
132
  /**
@@ -58,39 +134,76 @@ interface StuckTaskRow {
58
134
  * Exported for tests and for manual triggers.
59
135
  */
60
136
  export async function retryStuckPendingTasks(
61
- webhookBaseUrl?: string,
62
- ): Promise<void> {
63
- const baseUrl = webhookBaseUrl ?? activeWebhookBaseUrl;
137
+ input?: string | PendingTasksSweepOptions,
138
+ ): Promise<PendingTasksSweepResult> {
139
+ const options: PendingTasksSweepOptions =
140
+ typeof input === "string" ? { webhookBaseUrl: input } : (input ?? {});
141
+ const baseUrl = options.webhookBaseUrl ?? activeWebhookBaseUrl;
142
+ const limit = Math.max(
143
+ 1,
144
+ Math.min(500, Math.floor(options.limit ?? DEFAULT_SWEEP_LIMIT)),
145
+ );
146
+ const scopeSql = options.durableOnly
147
+ ? durableScopeSql()
148
+ : { clause: "", args: [] as string[] };
149
+ const result: PendingTasksSweepResult = {
150
+ selected: 0,
151
+ dispatched: 0,
152
+ markedFailed: 0,
153
+ skipped: 0,
154
+ dispatchFailed: 0,
155
+ };
156
+ await ensurePendingTasksTable();
64
157
  const client = getDbExec();
65
158
  const now = Date.now();
66
159
  const pendingCutoff = now - PENDING_STUCK_AFTER_MS;
67
160
  const processingCutoff = now - getProcessingStuckAfterMs();
161
+ const durableProcessingCutoff =
162
+ now - DURABLE_BACKGROUND_PROCESSING_STUCK_AFTER_MS;
68
163
 
69
164
  let stuckRows: StuckTaskRow[];
70
165
  try {
71
166
  const { rows } = await client.execute({
72
167
  sql: `
73
- SELECT id, status, attempts
168
+ SELECT id, platform, external_thread_id, dispatch_scope, status, attempts, updated_at
74
169
  FROM integration_pending_tasks
75
- WHERE (status = 'pending' AND created_at <= ? AND updated_at <= ?)
76
- OR (status = 'processing' AND updated_at <= ?)
170
+ WHERE ((status = 'pending' AND created_at <= ? AND updated_at <= ?)
171
+ OR (status = 'processing' AND (
172
+ (last_dispatch_outcome = 'background-acknowledged'
173
+ AND updated_at <= ?)
174
+ OR ((last_dispatch_outcome IS NULL
175
+ OR last_dispatch_outcome <> 'background-acknowledged')
176
+ AND updated_at <= ?)
177
+ )))
178
+ ${scopeSql.clause}
179
+ ORDER BY updated_at ASC
180
+ LIMIT ?
77
181
  `,
78
182
  // `updated_at` is initialized to `created_at` on insert, so a genuinely
79
183
  // stuck pending row still matches on the first sweep. The retry path
80
184
  // below touches `updated_at`, which (with this predicate) keeps the row
81
185
  // from being re-selected — and re-firing the processor — on every tick.
82
- args: [pendingCutoff, pendingCutoff, processingCutoff],
186
+ args: [
187
+ pendingCutoff,
188
+ pendingCutoff,
189
+ durableProcessingCutoff,
190
+ processingCutoff,
191
+ ...scopeSql.args,
192
+ limit,
193
+ ],
83
194
  });
84
195
  stuckRows = rows.map((r) => ({
85
196
  id: r.id as string,
197
+ platform: r.platform as string,
198
+ externalThreadId: r.external_thread_id as string,
86
199
  status: r.status as string,
87
200
  attempts: Number(r.attempts ?? 0),
201
+ updatedAt: Number(r.updated_at ?? 0),
202
+ dispatchScope: (r.dispatch_scope as string | null) ?? null,
88
203
  }));
89
204
  tableExists = true;
90
205
  } catch (err) {
91
- // Most common case: the table hasn't been created yet because no inbound
92
- // integration webhook has been processed on this deployment. Silently
93
- // no-op until the table appears.
206
+ if (!isMissingPendingTasksTableError(err)) throw err;
94
207
  if (tableExists !== false) {
95
208
  tableExists = false;
96
209
  if (process.env.DEBUG) {
@@ -99,17 +212,29 @@ export async function retryStuckPendingTasks(
99
212
  );
100
213
  }
101
214
  }
102
- return;
215
+ return result;
103
216
  }
104
217
 
105
- if (stuckRows.length === 0) return;
218
+ stuckRows = stuckRows.filter((row) => {
219
+ const durable = isIntegrationDurableDispatchEnabledForTask({
220
+ platform: row.platform,
221
+ externalThreadId: row.externalThreadId,
222
+ platformContext: row.dispatchScope
223
+ ? { channelId: row.dispatchScope }
224
+ : undefined,
225
+ });
226
+ if (options.durableOnly && !durable) return false;
227
+ return true;
228
+ });
229
+ result.selected = stuckRows.length;
230
+ if (stuckRows.length === 0) return result;
106
231
 
107
232
  for (const row of stuckRows) {
108
233
  try {
109
234
  // Cap retries — mark failed and move on so the row stops bouncing
110
235
  // between pending and processing forever.
111
236
  if (row.attempts >= MAX_PENDING_TASK_ATTEMPTS) {
112
- await client.execute({
237
+ const update = await client.execute({
113
238
  sql: `
114
239
  UPDATE integration_pending_tasks
115
240
  SET status = 'failed',
@@ -119,14 +244,21 @@ export async function retryStuckPendingTasks(
119
244
  external_event_key = NULL
120
245
  WHERE id = ?
121
246
  AND status = ?
247
+ AND updated_at = ?
122
248
  `,
123
249
  args: [
124
250
  Date.now(),
125
251
  `Retry job: exceeded ${MAX_PENDING_TASK_ATTEMPTS} attempts`,
126
252
  row.id,
127
253
  row.status,
254
+ row.updatedAt,
128
255
  ],
129
256
  });
257
+ if (affectedRows(update) === 0) {
258
+ result.skipped += 1;
259
+ continue;
260
+ }
261
+ result.markedFailed += 1;
130
262
  console.warn(
131
263
  `[integrations] Pending task ${row.id} exceeded ${MAX_PENDING_TASK_ATTEMPTS} attempts — marking failed`,
132
264
  );
@@ -138,24 +270,44 @@ export async function retryStuckPendingTasks(
138
270
  // Without this, processing rows stay stuck forever.
139
271
  // For pending rows, just touch updated_at to avoid re-firing every tick.
140
272
  const newStatus = row.status === "processing" ? "pending" : row.status;
141
- await client.execute({
273
+ const update = await client.execute({
142
274
  sql: `
143
275
  UPDATE integration_pending_tasks
144
276
  SET status = ?, updated_at = ?
145
277
  WHERE id = ?
146
278
  AND status = ?
279
+ AND updated_at = ?
147
280
  `,
148
- args: [newStatus, Date.now(), row.id, row.status],
281
+ args: [newStatus, Date.now(), row.id, row.status, row.updatedAt],
149
282
  });
283
+ if (affectedRows(update) === 0) {
284
+ result.skipped += 1;
285
+ continue;
286
+ }
150
287
 
151
- await refireProcessor(row.id, baseUrl);
288
+ const outcome = await dispatchPendingIntegrationTask({
289
+ taskId: row.id,
290
+ task: {
291
+ platform: row.platform,
292
+ externalThreadId: row.externalThreadId,
293
+ platformContext: row.dispatchScope
294
+ ? { channelId: row.dispatchScope }
295
+ : undefined,
296
+ },
297
+ baseUrl,
298
+ portableSettleMs: 1_000,
299
+ });
300
+ if (outcome === "failed") result.dispatchFailed += 1;
301
+ else result.dispatched += 1;
152
302
  } catch (err) {
303
+ result.dispatchFailed += 1;
153
304
  console.error(
154
305
  `[integrations] Failed to retry pending task ${row.id}:`,
155
306
  err,
156
307
  );
157
308
  }
158
309
  }
310
+ return result;
159
311
  }
160
312
 
161
313
  function getProcessingStuckAfterMs(): number {
@@ -170,70 +322,6 @@ function getProcessingStuckAfterMs(): number {
170
322
  return DEFAULT_PROCESSING_STUCK_AFTER_MS;
171
323
  }
172
324
 
173
- /**
174
- * Fire-and-forget POST to the processor endpoint for a single task id.
175
- * Mirrors the original dispatch from the webhook handler, including the
176
- * short-lived HMAC bearer token bound to this taskId.
177
- */
178
- async function refireProcessor(
179
- taskId: string,
180
- webhookBaseUrl: string | undefined,
181
- ): Promise<void> {
182
- const baseUrl =
183
- webhookBaseUrl ||
184
- process.env.WEBHOOK_BASE_URL ||
185
- process.env.APP_URL ||
186
- process.env.URL ||
187
- process.env.DEPLOY_URL ||
188
- `http://localhost:${process.env.PORT || 3000}`;
189
-
190
- const url = `${withConfiguredAppBasePath(baseUrl)}${PROCESSOR_PATH}`;
191
-
192
- // Sign with HMAC if A2A_SECRET is configured. In production we MUST sign —
193
- // an unsigned dispatch in production lets attackers re-trigger any queued
194
- // task with a guessable id (C3 in the webhook security audit). In dev we
195
- // fall back to unsigned so contributors can iterate without configuring
196
- // A2A_SECRET locally.
197
- const headers: Record<string, string> = {
198
- "Content-Type": "application/json",
199
- };
200
- try {
201
- headers["Authorization"] = `Bearer ${signInternalToken(taskId)}`;
202
- } catch (err) {
203
- if (process.env.NODE_ENV === "production") {
204
- console.error(
205
- `[integrations] Refusing to dispatch task ${taskId} — A2A_SECRET not configured. ` +
206
- "Set A2A_SECRET to enable signed retry dispatches.",
207
- );
208
- return;
209
- }
210
- // Dev: proceed unsigned. Log the underlying error path so a malformed
211
- // secret (different from "not set") doesn't fail silently (L5 in the audit).
212
- if (err instanceof Error && !/A2A_SECRET/i.test(err.message)) {
213
- console.error(
214
- `[integrations] signInternalToken failed unexpectedly for ${taskId}:`,
215
- err,
216
- );
217
- }
218
- }
219
-
220
- // Don't await the body — we just want the request to leave the box.
221
- // A short timeout avoids tying up the retry loop on a hung processor.
222
- const controller = new AbortController();
223
- const timer = setTimeout(() => controller.abort(), 5_000);
224
-
225
- try {
226
- await fetch(url, {
227
- method: "POST",
228
- headers,
229
- body: JSON.stringify({ taskId }),
230
- signal: controller.signal,
231
- });
232
- } finally {
233
- clearTimeout(timer);
234
- }
235
- }
236
-
237
325
  /**
238
326
  * Start the periodic retry loop. Safe to call multiple times — second call
239
327
  * is a no-op.