@relaycast/engine 11.1.1 → 12.1.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 (178) hide show
  1. package/dist/adapters/node/index.d.ts +3 -3
  2. package/dist/adapters/node/index.d.ts.map +1 -1
  3. package/dist/adapters/node/index.js +2 -3
  4. package/dist/adapters/node/index.js.map +1 -1
  5. package/dist/adapters/node/kv.d.ts +0 -1
  6. package/dist/adapters/node/kv.d.ts.map +1 -1
  7. package/dist/adapters/node/kv.js +0 -17
  8. package/dist/adapters/node/kv.js.map +1 -1
  9. package/dist/adapters/node/presence.d.ts +18 -0
  10. package/dist/adapters/node/presence.d.ts.map +1 -1
  11. package/dist/adapters/node/presence.js +82 -0
  12. package/dist/adapters/node/presence.js.map +1 -1
  13. package/dist/adapters/node/realtime.d.ts +4 -0
  14. package/dist/adapters/node/realtime.d.ts.map +1 -1
  15. package/dist/adapters/node/realtime.js +32 -5
  16. package/dist/adapters/node/realtime.js.map +1 -1
  17. package/dist/db/migrations/0077_drop_workspace_plan.sql +4 -0
  18. package/dist/db/schema.d.ts +2 -21
  19. package/dist/db/schema.d.ts.map +1 -1
  20. package/dist/db/schema.js +0 -1
  21. package/dist/db/schema.js.map +1 -1
  22. package/dist/engine/a2a.d.ts +3 -0
  23. package/dist/engine/a2a.d.ts.map +1 -1
  24. package/dist/engine/a2a.js +1 -0
  25. package/dist/engine/a2a.js.map +1 -1
  26. package/dist/engine/action.d.ts +14 -9
  27. package/dist/engine/action.d.ts.map +1 -1
  28. package/dist/engine/action.js +335 -74
  29. package/dist/engine/action.js.map +1 -1
  30. package/dist/engine/agent.d.ts +9 -3
  31. package/dist/engine/agent.d.ts.map +1 -1
  32. package/dist/engine/agent.js +21 -3
  33. package/dist/engine/agent.js.map +1 -1
  34. package/dist/engine/delivery.d.ts.map +1 -1
  35. package/dist/engine/delivery.js +4 -0
  36. package/dist/engine/delivery.js.map +1 -1
  37. package/dist/engine/eventDelivery.d.ts +1 -1
  38. package/dist/engine/inboundWebhook.d.ts +10 -0
  39. package/dist/engine/inboundWebhook.d.ts.map +1 -1
  40. package/dist/engine/inboundWebhook.js +30 -4
  41. package/dist/engine/inboundWebhook.js.map +1 -1
  42. package/dist/engine/inbox.d.ts.map +1 -1
  43. package/dist/engine/inbox.js +17 -0
  44. package/dist/engine/inbox.js.map +1 -1
  45. package/dist/engine/invocationClaim.d.ts +32 -0
  46. package/dist/engine/invocationClaim.d.ts.map +1 -0
  47. package/dist/engine/invocationClaim.js +42 -0
  48. package/dist/engine/invocationClaim.js.map +1 -0
  49. package/dist/engine/messageMetadata.d.ts +2 -0
  50. package/dist/engine/messageMetadata.d.ts.map +1 -1
  51. package/dist/engine/messageMetadata.js +3 -2
  52. package/dist/engine/messageMetadata.js.map +1 -1
  53. package/dist/engine/node.d.ts +12 -2
  54. package/dist/engine/node.d.ts.map +1 -1
  55. package/dist/engine/node.js +83 -4
  56. package/dist/engine/node.js.map +1 -1
  57. package/dist/engine/presence.d.ts +173 -1
  58. package/dist/engine/presence.d.ts.map +1 -1
  59. package/dist/engine/presence.js +81 -4
  60. package/dist/engine/presence.js.map +1 -1
  61. package/dist/engine/registrationAdmission.d.ts +40 -0
  62. package/dist/engine/registrationAdmission.d.ts.map +1 -0
  63. package/dist/engine/registrationAdmission.js +78 -0
  64. package/dist/engine/registrationAdmission.js.map +1 -0
  65. package/dist/engine/taskInvocation.d.ts +1 -1
  66. package/dist/engine/workspace.d.ts +26 -3
  67. package/dist/engine/workspace.d.ts.map +1 -1
  68. package/dist/engine/workspace.js +108 -8
  69. package/dist/engine/workspace.js.map +1 -1
  70. package/dist/engine/workspaceDeliveryPolicy.d.ts +1 -2
  71. package/dist/engine/workspaceDeliveryPolicy.d.ts.map +1 -1
  72. package/dist/engine/workspaceDeliveryPolicy.js +2 -5
  73. package/dist/engine/workspaceDeliveryPolicy.js.map +1 -1
  74. package/dist/engine/workspaceDrain.d.ts +38 -0
  75. package/dist/engine/workspaceDrain.d.ts.map +1 -0
  76. package/dist/engine/workspaceDrain.js +202 -0
  77. package/dist/engine/workspaceDrain.js.map +1 -0
  78. package/dist/engine.d.ts +1 -1
  79. package/dist/engine.d.ts.map +1 -1
  80. package/dist/engine.js +30 -2
  81. package/dist/engine.js.map +1 -1
  82. package/dist/entrypoints/node.d.ts +2 -2
  83. package/dist/entrypoints/node.d.ts.map +1 -1
  84. package/dist/entrypoints/node.js +1 -1
  85. package/dist/entrypoints/node.js.map +1 -1
  86. package/dist/env.d.ts +5 -5
  87. package/dist/env.d.ts.map +1 -1
  88. package/dist/index.d.ts +4 -4
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +5 -6
  91. package/dist/index.js.map +1 -1
  92. package/dist/lib/admissionError.d.ts +11 -0
  93. package/dist/lib/admissionError.d.ts.map +1 -0
  94. package/dist/lib/admissionError.js +14 -0
  95. package/dist/lib/admissionError.js.map +1 -0
  96. package/dist/lib/engineDiagnostics.d.ts +26 -0
  97. package/dist/lib/engineDiagnostics.d.ts.map +1 -0
  98. package/dist/lib/engineDiagnostics.js +100 -0
  99. package/dist/lib/engineDiagnostics.js.map +1 -0
  100. package/dist/lib/httpError.d.ts.map +1 -1
  101. package/dist/lib/httpError.js +9 -4
  102. package/dist/lib/httpError.js.map +1 -1
  103. package/dist/middleware/admission.d.ts +49 -0
  104. package/dist/middleware/admission.d.ts.map +1 -0
  105. package/dist/middleware/admission.js +100 -0
  106. package/dist/middleware/admission.js.map +1 -0
  107. package/dist/middleware/rateLimit.d.ts +2 -0
  108. package/dist/middleware/rateLimit.d.ts.map +1 -1
  109. package/dist/middleware/rateLimit.js +29 -25
  110. package/dist/middleware/rateLimit.js.map +1 -1
  111. package/dist/ports/admission.d.ts +80 -0
  112. package/dist/ports/admission.d.ts.map +1 -0
  113. package/dist/ports/admission.js +2 -0
  114. package/dist/ports/admission.js.map +1 -0
  115. package/dist/ports/diagnostics.d.ts +42 -0
  116. package/dist/ports/diagnostics.d.ts.map +1 -0
  117. package/dist/ports/diagnostics.js +13 -0
  118. package/dist/ports/diagnostics.js.map +1 -0
  119. package/dist/ports/index.d.ts +18 -4
  120. package/dist/ports/index.d.ts.map +1 -1
  121. package/dist/ports/index.js.map +1 -1
  122. package/dist/ports/kv.d.ts +1 -16
  123. package/dist/ports/kv.d.ts.map +1 -1
  124. package/dist/ports/presence.d.ts +14 -0
  125. package/dist/ports/presence.d.ts.map +1 -1
  126. package/dist/ports/realtime.d.ts +18 -0
  127. package/dist/ports/realtime.d.ts.map +1 -1
  128. package/dist/ports/realtime.js.map +1 -1
  129. package/dist/ports/workspaceDelivery.d.ts +3 -4
  130. package/dist/ports/workspaceDelivery.d.ts.map +1 -1
  131. package/dist/routes/a2a.d.ts.map +1 -1
  132. package/dist/routes/a2a.js +2 -0
  133. package/dist/routes/a2a.js.map +1 -1
  134. package/dist/routes/action.js +3 -0
  135. package/dist/routes/action.js.map +1 -1
  136. package/dist/routes/agent.d.ts.map +1 -1
  137. package/dist/routes/agent.js +5 -1
  138. package/dist/routes/agent.js.map +1 -1
  139. package/dist/routes/dm.js +3 -3
  140. package/dist/routes/dm.js.map +1 -1
  141. package/dist/routes/groupDm.js +3 -3
  142. package/dist/routes/groupDm.js.map +1 -1
  143. package/dist/routes/inboundWebhook.js +8 -0
  144. package/dist/routes/inboundWebhook.js.map +1 -1
  145. package/dist/routes/message.js +3 -3
  146. package/dist/routes/message.js.map +1 -1
  147. package/dist/routes/node.d.ts.map +1 -1
  148. package/dist/routes/node.js +2 -1
  149. package/dist/routes/node.js.map +1 -1
  150. package/dist/routes/thread.js +3 -3
  151. package/dist/routes/thread.js.map +1 -1
  152. package/dist/routes/workspace.js +10 -2
  153. package/dist/routes/workspace.js.map +1 -1
  154. package/package.json +3 -3
  155. package/dist/engine/usage.d.ts +0 -35
  156. package/dist/engine/usage.d.ts.map +0 -1
  157. package/dist/engine/usage.js +0 -59
  158. package/dist/engine/usage.js.map +0 -1
  159. package/dist/lib/planLimitError.d.ts +0 -26
  160. package/dist/lib/planLimitError.d.ts.map +0 -1
  161. package/dist/lib/planLimitError.js +0 -16
  162. package/dist/lib/planLimitError.js.map +0 -1
  163. package/dist/middleware/planLimits.d.ts +0 -36
  164. package/dist/middleware/planLimits.d.ts.map +0 -1
  165. package/dist/middleware/planLimits.js +0 -190
  166. package/dist/middleware/planLimits.js.map +0 -1
  167. package/dist/middleware/usageTracker.d.ts +0 -3
  168. package/dist/middleware/usageTracker.d.ts.map +0 -1
  169. package/dist/middleware/usageTracker.js +0 -11
  170. package/dist/middleware/usageTracker.js.map +0 -1
  171. package/dist/ports/entitlements.d.ts +0 -65
  172. package/dist/ports/entitlements.d.ts.map +0 -1
  173. package/dist/ports/entitlements.js +0 -2
  174. package/dist/ports/entitlements.js.map +0 -1
  175. package/dist/providers/static-entitlements.d.ts +0 -25
  176. package/dist/providers/static-entitlements.d.ts.map +0 -1
  177. package/dist/providers/static-entitlements.js +0 -55
  178. package/dist/providers/static-entitlements.js.map +0 -1
@@ -9,10 +9,12 @@ import { D1_SAFE_IN_QUERY_CHUNK_SIZE } from '../lib/queryChunks.js';
9
9
  import { toFleetWireJson } from './deliveryWire.js';
10
10
  import { emitAgentExitedEffects, emitInvocationCompletionEffects, fleetInvocationId, } from './invocationCompletion.js';
11
11
  import { runAtomic, runAtomicWrites } from '../ports/database.js';
12
+ import { exclusiveClaimWrite, retryAfterAtMatches } from './invocationClaim.js';
12
13
  import { claimSpawnNode, chooseNodeForAction, isNodeLive, releaseNodeCapacity, reserveNodeCapacity } from './placement.js';
13
14
  import { DEFAULT_PROVIDER_NAME, capacityProviderName, getProvider, isProviderLive } from './nodeProvider.js';
14
15
  import { AGENT_TOKEN_HASH_PATTERN } from '@relaycast/types';
15
16
  import { createTaskState, expireTaskInvocation, expireTaskInvocations, taskExecution } from './taskInvocation.js';
17
+ import { withEngineOperation } from '../lib/engineDiagnostics.js';
16
18
  const OPEN_INVOCATION_STATUSES = ['pending', 'dispatched', 'invoked'];
17
19
  const REPLAYED_INVOCATION = Symbol('replayed-action-invocation');
18
20
  export const ACTION_DISPATCH_TIMEOUT_MS = 30_000;
@@ -40,14 +42,13 @@ export const ACTION_HANDLER_UNREACHABLE_TTL_MS = 120_000;
40
42
  export const PENDING_INVOCATION_MAX_AGE_MS = 72 * 60 * 60 * 1000;
41
43
  /**
42
44
  * Sleep between the never-dispatched sweep's candidate SELECT and its atomic
43
- * UPDATE. `dispatchNodeInvocation` sends the frame before recording
44
- * `dispatchAttempts += 1`, so a sweep landing between those two dispatcher
45
- * calls could otherwise mark an in-flight dispatch `never_dispatched_expired`
46
- * while the handler already has the frame. The atomic UPDATE re-checks
47
- * `dispatch_attempts = 0`, so once the dispatcher's UPDATE lands, the sweep's
48
- * WHERE excludes the row. This grace window (default 5s) covers the send→record
49
- * gap by a wide margin — the dispatcher's UPDATE is one D1 round-trip — while
50
- * still bounding sweep latency. Set to 0 in tests to keep them fast.
45
+ * UPDATE. Builtin spawn and agent-hosted sends claim before the frame, so
46
+ * `dispatch_attempts` is already set. Release and other unclaimed sends still
47
+ * record the attempt after the frame. A sweep landing in that gap could mark
48
+ * the row `never_dispatched_expired` while the handler already has the frame.
49
+ * The atomic UPDATE re-checks `dispatch_attempts = 0`, so once the attempt is
50
+ * recorded the row is excluded. This grace window (default 5s) covers that
51
+ * record gap. Set to 0 in tests to keep them fast.
51
52
  */
52
53
  export const NEVER_DISPATCHED_SWEEP_GRACE_MS = 5_000;
53
54
  const ACTION_RETRY_BACKOFF_MS = 5_000;
@@ -130,11 +131,10 @@ function nextRetryAfter(attempts) {
130
131
  return new Date(Date.now() + backoff);
131
132
  }
132
133
  /**
133
- * Field set that moves an invocation into the live `dispatched` state once its
134
- * `action.invoke` frame has actually been delivered to the node. Shared by the
135
- * live dispatch path (`dispatchNodeAttempt`) and exported offline-queue drain
136
- * path (`drainNodeInvocations`) so the dispatch-timeout sweep — which keys off
137
- * `dispatchedAt` — and the reschedule path cover drained invocations too.
134
+ * Live `dispatched` fields (`dispatchedAt`, `retryAfterAt`). The timeout sweep
135
+ * keys off `dispatchedAt`. A builtin spawn or agent-hosted claim writes them
136
+ * before the frame; offline-queue drain writes them when a queued frame is
137
+ * delivered.
138
138
  */
139
139
  function dispatchedStateFields(opts = {}) {
140
140
  return {
@@ -763,7 +763,33 @@ async function dispatchNodeProviderInvocation(args, options = {}) {
763
763
  }
764
764
  return { accepted, pending: true };
765
765
  }
766
- return dispatchNodeInvocation({ ...args });
766
+ // A retry passes the generation it selected. Re-reading here would adopt a
767
+ // claim that won while this retry was placing, and both would send. Callers
768
+ // without a snapshot still read the row so a dispatched spawn does not fall
769
+ // back to the fresh-row defaults.
770
+ const suppliedClaim = args.claimStatus
771
+ ? {
772
+ claimStatus: args.claimStatus,
773
+ observedRetryAfterAt: args.observedRetryAfterAt ?? null,
774
+ observedDispatchAttempts: args.observedDispatchAttempts ?? 0,
775
+ observedDispatchedNodeId: args.observedDispatchedNodeId ?? null,
776
+ }
777
+ : null;
778
+ const [claimRow] = suppliedClaim
779
+ ? []
780
+ : await args.db
781
+ .select({
782
+ status: actionInvocations.status,
783
+ retryAfterAt: actionInvocations.retryAfterAt,
784
+ dispatchAttempts: actionInvocations.dispatchAttempts,
785
+ dispatchedNodeId: actionInvocations.dispatchedNodeId,
786
+ })
787
+ .from(actionInvocations)
788
+ .where(and(eq(actionInvocations.workspaceId, args.workspaceId), eq(actionInvocations.id, args.invocationId)));
789
+ return dispatchNodeInvocation({
790
+ ...args,
791
+ ...(suppliedClaim ?? (claimRow ? observedInvocationClaim(claimRow) : {})),
792
+ });
767
793
  }
768
794
  async function dispatchRelease(args) {
769
795
  const input = recordInput(args.data.input);
@@ -1268,11 +1294,12 @@ async function dispatchSpawn(args) {
1268
1294
  // Placement reserved capacity for an attempt that never landed. Return it
1269
1295
  // first, then mark the row unsent: only the mark makes it drainable, so a
1270
1296
  // drain never sees it while this send is in flight or still holding the
1271
- // reservation.
1272
- if (!dispatched.accepted && !placement.queued) {
1297
+ // reservation. A thrown send has an unknown outcome, so it is not marked
1298
+ // and only the never-dispatched sweep ends it.
1299
+ if (!dispatched.accepted && !placement.queued && !dispatched.reservationStillHeld) {
1273
1300
  await releaseNodeCapacity(args.db, args.workspaceId, nodeId);
1274
1301
  }
1275
- if (!dispatched.sent) {
1302
+ if (!dispatched.sent && !dispatched.sendOutcomeUnknown) {
1276
1303
  await markUnsentNativeSpawn(args.db, args.workspaceId, invocation.id, nodeId);
1277
1304
  }
1278
1305
  return spawnResult(invocation, nodeId, dispatched);
@@ -1399,7 +1426,10 @@ export async function invokeAction(db, workspaceId, actionName, data, options =
1399
1426
  invocationId,
1400
1427
  });
1401
1428
  }
1402
- if (!action && actionName === 'release') {
1429
+ // Destructive roster mutation. The input shape is not the guard: a payload
1430
+ // that already carries `name` and `delete_agent` still does not release
1431
+ // unless this caller set `authorizeBuiltinRelease`.
1432
+ if (!action && actionName === 'release' && options.authorizeBuiltinRelease) {
1403
1433
  return dispatchRelease({
1404
1434
  db,
1405
1435
  registry: options.nodeConnections,
@@ -1557,6 +1587,21 @@ export async function invokeAction(db, workspaceId, actionName, data, options =
1557
1587
  });
1558
1588
  }
1559
1589
  }
1590
+ // The agent CAS recorded dispatch before the frame. A handler can complete
1591
+ // that row before this response is built, and the ack returns that terminal row.
1592
+ if (dispatched.sent && dispatched.accepted) {
1593
+ const [current] = await db
1594
+ .select()
1595
+ .from(actionInvocations)
1596
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocation.id)));
1597
+ if (current && !OPEN_INVOCATION_STATUSES.some((status) => status === current.status)) {
1598
+ return invocationAck(current, {
1599
+ actionName,
1600
+ handlerAgentId: action.handlerAgentId,
1601
+ handlerNodeId: handlerAgent.locationNodeId,
1602
+ });
1603
+ }
1604
+ }
1560
1605
  if (!dispatched.sent) {
1561
1606
  if (invocationId !== undefined) {
1562
1607
  // A keyed claim must stay consistent with its replays. Re-read it so a
@@ -1839,7 +1884,16 @@ async function dispatchNodeAttempt(db, workspaceId, invocationId, nodeId, opts)
1839
1884
  ...(opts.targetActionId ? { actionId: opts.targetActionId } : {}),
1840
1885
  ...attemptFields,
1841
1886
  })
1842
- .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(opts.expectedNodeLivenessRevision !== undefined
1887
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), ...(opts.claim
1888
+ ? [
1889
+ eq(actionInvocations.status, opts.claim.status),
1890
+ retryAfterAtMatches(opts.claim.retryAfterAt),
1891
+ eq(actionInvocations.dispatchAttempts, opts.claim.dispatchAttempts),
1892
+ ...(opts.claim.spawnReservedAt
1893
+ ? [eq(actionInvocations.spawnReservedAt, opts.claim.spawnReservedAt)]
1894
+ : []),
1895
+ ]
1896
+ : [inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)]), ...(opts.expectedNodeLivenessRevision !== undefined
1843
1897
  ? [eq(actionInvocations.nodeLivenessRevision, opts.expectedNodeLivenessRevision)]
1844
1898
  : []), ...(opts.expectedAction ? [
1845
1899
  eq(actionInvocations.invocationOrigin, 'registered_action'),
@@ -1863,6 +1917,25 @@ async function dispatchNodeAttempt(db, workspaceId, invocationId, nodeId, opts)
1863
1917
  .returning();
1864
1918
  return !!updated;
1865
1919
  }
1920
+ /**
1921
+ * Drop a spawn claim whose frame was not accepted. The compare matches the
1922
+ * attempt count and deadline this claim wrote, so a failed older send cannot
1923
+ * reset a newer winner. Restore the observed attempt count, route, deadline,
1924
+ * and reservation token so the pending row is due for another sender.
1925
+ */
1926
+ async function restoreUnsentSpawnClaim(db, workspaceId, invocationId, claim) {
1927
+ await db
1928
+ .update(actionInvocations)
1929
+ .set({
1930
+ status: 'pending',
1931
+ dispatchedAt: null,
1932
+ dispatchedNodeId: claim.restoreDispatchedNodeId,
1933
+ spawnReservedAt: claim.restoreSpawnReservedAt,
1934
+ retryAfterAt: claim.restoreRetryAfterAt,
1935
+ dispatchAttempts: claim.restoreDispatchAttempts,
1936
+ })
1937
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, ['pending', 'dispatched']), eq(actionInvocations.dispatchAttempts, claim.dispatchAttempts), retryAfterAtMatches(claim.retryAfterAt)));
1938
+ }
1866
1939
  function registeredActionDispatchPredicate(args) {
1867
1940
  const handlerPredicate = args.handlerAgentId
1868
1941
  ? sql `AND ${actions.handlerAgentId} = ${args.handlerAgentId} AND ${actions.handlerNodeId} IS NULL`
@@ -2068,6 +2141,17 @@ async function dispatchNodeInvocation(args) {
2068
2141
  return { accepted: false, pending: false, sent: false };
2069
2142
  const connectedBefore = args.registry.isProviderConnected(args.workspaceId, args.nodeId, args.providerName);
2070
2143
  const pending = !!args.pending || !connectedBefore;
2144
+ const claimStatus = args.claimStatus ?? 'pending';
2145
+ const observedRetryAfterAt = args.observedRetryAfterAt ?? null;
2146
+ const observedDispatchAttempts = args.observedDispatchAttempts ?? 0;
2147
+ const claimWrite = exclusiveClaimWrite({
2148
+ observedStatus: claimStatus,
2149
+ observedRetryAfterAt,
2150
+ observedDispatchAttempts,
2151
+ nextStatus: pending ? 'pending' : 'dispatched',
2152
+ nextRetryAfterAt: args.retryAfterAt ?? null,
2153
+ incrementAttempts: !args.skipIncrementAttempts,
2154
+ });
2071
2155
  const expectedActionId = args.invocationOrigin === 'registered_action'
2072
2156
  ? (args.expectedActionId ?? args.actionId)
2073
2157
  : null;
@@ -2097,38 +2181,127 @@ async function dispatchNodeInvocation(args) {
2097
2181
  if (registeredNodeClaim?.taskState) {
2098
2182
  frame.task_execution = taskExecution({ id: args.invocationId, ...registeredNodeClaim });
2099
2183
  }
2100
- const sent = args.agent && args.actionId
2101
- ? await (args.registry.sendAuthorizedActionToProvider?.(args.workspaceId, args.nodeId, args.providerName, frame, {
2102
- kind: 'agent-action-v1',
2103
- invocationId: args.invocationId,
2104
- actionId: args.actionId,
2105
- handlerAgentId: args.agent.id,
2106
- recordAttempt: !args.skipIncrementAttempts,
2107
- ...(args.expectedNodeLivenessRevision !== undefined
2108
- ? { expectedNodeLivenessRevision: args.expectedNodeLivenessRevision }
2109
- : {}),
2110
- }) ?? false)
2111
- : registeredNodeClaim
2184
+ // Native spawn has no registered handoff. Claim before the frame so a
2185
+ // concurrent drain and sweep cannot both send. The claim matches the
2186
+ // observed status, so a dispatched timeout retry can win, and it changes
2187
+ // the attempt count or the deadline so a second scheduler loses. Success
2188
+ // does not update the row again.
2189
+ const nativeSpawnSend = isSpawnInvocation(args.action) && !args.agent && !registeredNodeClaim;
2190
+ let nativeSpawnClaimed = false;
2191
+ if (nativeSpawnSend) {
2192
+ try {
2193
+ nativeSpawnClaimed = await dispatchNodeAttempt(args.db, args.workspaceId, args.invocationId, args.nodeId, {
2194
+ pending,
2195
+ providerName: args.providerName,
2196
+ retryAfterAt: claimWrite.retryAfterAt,
2197
+ reservationHeld: args.reservationHeld,
2198
+ skipIncrementAttempts: args.skipIncrementAttempts,
2199
+ expectedNodeLivenessRevision: args.expectedNodeLivenessRevision,
2200
+ claim: {
2201
+ status: claimStatus,
2202
+ retryAfterAt: observedRetryAfterAt,
2203
+ dispatchAttempts: observedDispatchAttempts,
2204
+ ...(args.preserveSpawnReservation && args.observedSpawnReservedAt
2205
+ ? { spawnReservedAt: args.observedSpawnReservedAt }
2206
+ : {}),
2207
+ },
2208
+ });
2209
+ }
2210
+ catch {
2211
+ // The claim write did not commit: no frame left, and the row has no
2212
+ // reservation marker for a later sweep to release. A false result lets
2213
+ // the caller return the slot it already reserved and leave the
2214
+ // invocation pending for another sender.
2215
+ return { accepted: false, pending: false, sent: false };
2216
+ }
2217
+ }
2218
+ if (nativeSpawnSend && !nativeSpawnClaimed) {
2219
+ return { accepted: false, pending: false, sent: false };
2220
+ }
2221
+ const restoreSpawnClaim = () => restoreUnsentSpawnClaim(args.db, args.workspaceId, args.invocationId, {
2222
+ dispatchAttempts: claimWrite.dispatchAttempts,
2223
+ retryAfterAt: claimWrite.retryAfterAt,
2224
+ restoreRetryAfterAt: observedRetryAfterAt,
2225
+ restoreDispatchAttempts: observedDispatchAttempts,
2226
+ restoreDispatchedNodeId: args.observedDispatchedNodeId ?? null,
2227
+ restoreSpawnReservedAt: args.preserveSpawnReservation ? args.observedSpawnReservedAt ?? null : null,
2228
+ });
2229
+ // A failed restore leaves the marker set. Returning the slot as well lets
2230
+ // the timeout sweep release it a second time.
2231
+ const restoreSpawnClaimOrHold = async () => {
2232
+ try {
2233
+ await restoreSpawnClaim();
2234
+ return false;
2235
+ }
2236
+ catch {
2237
+ return true;
2238
+ }
2239
+ };
2240
+ let sent = false;
2241
+ try {
2242
+ sent = args.agent && args.actionId
2112
2243
  ? await (args.registry.sendAuthorizedActionToProvider?.(args.workspaceId, args.nodeId, args.providerName, frame, {
2113
- kind: 'registered-node-action-v2',
2244
+ kind: 'agent-action-v1',
2114
2245
  invocationId: args.invocationId,
2115
- actionId: registeredNodeClaim.actionId,
2116
- dispatchAttempt: registeredNodeClaim.dispatchAttempts,
2117
- invocationActionName: registeredNodeClaim.actionName,
2118
- actionName: args.action,
2246
+ actionId: args.actionId,
2247
+ handlerAgentId: args.agent.id,
2248
+ recordAttempt: !args.skipIncrementAttempts,
2249
+ pending,
2250
+ retryAfterAt: claimWrite.retryAfterAt,
2251
+ claimStatus,
2252
+ observedRetryAfterAt,
2253
+ observedDispatchAttempts,
2254
+ reservationHeld: !!args.reservationHeld,
2255
+ ...(args.expectedNodeLivenessRevision !== undefined
2256
+ ? { expectedNodeLivenessRevision: args.expectedNodeLivenessRevision }
2257
+ : {}),
2119
2258
  }) ?? false)
2120
- : guardedReleaseHash || guardedReleaseAgentId
2259
+ : registeredNodeClaim
2121
2260
  ? await (args.registry.sendAuthorizedActionToProvider?.(args.workspaceId, args.nodeId, args.providerName, frame, {
2122
- kind: 'release-generation-v1',
2261
+ kind: 'registered-node-action-v2',
2123
2262
  invocationId: args.invocationId,
2124
- agentName: typeof args.input.name === 'string' ? args.input.name : '',
2125
- ...(guardedReleaseHash && guardedReleaseAgentId
2126
- ? { expectedTokenHash: guardedReleaseHash, expectedAgentId: guardedReleaseAgentId }
2127
- : guardedReleaseHash
2128
- ? { expectedTokenHash: guardedReleaseHash }
2129
- : { expectedAgentId: guardedReleaseAgentId }),
2263
+ actionId: registeredNodeClaim.actionId,
2264
+ dispatchAttempt: registeredNodeClaim.dispatchAttempts,
2265
+ invocationActionName: registeredNodeClaim.actionName,
2266
+ actionName: args.action,
2130
2267
  }) ?? false)
2131
- : await args.registry.sendToProvider(args.workspaceId, args.nodeId, args.providerName, frame);
2268
+ : guardedReleaseHash || guardedReleaseAgentId
2269
+ ? await (args.registry.sendAuthorizedActionToProvider?.(args.workspaceId, args.nodeId, args.providerName, frame, {
2270
+ kind: 'release-generation-v1',
2271
+ invocationId: args.invocationId,
2272
+ agentName: typeof args.input.name === 'string' ? args.input.name : '',
2273
+ ...(guardedReleaseHash && guardedReleaseAgentId
2274
+ ? { expectedTokenHash: guardedReleaseHash, expectedAgentId: guardedReleaseAgentId }
2275
+ : guardedReleaseHash
2276
+ ? { expectedTokenHash: guardedReleaseHash }
2277
+ : { expectedAgentId: guardedReleaseAgentId }),
2278
+ }) ?? false)
2279
+ : await args.registry.sendToProvider(args.workspaceId, args.nodeId, args.providerName, frame);
2280
+ }
2281
+ catch (error) {
2282
+ // A thrown send never reached the false-result path. Restore only this
2283
+ // claim, then report a false result so the caller releases the capacity
2284
+ // it reserved. The outcome is unknown, so callers must not queue another
2285
+ // send. Registered-action crashes still propagate: their claim has to stay
2286
+ // in place for the keyed replay.
2287
+ if (nativeSpawnClaimed) {
2288
+ const reservationStillHeld = await restoreSpawnClaimOrHold();
2289
+ return {
2290
+ accepted: false,
2291
+ pending: false,
2292
+ sent: false,
2293
+ sendOutcomeUnknown: true,
2294
+ ...(reservationStillHeld ? { reservationStillHeld: true } : {}),
2295
+ };
2296
+ }
2297
+ throw error;
2298
+ }
2299
+ if (!sent && nativeSpawnClaimed) {
2300
+ const reservationStillHeld = await restoreSpawnClaimOrHold();
2301
+ if (reservationStillHeld) {
2302
+ return { accepted: false, pending: false, sent: false, reservationStillHeld: true };
2303
+ }
2304
+ }
2132
2305
  if (!sent && (guardedReleaseHash || guardedReleaseAgentId)) {
2133
2306
  // An adapter compiled against the older owner-authorization contract may
2134
2307
  // expose the method but reject the new proof kind. Settle any still-open
@@ -2157,10 +2330,11 @@ async function dispatchNodeInvocation(args) {
2157
2330
  }
2158
2331
  return { accepted: false, pending: false, sent: false };
2159
2332
  }
2160
- // Registered node-action state was committed by the pre-send handoff CAS;
2161
- // agent-action state was committed by the socket owner's authorization CAS.
2333
+ // Registered node-action and native-spawn state was committed by the
2334
+ // pre-send claim. Agent-action state was committed by the socket owner's
2335
+ // authorization CAS, then re-read below in case the handler already finished.
2162
2336
  // Do not write either generation again after the frame is accepted.
2163
- if (registeredNodeClaim) {
2337
+ if (registeredNodeClaim || nativeSpawnClaimed) {
2164
2338
  return { accepted: true, pending, sent: true };
2165
2339
  }
2166
2340
  if (args.agent && args.actionId) {
@@ -2313,6 +2487,7 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId, op
2313
2487
  input: actionInvocations.input,
2314
2488
  providerAcceptedAttempt: actionInvocations.providerAcceptedAttempt,
2315
2489
  dispatchAttempts: actionInvocations.dispatchAttempts,
2490
+ retryAfterAt: actionInvocations.retryAfterAt,
2316
2491
  spawnReservedAt: actionInvocations.spawnReservedAt,
2317
2492
  dispatchedNodeId: actionInvocations.dispatchedNodeId,
2318
2493
  dispatchedProvider: actionInvocations.dispatchedProvider,
@@ -2434,15 +2609,23 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId, op
2434
2609
  retryAfterAt: new Date(Date.now() + ACTION_DISPATCH_TIMEOUT_MS),
2435
2610
  reservationHeld,
2436
2611
  skipIncrementAttempts: row.dispatchedNodeId === nodeId,
2612
+ claimStatus: 'pending',
2613
+ observedRetryAfterAt: row.retryAfterAt,
2614
+ observedDispatchAttempts: row.dispatchAttempts,
2615
+ observedDispatchedNodeId: row.dispatchedNodeId,
2616
+ observedSpawnReservedAt: row.spawnReservedAt,
2617
+ preserveSpawnReservation: reservationHeld && !reservedForDrain,
2437
2618
  });
2438
2619
  if (dispatched.accepted) {
2439
2620
  drained++;
2440
2621
  }
2441
- else if (reservedForDrain) {
2622
+ else if (reservedForDrain && !dispatched.reservationStillHeld) {
2442
2623
  await releaseNodeCapacity(db, workspaceId, nodeId);
2443
2624
  reservedForDrain = false;
2444
2625
  }
2445
- if (!dispatched.sent)
2626
+ // A thrown send may have delivered the frame. Leave an unsent spawn
2627
+ // unmarked so a later drain does not send it again.
2628
+ if (!dispatched.sent && !dispatched.sendOutcomeUnknown)
2446
2629
  await remarkUnsent();
2447
2630
  }
2448
2631
  catch {
@@ -2459,6 +2642,16 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId, op
2459
2642
  }
2460
2643
  return drained;
2461
2644
  }
2645
+ function observedInvocationClaim(invocation) {
2646
+ if (invocation.status !== 'pending' && invocation.status !== 'dispatched' && invocation.status !== 'invoked')
2647
+ return {};
2648
+ return {
2649
+ claimStatus: invocation.status,
2650
+ observedRetryAfterAt: invocation.retryAfterAt ?? null,
2651
+ observedDispatchAttempts: invocation.dispatchAttempts ?? 0,
2652
+ observedDispatchedNodeId: invocation.dispatchedNodeId ?? null,
2653
+ };
2654
+ }
2462
2655
  export async function rescheduleNodeInvocation(db, registry, invocation, opts = {}) {
2463
2656
  // Once the exact provider accepted this generation, pruning only clears its
2464
2657
  // materialized action FK. It must not make timeout, disconnect, or inventory
@@ -2473,9 +2666,36 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2473
2666
  }
2474
2667
  // Only release capacity the invocation actually holds. A shadowed spawn
2475
2668
  // dropped its native reservation at dispatch (spawnReservedAt is null), so it
2476
- // must not release capacity that now belongs to another spawn.
2669
+ // must not release capacity that now belongs to another spawn. Clear the
2670
+ // marker once so overlapping retries release that one hold, not every hold
2671
+ // on the node.
2477
2672
  if (invocation.dispatchedNodeId && isSpawnInvocation(invocation.actionName) && invocation.spawnReservedAt) {
2478
- await releaseNodeCapacity(db, invocation.workspaceId, invocation.dispatchedNodeId);
2673
+ const heldAt = invocation.spawnReservedAt;
2674
+ const nodeId = invocation.dispatchedNodeId;
2675
+ const holdWhere = and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), eq(actionInvocations.status, invocation.status), eq(actionInvocations.dispatchAttempts, invocation.dispatchAttempts ?? 0), retryAfterAtMatches(invocation.retryAfterAt ?? null), ...(opts.expectedNodeLivenessRevision !== undefined
2676
+ ? [eq(actionInvocations.nodeLivenessRevision, opts.expectedNodeLivenessRevision)]
2677
+ : []));
2678
+ try {
2679
+ // Clear the marker and decrement together. A transaction rolls both
2680
+ // back when the counter update throws. A handle without one can commit
2681
+ // the clear first; the catch puts that marker back.
2682
+ await runAtomic(db, async (tx) => {
2683
+ const [releasedHold] = await tx
2684
+ .update(actionInvocations)
2685
+ .set({ spawnReservedAt: null })
2686
+ .where(and(holdWhere, eq(actionInvocations.spawnReservedAt, heldAt)))
2687
+ .returning({ id: actionInvocations.id });
2688
+ if (releasedHold)
2689
+ await releaseNodeCapacity(tx, invocation.workspaceId, nodeId);
2690
+ });
2691
+ }
2692
+ catch (error) {
2693
+ await db
2694
+ .update(actionInvocations)
2695
+ .set({ spawnReservedAt: heldAt })
2696
+ .where(and(holdWhere, isNull(actionInvocations.spawnReservedAt)));
2697
+ throw error;
2698
+ }
2479
2699
  }
2480
2700
  // A shadowed spawn stays pinned to its node: while a `spawn:<harness>` shadow
2481
2701
  // is registered there, native capacity for that harness is unreachable — even
@@ -2523,6 +2743,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2523
2743
  invocationOrigin: invocation.invocationOrigin,
2524
2744
  retryAfterAt: opts.retryAfterAt ?? null,
2525
2745
  expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
2746
+ ...observedInvocationClaim(invocation),
2526
2747
  });
2527
2748
  return dispatched.accepted;
2528
2749
  }
@@ -2572,6 +2793,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2572
2793
  queue: target.queue,
2573
2794
  reservationHeld: false,
2574
2795
  expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
2796
+ ...observedInvocationClaim(invocation),
2575
2797
  }, { failIfUnavailable: false });
2576
2798
  if (dispatched.providerUnavailable) {
2577
2799
  providerRejectedNodeIds.add(placement.node.id);
@@ -2589,15 +2811,32 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2589
2811
  const expectedActionId = invocation.invocationOrigin === 'registered_action'
2590
2812
  ? invocation.actionId
2591
2813
  : null;
2814
+ const queuedClaim = invocation.status === 'pending' || invocation.status === 'dispatched' || invocation.status === 'invoked'
2815
+ ? exclusiveClaimWrite({
2816
+ observedStatus: invocation.status,
2817
+ observedRetryAfterAt: invocation.retryAfterAt ?? null,
2818
+ observedDispatchAttempts: invocation.dispatchAttempts ?? 0,
2819
+ nextStatus: 'pending',
2820
+ nextRetryAfterAt: opts.retryAfterAt ?? null,
2821
+ incrementAttempts: true,
2822
+ })
2823
+ : null;
2592
2824
  const accepted = await dispatchNodeAttempt(db, invocation.workspaceId, invocation.id, placement.node.id, {
2593
2825
  providerName,
2594
2826
  pending: true,
2595
- retryAfterAt: opts.retryAfterAt ?? null,
2827
+ retryAfterAt: queuedClaim?.retryAfterAt ?? opts.retryAfterAt ?? null,
2596
2828
  reservationHeld: false,
2597
2829
  expectedAction: expectedActionId
2598
2830
  ? { id: expectedActionId, name: actionToSend }
2599
2831
  : undefined,
2600
2832
  targetActionId: target?.id,
2833
+ ...(queuedClaim ? {
2834
+ claim: {
2835
+ status: invocation.status,
2836
+ retryAfterAt: invocation.retryAfterAt ?? null,
2837
+ dispatchAttempts: invocation.dispatchAttempts ?? 0,
2838
+ },
2839
+ } : {}),
2601
2840
  expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
2602
2841
  });
2603
2842
  if (!accepted && invocation.invocationOrigin === 'registered_action') {
@@ -2605,21 +2844,41 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2605
2844
  }
2606
2845
  return accepted;
2607
2846
  }
2608
- const dispatched = await dispatchNodeInvocation({
2609
- db,
2610
- registry,
2611
- workspaceId: invocation.workspaceId,
2612
- invocationId: invocation.id,
2613
- nodeId: placement.node.id,
2614
- providerName,
2615
- action: actionToSend,
2616
- actionId: target?.id ?? null,
2617
- expectedActionId: invocation.actionId,
2618
- invocationOrigin: invocation.invocationOrigin,
2619
- input,
2620
- reservationHeld,
2621
- expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
2622
- });
2847
+ let dispatched;
2848
+ try {
2849
+ dispatched = await dispatchNodeInvocation({
2850
+ db,
2851
+ registry,
2852
+ workspaceId: invocation.workspaceId,
2853
+ invocationId: invocation.id,
2854
+ nodeId: placement.node.id,
2855
+ providerName,
2856
+ action: actionToSend,
2857
+ actionId: target?.id ?? null,
2858
+ expectedActionId: invocation.actionId,
2859
+ invocationOrigin: invocation.invocationOrigin,
2860
+ input,
2861
+ reservationHeld,
2862
+ retryAfterAt: opts.retryAfterAt ?? null,
2863
+ expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
2864
+ ...observedInvocationClaim(invocation),
2865
+ });
2866
+ }
2867
+ catch (error) {
2868
+ // A throw before the claim commits has no marker, so return the
2869
+ // new slot. A restore failure returns reservationStillHeld instead
2870
+ // of throwing, and the check below leaves that slot in place.
2871
+ if (reservationHeld) {
2872
+ await releaseNodeCapacity(db, invocation.workspaceId, placement.node.id);
2873
+ }
2874
+ throw error;
2875
+ }
2876
+ // claimSpawnNode incremented reservedAgents for this attempt. Release
2877
+ // it when the pending claim loses or the send never starts. Leave it
2878
+ // when the reservation marker is still set.
2879
+ if (!dispatched.accepted && !dispatched.sent && reservationHeld && !dispatched.reservationStillHeld) {
2880
+ await releaseNodeCapacity(db, invocation.workspaceId, placement.node.id);
2881
+ }
2623
2882
  return dispatched.accepted;
2624
2883
  }
2625
2884
  catch {
@@ -2642,7 +2901,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2642
2901
  spawnReservedAt: null,
2643
2902
  retryAfterAt: opts.retryAfterAt ?? nextRetryAfter(invocation.dispatchAttempts + 1),
2644
2903
  })
2645
- .where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(opts.expectedNodeLivenessRevision !== undefined
2904
+ .where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), eq(actionInvocations.status, invocation.status), eq(actionInvocations.dispatchAttempts, invocation.dispatchAttempts ?? 0), retryAfterAtMatches(invocation.retryAfterAt ?? null), ...(opts.expectedNodeLivenessRevision !== undefined
2646
2905
  ? [eq(actionInvocations.nodeLivenessRevision, opts.expectedNodeLivenessRevision)]
2647
2906
  : [])));
2648
2907
  return false;
@@ -2750,6 +3009,7 @@ export async function completeNodeInvocation(db, registry, workspaceId, nodeId,
2750
3009
  spawnReservedAt: actionInvocations.spawnReservedAt,
2751
3010
  attemptedNodeIds: actionInvocations.attemptedNodeIds,
2752
3011
  dispatchAttempts: actionInvocations.dispatchAttempts,
3012
+ retryAfterAt: actionInvocations.retryAfterAt,
2753
3013
  createdAt: actionInvocations.createdAt,
2754
3014
  completedAt: actionInvocations.completedAt,
2755
3015
  })
@@ -2866,11 +3126,9 @@ async function failNeverDispatchedExpiredInvocations(db, maxAgeMs, graceMs, comp
2866
3126
  .where(and(eq(actionInvocations.status, 'pending'), eq(actionInvocations.dispatchAttempts, 0), lte(actionInvocations.createdAt, cutoff)));
2867
3127
  if (rows.length === 0)
2868
3128
  return;
2869
- // Give any concurrent dispatcher's send→record window (`dispatchNodeAttempt`
2870
- // UPDATE, one D1 round-trip) time to close before the atomic UPDATE fires.
2871
- // Combined with the UPDATE's `dispatch_attempts = 0` re-check, this makes an
2872
- // in-flight dispatch reliably invisible to the age sweep instead of racing
2873
- // with it. See `NEVER_DISPATCHED_SWEEP_GRACE_MS`.
3129
+ // Unclaimed sends still record `dispatchAttempts` after the frame. Wait out
3130
+ // that gap, then the UPDATE's `dispatch_attempts = 0` re-check skips any row
3131
+ // the dispatcher has claimed. See `NEVER_DISPATCHED_SWEEP_GRACE_MS`.
2874
3132
  if (graceMs > 0) {
2875
3133
  await new Promise((resolve) => setTimeout(resolve, graceMs));
2876
3134
  }
@@ -2916,6 +3174,9 @@ async function failNeverDispatchedInvocationRows(db, workspaceId, invocationIds,
2916
3174
  .returning();
2917
3175
  }
2918
3176
  export async function sweepTimedOutInvocations(db, registry, opts = {}) {
3177
+ return withEngineOperation('invocations.sweep', undefined, () => sweepTimedOutInvocationsUnlabeled(db, registry, opts));
3178
+ }
3179
+ async function sweepTimedOutInvocationsUnlabeled(db, registry, opts) {
2919
3180
  await expireTaskInvocations(db);
2920
3181
  const sweepOpts = typeof opts === 'number' || opts === null ? {} : opts;
2921
3182
  const timeoutMs = typeof opts === 'number' ? opts : sweepOpts.timeoutMs ?? ACTION_DISPATCH_TIMEOUT_MS;