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