@relaycast/engine 10.2.0 → 10.3.1

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 (41) hide show
  1. package/dist/adapters/node/realtime.d.ts.map +1 -1
  2. package/dist/adapters/node/realtime.js +3 -1
  3. package/dist/adapters/node/realtime.js.map +1 -1
  4. package/dist/db/migrations/0071_node_liveness_revision.sql +123 -0
  5. package/dist/db/schema.d.ts +68 -0
  6. package/dist/db/schema.d.ts.map +1 -1
  7. package/dist/db/schema.js +20 -0
  8. package/dist/db/schema.js.map +1 -1
  9. package/dist/engine/action.d.ts +4 -1
  10. package/dist/engine/action.d.ts.map +1 -1
  11. package/dist/engine/action.js +174 -33
  12. package/dist/engine/action.js.map +1 -1
  13. package/dist/engine/delivery.d.ts.map +1 -1
  14. package/dist/engine/delivery.js +33 -2
  15. package/dist/engine/delivery.js.map +1 -1
  16. package/dist/engine/invocationCompletion.d.ts +2 -0
  17. package/dist/engine/invocationCompletion.d.ts.map +1 -1
  18. package/dist/engine/invocationCompletion.js +21 -6
  19. package/dist/engine/invocationCompletion.js.map +1 -1
  20. package/dist/engine/node.d.ts +46 -1
  21. package/dist/engine/node.d.ts.map +1 -1
  22. package/dist/engine/node.js +126 -29
  23. package/dist/engine/node.js.map +1 -1
  24. package/dist/engine/nodeProvider.d.ts +20 -0
  25. package/dist/engine/nodeProvider.d.ts.map +1 -1
  26. package/dist/engine/nodeProvider.js +69 -10
  27. package/dist/engine/nodeProvider.js.map +1 -1
  28. package/dist/engine/taskInvocation.d.ts +1 -0
  29. package/dist/engine/taskInvocation.d.ts.map +1 -1
  30. package/dist/engine/workspaceEvents.d.ts +11 -0
  31. package/dist/engine/workspaceEvents.d.ts.map +1 -1
  32. package/dist/engine/workspaceEvents.js +44 -0
  33. package/dist/engine/workspaceEvents.js.map +1 -1
  34. package/dist/node-control.d.ts +1 -1
  35. package/dist/node-control.d.ts.map +1 -1
  36. package/dist/node-control.js +6 -1
  37. package/dist/node-control.js.map +1 -1
  38. package/dist/ports/realtime.d.ts +2 -0
  39. package/dist/ports/realtime.d.ts.map +1 -1
  40. package/dist/ports/realtime.js.map +1 -1
  41. package/package.json +3 -3
@@ -1,4 +1,4 @@
1
- import { and, asc, eq, inArray, isNotNull, isNull, lte, or, sql } from 'drizzle-orm';
1
+ import { and, asc, eq, inArray, isNotNull, isNull, like, lt, lte, or, sql } from 'drizzle-orm';
2
2
  import { actions, actionInvocations, agents, agentNodeBindings, channelMembers, dmParticipants, nodes } from '../db/schema.js';
3
3
  import { waitForPendingInvocationRetry } from './invocationRetry.js';
4
4
  import { generateId } from './snowflake.js';
@@ -617,11 +617,13 @@ async function isNodeProviderLive(db, registry, workspaceId, nodeId, providerNam
617
617
  const provider = await getProvider(db, workspaceId, nodeId, providerName);
618
618
  return !!provider && isProviderLive(provider);
619
619
  }
620
- async function failInvocationForUnavailableProvider(db, workspaceId, invocationId) {
620
+ async function failInvocationForUnavailableProvider(db, workspaceId, invocationId, expectedNodeLivenessRevision) {
621
621
  await db
622
622
  .update(actionInvocations)
623
623
  .set({ status: 'failed', error: 'handler_unavailable', completedAt: new Date(), spawnReservedAt: null })
624
- .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
624
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(expectedNodeLivenessRevision !== undefined
625
+ ? [eq(actionInvocations.nodeLivenessRevision, expectedNodeLivenessRevision)]
626
+ : [])));
625
627
  }
626
628
  /**
627
629
  * Terminally fail one invocation ONLY while it is still open AND was never
@@ -648,7 +650,7 @@ async function failNeverDispatchedInvocation(db, workspaceId, invocationId, erro
648
650
  * enclosing transaction commits). A spawn invocation holding a native node
649
651
  * reservation releases that capacity, mirroring `completeNodeInvocation`.
650
652
  */
651
- async function failOpenInvocationRows(db, workspaceId, invocationIds, error) {
653
+ async function failOpenInvocationRows(db, workspaceId, invocationIds, error, expectedNodeLivenessRevision) {
652
654
  if (invocationIds.length === 0)
653
655
  return [];
654
656
  // Capture reservation state before the update clears spawnReservedAt.
@@ -660,11 +662,15 @@ async function failOpenInvocationRows(db, workspaceId, invocationIds, error) {
660
662
  spawnReservedAt: actionInvocations.spawnReservedAt,
661
663
  })
662
664
  .from(actionInvocations)
663
- .where(and(eq(actionInvocations.workspaceId, workspaceId), inArray(actionInvocations.id, invocationIds), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
665
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), inArray(actionInvocations.id, invocationIds), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(expectedNodeLivenessRevision !== undefined
666
+ ? [eq(actionInvocations.nodeLivenessRevision, expectedNodeLivenessRevision)]
667
+ : [])));
664
668
  const failed = await db
665
669
  .update(actionInvocations)
666
670
  .set({ status: 'failed', error, completedAt: new Date(), spawnReservedAt: null })
667
- .where(and(eq(actionInvocations.workspaceId, workspaceId), inArray(actionInvocations.id, invocationIds), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)))
671
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), inArray(actionInvocations.id, invocationIds), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(expectedNodeLivenessRevision !== undefined
672
+ ? [eq(actionInvocations.nodeLivenessRevision, expectedNodeLivenessRevision)]
673
+ : [])))
668
674
  .returning();
669
675
  const failedIds = new Set(failed.map((row) => row.id));
670
676
  for (const row of held) {
@@ -732,7 +738,7 @@ async function dispatchNodeProviderInvocation(args, options = {}) {
732
738
  if (options.failIfUnavailable === false) {
733
739
  return { accepted: false, pending: false, providerUnavailable: true };
734
740
  }
735
- await failInvocationForUnavailableProvider(args.db, args.workspaceId, args.invocationId);
741
+ await failInvocationForUnavailableProvider(args.db, args.workspaceId, args.invocationId, args.expectedNodeLivenessRevision);
736
742
  throw codedError(`Provider "${args.providerName}" is offline for action "${args.action}"`, 'handler_unavailable', 503);
737
743
  }
738
744
  // The DB is the authoritative offline queue. Do not call sendToProvider
@@ -749,9 +755,10 @@ async function dispatchNodeProviderInvocation(args, options = {}) {
749
755
  ? { id: expectedActionId, name: args.action }
750
756
  : undefined,
751
757
  targetActionId: args.actionId ?? undefined,
758
+ expectedNodeLivenessRevision: args.expectedNodeLivenessRevision,
752
759
  });
753
760
  if (!accepted && args.invocationOrigin === 'registered_action') {
754
- const settled = await settleDeletedRegisteredAction(args.db, args.workspaceId, args.invocationId);
761
+ const settled = await settleDeletedRegisteredAction(args.db, args.workspaceId, args.invocationId, args.expectedNodeLivenessRevision);
755
762
  return { accepted: false, pending: false, settled };
756
763
  }
757
764
  return { accepted, pending: true };
@@ -1258,6 +1265,16 @@ async function dispatchSpawn(args) {
1258
1265
  pending: placement.queued,
1259
1266
  reservationHeld: !placement.queued,
1260
1267
  });
1268
+ // Placement reserved capacity for an attempt that never landed. Return it
1269
+ // first, then mark the row unsent: only the mark makes it drainable, so a
1270
+ // drain never sees it while this send is in flight or still holding the
1271
+ // reservation.
1272
+ if (!dispatched.accepted && !placement.queued) {
1273
+ await releaseNodeCapacity(args.db, args.workspaceId, nodeId);
1274
+ }
1275
+ if (!dispatched.sent) {
1276
+ await markUnsentNativeSpawn(args.db, args.workspaceId, invocation.id, nodeId);
1277
+ }
1261
1278
  return spawnResult(invocation, nodeId, dispatched);
1262
1279
  }
1263
1280
  /**
@@ -1822,7 +1839,9 @@ async function dispatchNodeAttempt(db, workspaceId, invocationId, nodeId, opts)
1822
1839
  ...(opts.targetActionId ? { actionId: opts.targetActionId } : {}),
1823
1840
  ...attemptFields,
1824
1841
  })
1825
- .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(opts.expectedAction ? [
1842
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(opts.expectedNodeLivenessRevision !== undefined
1843
+ ? [eq(actionInvocations.nodeLivenessRevision, opts.expectedNodeLivenessRevision)]
1844
+ : []), ...(opts.expectedAction ? [
1826
1845
  eq(actionInvocations.invocationOrigin, 'registered_action'),
1827
1846
  eq(actionInvocations.actionId, opts.expectedAction.id),
1828
1847
  sql `EXISTS (
@@ -1885,7 +1904,9 @@ async function claimRegisteredActionHandoff(db, args) {
1885
1904
  actionId: claimedActionId,
1886
1905
  ...attemptFields,
1887
1906
  })
1888
- .where(and(eq(actionInvocations.workspaceId, args.workspaceId), eq(actionInvocations.id, args.invocationId), eq(actionInvocations.invocationOrigin, 'registered_action'), eq(actionInvocations.actionId, args.expectedActionId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), sql `NOT (
1907
+ .where(and(eq(actionInvocations.workspaceId, args.workspaceId), eq(actionInvocations.id, args.invocationId), eq(actionInvocations.invocationOrigin, 'registered_action'), eq(actionInvocations.actionId, args.expectedActionId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(args.expectedNodeLivenessRevision !== undefined
1908
+ ? [eq(actionInvocations.nodeLivenessRevision, args.expectedNodeLivenessRevision)]
1909
+ : []), sql `NOT (
1889
1910
  ${actionInvocations.actionId} = ${claimedActionId}
1890
1911
  AND COALESCE(${actionInvocations.dispatchedNodeId}, '') = ${args.nodeId}
1891
1912
  AND COALESCE(${actionInvocations.dispatchedProvider}, ${DEFAULT_PROVIDER_NAME}) = ${args.providerName}
@@ -1922,7 +1943,7 @@ async function bindInvocationToRegisteredNodeAction(db, args) {
1922
1943
  .returning({ id: actionInvocations.id });
1923
1944
  return !!bound;
1924
1945
  }
1925
- async function settleDeletedRegisteredAction(db, workspaceId, invocationId) {
1946
+ async function settleDeletedRegisteredAction(db, workspaceId, invocationId, expectedNodeLivenessRevision) {
1926
1947
  // A failed handoff can also mean another dispatcher already advanced the
1927
1948
  // same invocation to a different live action. Fail only the FK-null state
1928
1949
  // that proves pruning won; never clobber that concurrent dispatch winner.
@@ -1934,7 +1955,9 @@ async function settleDeletedRegisteredAction(db, workspaceId, invocationId) {
1934
1955
  completedAt: new Date(),
1935
1956
  spawnReservedAt: null,
1936
1957
  })
1937
- .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), eq(actionInvocations.invocationOrigin, 'registered_action'), isNull(actionInvocations.actionId), or(isNull(actionInvocations.providerAcceptedAttempt), sql `${actionInvocations.providerAcceptedAttempt} <> ${actionInvocations.dispatchAttempts}`), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)))
1958
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), eq(actionInvocations.invocationOrigin, 'registered_action'), isNull(actionInvocations.actionId), or(isNull(actionInvocations.providerAcceptedAttempt), sql `${actionInvocations.providerAcceptedAttempt} <> ${actionInvocations.dispatchAttempts}`), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(expectedNodeLivenessRevision !== undefined
1959
+ ? [eq(actionInvocations.nodeLivenessRevision, expectedNodeLivenessRevision)]
1960
+ : [])))
1938
1961
  .returning();
1939
1962
  if (failed)
1940
1963
  return failed;
@@ -1952,13 +1975,15 @@ async function settleRegisteredActionSendFailure(db, args) {
1952
1975
  .returning();
1953
1976
  return failed ?? settleDeletedRegisteredAction(db, args.workspaceId, args.invocationId);
1954
1977
  }
1955
- async function snapshotInvocationHandlerNode(db, workspaceId, invocationId, nodeId) {
1978
+ async function snapshotInvocationHandlerNode(db, workspaceId, invocationId, nodeId, expectedNodeLivenessRevision) {
1956
1979
  const [updated] = await db
1957
1980
  .update(actionInvocations)
1958
1981
  .set({
1959
1982
  handlerNodeId: sql `COALESCE(${actionInvocations.handlerNodeId}, ${nodeId})`,
1960
1983
  })
1961
- .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)))
1984
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(expectedNodeLivenessRevision !== undefined
1985
+ ? [eq(actionInvocations.nodeLivenessRevision, expectedNodeLivenessRevision)]
1986
+ : [])))
1962
1987
  .returning({ id: actionInvocations.id });
1963
1988
  return !!updated;
1964
1989
  }
@@ -2038,7 +2063,7 @@ async function dispatchNodeInvocation(args) {
2038
2063
  // owner performs the last-moment gate below immediately before the send.
2039
2064
  const snapshotted = args.agent
2040
2065
  ? true
2041
- : await snapshotInvocationHandlerNode(args.db, args.workspaceId, args.invocationId, args.nodeId);
2066
+ : await snapshotInvocationHandlerNode(args.db, args.workspaceId, args.invocationId, args.nodeId, args.expectedNodeLivenessRevision);
2042
2067
  if (!snapshotted)
2043
2068
  return { accepted: false, pending: false, sent: false };
2044
2069
  const connectedBefore = args.registry.isProviderConnected(args.workspaceId, args.nodeId, args.providerName);
@@ -2061,10 +2086,11 @@ async function dispatchNodeInvocation(args) {
2061
2086
  retryAfterAt: args.retryAfterAt,
2062
2087
  reservationHeld: args.reservationHeld,
2063
2088
  skipIncrementAttempts: args.skipIncrementAttempts,
2089
+ expectedNodeLivenessRevision: args.expectedNodeLivenessRevision,
2064
2090
  })
2065
2091
  : null;
2066
2092
  if (!registeredNodeClaim) {
2067
- const settled = await settleDeletedRegisteredAction(args.db, args.workspaceId, args.invocationId);
2093
+ const settled = await settleDeletedRegisteredAction(args.db, args.workspaceId, args.invocationId, args.expectedNodeLivenessRevision);
2068
2094
  return { accepted: false, pending: false, sent: false, settled };
2069
2095
  }
2070
2096
  }
@@ -2078,6 +2104,9 @@ async function dispatchNodeInvocation(args) {
2078
2104
  actionId: args.actionId,
2079
2105
  handlerAgentId: args.agent.id,
2080
2106
  recordAttempt: !args.skipIncrementAttempts,
2107
+ ...(args.expectedNodeLivenessRevision !== undefined
2108
+ ? { expectedNodeLivenessRevision: args.expectedNodeLivenessRevision }
2109
+ : {}),
2081
2110
  }) ?? false)
2082
2111
  : registeredNodeClaim
2083
2112
  ? await (args.registry.sendAuthorizedActionToProvider?.(args.workspaceId, args.nodeId, args.providerName, frame, {
@@ -2128,12 +2157,22 @@ async function dispatchNodeInvocation(args) {
2128
2157
  }
2129
2158
  return { accepted: false, pending: false, sent: false };
2130
2159
  }
2131
- // Registered node-action state was committed by the pre-send handoff CAS.
2132
- // Pruning after provider acceptance may clear the action FK, but it cannot
2133
- // revoke work the provider already received or prevent route-owned completion.
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.
2162
+ // Do not write either generation again after the frame is accepted.
2134
2163
  if (registeredNodeClaim) {
2135
2164
  return { accepted: true, pending, sent: true };
2136
2165
  }
2166
+ if (args.agent && args.actionId) {
2167
+ const [settled] = await args.db
2168
+ .select()
2169
+ .from(actionInvocations)
2170
+ .where(and(eq(actionInvocations.workspaceId, args.workspaceId), eq(actionInvocations.id, args.invocationId)));
2171
+ if (settled && !OPEN_INVOCATION_STATUSES.some((status) => status === settled.status)) {
2172
+ return { accepted: false, pending: false, sent: true, settled };
2173
+ }
2174
+ return { accepted: true, pending, sent: true };
2175
+ }
2137
2176
  const finalExpectedActionId = expectedActionId;
2138
2177
  const accepted = await dispatchNodeAttempt(args.db, args.workspaceId, args.invocationId, args.nodeId, {
2139
2178
  pending,
@@ -2151,9 +2190,10 @@ async function dispatchNodeInvocation(args) {
2151
2190
  targetActionId: args.agent || args.actionId
2152
2191
  ? finalExpectedActionId ?? undefined
2153
2192
  : undefined,
2193
+ expectedNodeLivenessRevision: args.expectedNodeLivenessRevision,
2154
2194
  });
2155
2195
  if (!accepted && args.invocationOrigin === 'registered_action') {
2156
- const settled = await settleDeletedRegisteredAction(args.db, args.workspaceId, args.invocationId);
2196
+ const settled = await settleDeletedRegisteredAction(args.db, args.workspaceId, args.invocationId, args.expectedNodeLivenessRevision);
2157
2197
  return { accepted: false, pending: false, sent: true, settled };
2158
2198
  }
2159
2199
  if (!accepted && (guardedReleaseHash || guardedReleaseAgentId)) {
@@ -2214,6 +2254,53 @@ async function targetAgentForInvocation(db, invocation) {
2214
2254
  providerName: row.providerName,
2215
2255
  };
2216
2256
  }
2257
+ /**
2258
+ * A native spawn whose first `action.invoke` send failed. A send can fail on a
2259
+ * live node — the provider socket is between reconnects, or the node's owner
2260
+ * rejects the send under pressure — and Cloud keeps no per-node frame queue, so
2261
+ * such a row records no dispatch attempt and no `dispatchedNodeId`. Its
2262
+ * placement survives only in the immutable `handlerNodeId` snapshot.
2263
+ *
2264
+ * `handlerUnreachableSince` marks the send as settled-unsent. The same
2265
+ * snapshot exists while a first send is still in flight, so the mark, not the
2266
+ * snapshot, is what lets a drain re-send. Built-in spawns have no action row,
2267
+ * so the agent-handler reachability sweep (which joins on one) never reads it.
2268
+ */
2269
+ function unsentNativeSpawnOn(workspaceId, invocationId, nodeId) {
2270
+ return and(eq(actionInvocations.workspaceId, workspaceId), ...(invocationId ? [eq(actionInvocations.id, invocationId)] : []), eq(actionInvocations.status, 'pending'), isNull(actionInvocations.actionId), isNull(actionInvocations.dispatchedNodeId), eq(actionInvocations.dispatchAttempts, 0), ...(nodeId ? [eq(actionInvocations.handlerNodeId, nodeId)] : []), or(eq(actionInvocations.actionName, 'spawn'), like(actionInvocations.actionName, 'spawn:%')));
2271
+ }
2272
+ async function markUnsentNativeSpawn(db, workspaceId, invocationId, nodeId) {
2273
+ await db
2274
+ .update(actionInvocations)
2275
+ .set({ handlerUnreachableSince: new Date() })
2276
+ .where(unsentNativeSpawnOn(workspaceId, invocationId, nodeId));
2277
+ }
2278
+ /** One drain wins the re-send; a loser, or a send still in flight, sees no mark. */
2279
+ async function claimUnsentNativeSpawn(db, workspaceId, invocationId) {
2280
+ const [claimed] = await db
2281
+ .update(actionInvocations)
2282
+ .set({ handlerUnreachableSince: null })
2283
+ .where(and(unsentNativeSpawnOn(workspaceId, invocationId, null), isNotNull(actionInvocations.handlerUnreachableSince)))
2284
+ .returning({ id: actionInvocations.id });
2285
+ return !!claimed;
2286
+ }
2287
+ /** `registry`, calling `onSend` before any provider send starts. */
2288
+ function observeSends(registry, onSend) {
2289
+ return new Proxy(registry, {
2290
+ get(target, key) {
2291
+ const value = Reflect.get(target, key, target);
2292
+ if (typeof value !== 'function')
2293
+ return value;
2294
+ if (key === 'sendToProvider' || key === 'sendAuthorizedActionToProvider') {
2295
+ return (...args) => {
2296
+ onSend();
2297
+ return Reflect.apply(value, target, args);
2298
+ };
2299
+ }
2300
+ return value.bind(target);
2301
+ },
2302
+ });
2303
+ }
2217
2304
  export async function drainNodeInvocations(db, registry, workspaceId, nodeId, options = {}) {
2218
2305
  const now = new Date();
2219
2306
  const rows = await db
@@ -2229,6 +2316,7 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId, op
2229
2316
  spawnReservedAt: actionInvocations.spawnReservedAt,
2230
2317
  dispatchedNodeId: actionInvocations.dispatchedNodeId,
2231
2318
  dispatchedProvider: actionInvocations.dispatchedProvider,
2319
+ placedNodeId: actionInvocations.handlerNodeId,
2232
2320
  handlerNodeId: actions.handlerNodeId,
2233
2321
  handlerProvider: actions.handlerProvider,
2234
2322
  handlerAgentId: agents.id,
@@ -2242,7 +2330,10 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId, op
2242
2330
  .leftJoin(agents, eq(actions.handlerAgentId, agents.id))
2243
2331
  .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.status, 'pending'), options.includeDeferred
2244
2332
  ? undefined
2245
- : or(isNull(actionInvocations.retryAfterAt), lte(actionInvocations.retryAfterAt, now)), or(eq(actionInvocations.dispatchedNodeId, nodeId), eq(actions.handlerNodeId, nodeId), and(eq(agents.locationType, 'via_node'), eq(agents.locationNodeId, nodeId)))))
2333
+ : or(isNull(actionInvocations.retryAfterAt), lte(actionInvocations.retryAfterAt, now)), or(eq(actionInvocations.dispatchedNodeId, nodeId), eq(actions.handlerNodeId, nodeId), and(eq(agents.locationType, 'via_node'), eq(agents.locationNodeId, nodeId)),
2334
+ // A native spawn whose first send failed on this node: no attempt
2335
+ // landed, so no other column names it.
2336
+ and(unsentNativeSpawnOn(workspaceId, null, nodeId), isNotNull(actionInvocations.handlerUnreachableSince)))))
2246
2337
  .orderBy(asc(actionInvocations.createdAt));
2247
2338
  let drained = 0;
2248
2339
  for (const row of rows) {
@@ -2256,7 +2347,13 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId, op
2256
2347
  && row.handlerAgentNodeId
2257
2348
  ? { id: row.handlerAgentId, name: row.handlerAgentName, nodeId: row.handlerAgentNodeId }
2258
2349
  : null;
2259
- const targetNodeId = targetAgent?.nodeId ?? row.handlerNodeId ?? row.dispatchedNodeId;
2350
+ // Only the unsent-spawn branch above matches a row with no action and no
2351
+ // dispatched node; its placed node is the target.
2352
+ const unsentSpawn = row.actionId === null && row.dispatchedNodeId === null;
2353
+ const targetNodeId = targetAgent?.nodeId
2354
+ ?? row.handlerNodeId
2355
+ ?? row.dispatchedNodeId
2356
+ ?? (unsentSpawn ? row.placedNodeId : null);
2260
2357
  if (targetNodeId !== nodeId)
2261
2358
  continue;
2262
2359
  const input = recordInput(row.input);
@@ -2284,12 +2381,24 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId, op
2284
2381
  // node, for which connection state remains the available liveness signal.
2285
2382
  if (!(await isHandlerConnectionLive(db, registry, workspaceId, nodeId, providerName)))
2286
2383
  continue;
2384
+ // Claim before reserving, so concurrent drains cannot both reserve and send.
2385
+ if (unsentSpawn && !await claimUnsentNativeSpawn(db, workspaceId, row.id))
2386
+ continue;
2387
+ // A definitely-unsent outcome below restores the mark, after returning
2388
+ // any reservation this drain took. Whether a send started separates a
2389
+ // pre-send throw (unsent) from a throw with an unknown outcome.
2390
+ const remarkUnsent = () => unsentSpawn
2391
+ ? markUnsentNativeSpawn(db, workspaceId, row.id, nodeId)
2392
+ : Promise.resolve();
2393
+ let sendStarted = false;
2394
+ const sendRegistry = unsentSpawn ? observeSends(registry, () => { sendStarted = true; }) : registry;
2287
2395
  let reservedForDrain = false;
2288
2396
  try {
2289
2397
  let reservationHeld = nativeSpawn && !!row.spawnReservedAt;
2290
2398
  if (nativeSpawn && !reservationHeld) {
2291
2399
  const reserved = await reserveNodeCapacity(db, workspaceId, nodeId);
2292
2400
  if (!reserved) {
2401
+ await remarkUnsent();
2293
2402
  const [node] = await db
2294
2403
  .select({
2295
2404
  status: nodes.status,
@@ -2311,7 +2420,7 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId, op
2311
2420
  }
2312
2421
  const dispatched = await dispatchNodeInvocation({
2313
2422
  db,
2314
- registry,
2423
+ registry: sendRegistry,
2315
2424
  workspaceId,
2316
2425
  invocationId: row.id,
2317
2426
  nodeId,
@@ -2331,13 +2440,21 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId, op
2331
2440
  }
2332
2441
  else if (reservedForDrain) {
2333
2442
  await releaseNodeCapacity(db, workspaceId, nodeId);
2443
+ reservedForDrain = false;
2334
2444
  }
2445
+ if (!dispatched.sent)
2446
+ await remarkUnsent();
2335
2447
  }
2336
2448
  catch {
2337
2449
  if (reservedForDrain) {
2338
2450
  await releaseNodeCapacity(db, workspaceId, nodeId).catch(() => { });
2339
2451
  }
2340
2452
  // Leave the invocation pending; a later drain or timeout sweep can retry.
2453
+ // A throw before any send leaves the spawn unsent, so it is re-marked. A
2454
+ // throw once a send started is an unknown outcome, so the spawn stays
2455
+ // unmarked and only the never-dispatched sweep ends it.
2456
+ if (!sendStarted)
2457
+ await remarkUnsent().catch(() => { });
2341
2458
  }
2342
2459
  }
2343
2460
  return drained;
@@ -2351,7 +2468,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2351
2468
  return false;
2352
2469
  }
2353
2470
  if (isDeletedRegisteredActionInvocation(invocation)) {
2354
- await failOpenInvocationRows(db, invocation.workspaceId, [invocation.id], 'action_deleted');
2471
+ await failOpenInvocationRows(db, invocation.workspaceId, [invocation.id], 'action_deleted', opts.expectedNodeLivenessRevision);
2355
2472
  return false;
2356
2473
  }
2357
2474
  // Only release capacity the invocation actually holds. A shadowed spawn
@@ -2368,10 +2485,15 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2368
2485
  const capability = dispatchActionNameForInvocation(invocation.actionName, recordInput(invocation.input));
2369
2486
  const shadow = await fetchNodeAction(db, invocation.workspaceId, invocation.dispatchedNodeId, capability);
2370
2487
  if (shadow) {
2371
- await db
2488
+ const [reset] = await db
2372
2489
  .update(actionInvocations)
2373
2490
  .set({ status: 'pending', dispatchedAt: null, spawnReservedAt: null, retryAfterAt: null })
2374
- .where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
2491
+ .where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(opts.expectedNodeLivenessRevision !== undefined
2492
+ ? [eq(actionInvocations.nodeLivenessRevision, opts.expectedNodeLivenessRevision)]
2493
+ : [])))
2494
+ .returning({ id: actionInvocations.id });
2495
+ if (!reset)
2496
+ return false;
2375
2497
  return false;
2376
2498
  }
2377
2499
  }
@@ -2391,6 +2513,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2391
2513
  expectedActionId: invocation.actionId,
2392
2514
  invocationOrigin: invocation.invocationOrigin,
2393
2515
  retryAfterAt: opts.retryAfterAt ?? null,
2516
+ expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
2394
2517
  });
2395
2518
  return dispatched.accepted;
2396
2519
  }
@@ -2439,6 +2562,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2439
2562
  input,
2440
2563
  queue: target.queue,
2441
2564
  reservationHeld: false,
2565
+ expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
2442
2566
  }, { failIfUnavailable: false });
2443
2567
  if (dispatched.providerUnavailable) {
2444
2568
  providerRejectedNodeIds.add(placement.node.id);
@@ -2465,9 +2589,10 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2465
2589
  ? { id: expectedActionId, name: actionToSend }
2466
2590
  : undefined,
2467
2591
  targetActionId: target?.id,
2592
+ expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
2468
2593
  });
2469
2594
  if (!accepted && invocation.invocationOrigin === 'registered_action') {
2470
- await settleDeletedRegisteredAction(db, invocation.workspaceId, invocation.id);
2595
+ await settleDeletedRegisteredAction(db, invocation.workspaceId, invocation.id, opts.expectedNodeLivenessRevision);
2471
2596
  }
2472
2597
  return accepted;
2473
2598
  }
@@ -2484,6 +2609,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2484
2609
  invocationOrigin: invocation.invocationOrigin,
2485
2610
  input,
2486
2611
  reservationHeld,
2612
+ expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
2487
2613
  });
2488
2614
  return dispatched.accepted;
2489
2615
  }
@@ -2494,7 +2620,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2494
2620
  }
2495
2621
  }
2496
2622
  if (providerRejectedNodeIds.size > 0) {
2497
- await failInvocationForUnavailableProvider(db, invocation.workspaceId, invocation.id);
2623
+ await failInvocationForUnavailableProvider(db, invocation.workspaceId, invocation.id, opts.expectedNodeLivenessRevision);
2498
2624
  return false;
2499
2625
  }
2500
2626
  await db
@@ -2507,18 +2633,31 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2507
2633
  spawnReservedAt: null,
2508
2634
  retryAfterAt: opts.retryAfterAt ?? nextRetryAfter(invocation.dispatchAttempts + 1),
2509
2635
  })
2510
- .where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
2636
+ .where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(opts.expectedNodeLivenessRevision !== undefined
2637
+ ? [eq(actionInvocations.nodeLivenessRevision, opts.expectedNodeLivenessRevision)]
2638
+ : [])));
2511
2639
  return false;
2512
2640
  }
2513
- export async function rescheduleInvocationsForLostNode(db, registry, workspaceId, nodeId) {
2641
+ export async function rescheduleInvocationsForLostNode(db, registry, workspaceId, nodeId, opts = {}) {
2514
2642
  const rows = await db
2515
2643
  .select()
2516
2644
  .from(actionInvocations)
2517
- .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.dispatchedNodeId, nodeId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
2645
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.dispatchedNodeId, nodeId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES),
2646
+ // The dispatch trigger stamps the accepting node generation. A reconnect
2647
+ // can dispatch new work while settlement runs, including in the same
2648
+ // timestamp second; only attempts older than this claim are recoverable.
2649
+ ...(opts.claimRevision !== undefined
2650
+ ? [lt(actionInvocations.nodeLivenessRevision, opts.claimRevision)]
2651
+ : [])));
2518
2652
  let rescheduled = 0;
2519
2653
  for (const invocation of rows) {
2520
2654
  try {
2521
- if (await rescheduleNodeInvocation(db, registry, invocation, { retryAfterAt: nextRetryAfter(invocation.dispatchAttempts + 1) })) {
2655
+ if (await rescheduleNodeInvocation(db, registry, invocation, {
2656
+ retryAfterAt: nextRetryAfter(invocation.dispatchAttempts + 1),
2657
+ ...(opts.claimRevision !== undefined
2658
+ ? { expectedNodeLivenessRevision: invocation.nodeLivenessRevision }
2659
+ : {}),
2660
+ })) {
2522
2661
  rescheduled++;
2523
2662
  }
2524
2663
  }
@@ -2534,7 +2673,9 @@ export async function rescheduleInvocationsForLostNode(db, registry, workspaceId
2534
2673
  spawnReservedAt: null,
2535
2674
  retryAfterAt: nextRetryAfter(invocation.dispatchAttempts + 1),
2536
2675
  })
2537
- .where(eq(actionInvocations.id, invocation.id));
2676
+ .where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(opts.claimRevision !== undefined
2677
+ ? [eq(actionInvocations.nodeLivenessRevision, invocation.nodeLivenessRevision)]
2678
+ : [])));
2538
2679
  }
2539
2680
  }
2540
2681
  return rescheduled;