@relaycast/engine 10.1.1 → 10.3.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 (50) 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/realtime.d.ts +4 -0
  5. package/dist/adapters/node/realtime.d.ts.map +1 -1
  6. package/dist/adapters/node/realtime.js +9 -1
  7. package/dist/adapters/node/realtime.js.map +1 -1
  8. package/dist/db/migrations/0070_agent_liveness_status.sql +16 -0
  9. package/dist/db/migrations/0071_node_liveness_revision.sql +123 -0
  10. package/dist/db/schema.d.ts +87 -0
  11. package/dist/db/schema.d.ts.map +1 -1
  12. package/dist/db/schema.js +22 -0
  13. package/dist/db/schema.js.map +1 -1
  14. package/dist/engine/action.d.ts +4 -1
  15. package/dist/engine/action.d.ts.map +1 -1
  16. package/dist/engine/action.js +84 -30
  17. package/dist/engine/action.js.map +1 -1
  18. package/dist/engine/agent.d.ts +11 -0
  19. package/dist/engine/agent.d.ts.map +1 -1
  20. package/dist/engine/agent.js +16 -2
  21. package/dist/engine/agent.js.map +1 -1
  22. package/dist/engine/delivery.d.ts.map +1 -1
  23. package/dist/engine/delivery.js +33 -2
  24. package/dist/engine/delivery.js.map +1 -1
  25. package/dist/engine/invocationCompletion.d.ts +2 -0
  26. package/dist/engine/invocationCompletion.d.ts.map +1 -1
  27. package/dist/engine/invocationCompletion.js +21 -6
  28. package/dist/engine/invocationCompletion.js.map +1 -1
  29. package/dist/engine/node.d.ts +51 -1
  30. package/dist/engine/node.d.ts.map +1 -1
  31. package/dist/engine/node.js +155 -33
  32. package/dist/engine/node.js.map +1 -1
  33. package/dist/engine/nodeProvider.d.ts +20 -0
  34. package/dist/engine/nodeProvider.d.ts.map +1 -1
  35. package/dist/engine/nodeProvider.js +69 -10
  36. package/dist/engine/nodeProvider.js.map +1 -1
  37. package/dist/engine/taskInvocation.d.ts +1 -0
  38. package/dist/engine/taskInvocation.d.ts.map +1 -1
  39. package/dist/engine/workspaceEvents.d.ts +11 -0
  40. package/dist/engine/workspaceEvents.d.ts.map +1 -1
  41. package/dist/engine/workspaceEvents.js +44 -0
  42. package/dist/engine/workspaceEvents.js.map +1 -1
  43. package/dist/node-control.d.ts +1 -1
  44. package/dist/node-control.d.ts.map +1 -1
  45. package/dist/node-control.js +6 -1
  46. package/dist/node-control.js.map +1 -1
  47. package/dist/ports/realtime.d.ts +2 -0
  48. package/dist/ports/realtime.d.ts.map +1 -1
  49. package/dist/ports/realtime.js.map +1 -1
  50. 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, 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 };
@@ -1822,7 +1829,9 @@ async function dispatchNodeAttempt(db, workspaceId, invocationId, nodeId, opts)
1822
1829
  ...(opts.targetActionId ? { actionId: opts.targetActionId } : {}),
1823
1830
  ...attemptFields,
1824
1831
  })
1825
- .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(opts.expectedAction ? [
1832
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(opts.expectedNodeLivenessRevision !== undefined
1833
+ ? [eq(actionInvocations.nodeLivenessRevision, opts.expectedNodeLivenessRevision)]
1834
+ : []), ...(opts.expectedAction ? [
1826
1835
  eq(actionInvocations.invocationOrigin, 'registered_action'),
1827
1836
  eq(actionInvocations.actionId, opts.expectedAction.id),
1828
1837
  sql `EXISTS (
@@ -1885,7 +1894,9 @@ async function claimRegisteredActionHandoff(db, args) {
1885
1894
  actionId: claimedActionId,
1886
1895
  ...attemptFields,
1887
1896
  })
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 (
1897
+ .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
1898
+ ? [eq(actionInvocations.nodeLivenessRevision, args.expectedNodeLivenessRevision)]
1899
+ : []), sql `NOT (
1889
1900
  ${actionInvocations.actionId} = ${claimedActionId}
1890
1901
  AND COALESCE(${actionInvocations.dispatchedNodeId}, '') = ${args.nodeId}
1891
1902
  AND COALESCE(${actionInvocations.dispatchedProvider}, ${DEFAULT_PROVIDER_NAME}) = ${args.providerName}
@@ -1922,7 +1933,7 @@ async function bindInvocationToRegisteredNodeAction(db, args) {
1922
1933
  .returning({ id: actionInvocations.id });
1923
1934
  return !!bound;
1924
1935
  }
1925
- async function settleDeletedRegisteredAction(db, workspaceId, invocationId) {
1936
+ async function settleDeletedRegisteredAction(db, workspaceId, invocationId, expectedNodeLivenessRevision) {
1926
1937
  // A failed handoff can also mean another dispatcher already advanced the
1927
1938
  // same invocation to a different live action. Fail only the FK-null state
1928
1939
  // that proves pruning won; never clobber that concurrent dispatch winner.
@@ -1934,7 +1945,9 @@ async function settleDeletedRegisteredAction(db, workspaceId, invocationId) {
1934
1945
  completedAt: new Date(),
1935
1946
  spawnReservedAt: null,
1936
1947
  })
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)))
1948
+ .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
1949
+ ? [eq(actionInvocations.nodeLivenessRevision, expectedNodeLivenessRevision)]
1950
+ : [])))
1938
1951
  .returning();
1939
1952
  if (failed)
1940
1953
  return failed;
@@ -1952,13 +1965,15 @@ async function settleRegisteredActionSendFailure(db, args) {
1952
1965
  .returning();
1953
1966
  return failed ?? settleDeletedRegisteredAction(db, args.workspaceId, args.invocationId);
1954
1967
  }
1955
- async function snapshotInvocationHandlerNode(db, workspaceId, invocationId, nodeId) {
1968
+ async function snapshotInvocationHandlerNode(db, workspaceId, invocationId, nodeId, expectedNodeLivenessRevision) {
1956
1969
  const [updated] = await db
1957
1970
  .update(actionInvocations)
1958
1971
  .set({
1959
1972
  handlerNodeId: sql `COALESCE(${actionInvocations.handlerNodeId}, ${nodeId})`,
1960
1973
  })
1961
- .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)))
1974
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(expectedNodeLivenessRevision !== undefined
1975
+ ? [eq(actionInvocations.nodeLivenessRevision, expectedNodeLivenessRevision)]
1976
+ : [])))
1962
1977
  .returning({ id: actionInvocations.id });
1963
1978
  return !!updated;
1964
1979
  }
@@ -2038,7 +2053,7 @@ async function dispatchNodeInvocation(args) {
2038
2053
  // owner performs the last-moment gate below immediately before the send.
2039
2054
  const snapshotted = args.agent
2040
2055
  ? true
2041
- : await snapshotInvocationHandlerNode(args.db, args.workspaceId, args.invocationId, args.nodeId);
2056
+ : await snapshotInvocationHandlerNode(args.db, args.workspaceId, args.invocationId, args.nodeId, args.expectedNodeLivenessRevision);
2042
2057
  if (!snapshotted)
2043
2058
  return { accepted: false, pending: false, sent: false };
2044
2059
  const connectedBefore = args.registry.isProviderConnected(args.workspaceId, args.nodeId, args.providerName);
@@ -2061,10 +2076,11 @@ async function dispatchNodeInvocation(args) {
2061
2076
  retryAfterAt: args.retryAfterAt,
2062
2077
  reservationHeld: args.reservationHeld,
2063
2078
  skipIncrementAttempts: args.skipIncrementAttempts,
2079
+ expectedNodeLivenessRevision: args.expectedNodeLivenessRevision,
2064
2080
  })
2065
2081
  : null;
2066
2082
  if (!registeredNodeClaim) {
2067
- const settled = await settleDeletedRegisteredAction(args.db, args.workspaceId, args.invocationId);
2083
+ const settled = await settleDeletedRegisteredAction(args.db, args.workspaceId, args.invocationId, args.expectedNodeLivenessRevision);
2068
2084
  return { accepted: false, pending: false, sent: false, settled };
2069
2085
  }
2070
2086
  }
@@ -2078,6 +2094,9 @@ async function dispatchNodeInvocation(args) {
2078
2094
  actionId: args.actionId,
2079
2095
  handlerAgentId: args.agent.id,
2080
2096
  recordAttempt: !args.skipIncrementAttempts,
2097
+ ...(args.expectedNodeLivenessRevision !== undefined
2098
+ ? { expectedNodeLivenessRevision: args.expectedNodeLivenessRevision }
2099
+ : {}),
2081
2100
  }) ?? false)
2082
2101
  : registeredNodeClaim
2083
2102
  ? await (args.registry.sendAuthorizedActionToProvider?.(args.workspaceId, args.nodeId, args.providerName, frame, {
@@ -2128,12 +2147,22 @@ async function dispatchNodeInvocation(args) {
2128
2147
  }
2129
2148
  return { accepted: false, pending: false, sent: false };
2130
2149
  }
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.
2150
+ // Registered node-action state was committed by the pre-send handoff CAS;
2151
+ // agent-action state was committed by the socket owner's authorization CAS.
2152
+ // Do not write either generation again after the frame is accepted.
2134
2153
  if (registeredNodeClaim) {
2135
2154
  return { accepted: true, pending, sent: true };
2136
2155
  }
2156
+ if (args.agent && args.actionId) {
2157
+ const [settled] = await args.db
2158
+ .select()
2159
+ .from(actionInvocations)
2160
+ .where(and(eq(actionInvocations.workspaceId, args.workspaceId), eq(actionInvocations.id, args.invocationId)));
2161
+ if (settled && !OPEN_INVOCATION_STATUSES.some((status) => status === settled.status)) {
2162
+ return { accepted: false, pending: false, sent: true, settled };
2163
+ }
2164
+ return { accepted: true, pending, sent: true };
2165
+ }
2137
2166
  const finalExpectedActionId = expectedActionId;
2138
2167
  const accepted = await dispatchNodeAttempt(args.db, args.workspaceId, args.invocationId, args.nodeId, {
2139
2168
  pending,
@@ -2151,9 +2180,10 @@ async function dispatchNodeInvocation(args) {
2151
2180
  targetActionId: args.agent || args.actionId
2152
2181
  ? finalExpectedActionId ?? undefined
2153
2182
  : undefined,
2183
+ expectedNodeLivenessRevision: args.expectedNodeLivenessRevision,
2154
2184
  });
2155
2185
  if (!accepted && args.invocationOrigin === 'registered_action') {
2156
- const settled = await settleDeletedRegisteredAction(args.db, args.workspaceId, args.invocationId);
2186
+ const settled = await settleDeletedRegisteredAction(args.db, args.workspaceId, args.invocationId, args.expectedNodeLivenessRevision);
2157
2187
  return { accepted: false, pending: false, sent: true, settled };
2158
2188
  }
2159
2189
  if (!accepted && (guardedReleaseHash || guardedReleaseAgentId)) {
@@ -2351,7 +2381,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2351
2381
  return false;
2352
2382
  }
2353
2383
  if (isDeletedRegisteredActionInvocation(invocation)) {
2354
- await failOpenInvocationRows(db, invocation.workspaceId, [invocation.id], 'action_deleted');
2384
+ await failOpenInvocationRows(db, invocation.workspaceId, [invocation.id], 'action_deleted', opts.expectedNodeLivenessRevision);
2355
2385
  return false;
2356
2386
  }
2357
2387
  // Only release capacity the invocation actually holds. A shadowed spawn
@@ -2368,10 +2398,15 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2368
2398
  const capability = dispatchActionNameForInvocation(invocation.actionName, recordInput(invocation.input));
2369
2399
  const shadow = await fetchNodeAction(db, invocation.workspaceId, invocation.dispatchedNodeId, capability);
2370
2400
  if (shadow) {
2371
- await db
2401
+ const [reset] = await db
2372
2402
  .update(actionInvocations)
2373
2403
  .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)));
2404
+ .where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(opts.expectedNodeLivenessRevision !== undefined
2405
+ ? [eq(actionInvocations.nodeLivenessRevision, opts.expectedNodeLivenessRevision)]
2406
+ : [])))
2407
+ .returning({ id: actionInvocations.id });
2408
+ if (!reset)
2409
+ return false;
2375
2410
  return false;
2376
2411
  }
2377
2412
  }
@@ -2391,6 +2426,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2391
2426
  expectedActionId: invocation.actionId,
2392
2427
  invocationOrigin: invocation.invocationOrigin,
2393
2428
  retryAfterAt: opts.retryAfterAt ?? null,
2429
+ expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
2394
2430
  });
2395
2431
  return dispatched.accepted;
2396
2432
  }
@@ -2439,6 +2475,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2439
2475
  input,
2440
2476
  queue: target.queue,
2441
2477
  reservationHeld: false,
2478
+ expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
2442
2479
  }, { failIfUnavailable: false });
2443
2480
  if (dispatched.providerUnavailable) {
2444
2481
  providerRejectedNodeIds.add(placement.node.id);
@@ -2465,9 +2502,10 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2465
2502
  ? { id: expectedActionId, name: actionToSend }
2466
2503
  : undefined,
2467
2504
  targetActionId: target?.id,
2505
+ expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
2468
2506
  });
2469
2507
  if (!accepted && invocation.invocationOrigin === 'registered_action') {
2470
- await settleDeletedRegisteredAction(db, invocation.workspaceId, invocation.id);
2508
+ await settleDeletedRegisteredAction(db, invocation.workspaceId, invocation.id, opts.expectedNodeLivenessRevision);
2471
2509
  }
2472
2510
  return accepted;
2473
2511
  }
@@ -2484,6 +2522,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2484
2522
  invocationOrigin: invocation.invocationOrigin,
2485
2523
  input,
2486
2524
  reservationHeld,
2525
+ expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
2487
2526
  });
2488
2527
  return dispatched.accepted;
2489
2528
  }
@@ -2494,7 +2533,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2494
2533
  }
2495
2534
  }
2496
2535
  if (providerRejectedNodeIds.size > 0) {
2497
- await failInvocationForUnavailableProvider(db, invocation.workspaceId, invocation.id);
2536
+ await failInvocationForUnavailableProvider(db, invocation.workspaceId, invocation.id, opts.expectedNodeLivenessRevision);
2498
2537
  return false;
2499
2538
  }
2500
2539
  await db
@@ -2507,18 +2546,31 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
2507
2546
  spawnReservedAt: null,
2508
2547
  retryAfterAt: opts.retryAfterAt ?? nextRetryAfter(invocation.dispatchAttempts + 1),
2509
2548
  })
2510
- .where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
2549
+ .where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(opts.expectedNodeLivenessRevision !== undefined
2550
+ ? [eq(actionInvocations.nodeLivenessRevision, opts.expectedNodeLivenessRevision)]
2551
+ : [])));
2511
2552
  return false;
2512
2553
  }
2513
- export async function rescheduleInvocationsForLostNode(db, registry, workspaceId, nodeId) {
2554
+ export async function rescheduleInvocationsForLostNode(db, registry, workspaceId, nodeId, opts = {}) {
2514
2555
  const rows = await db
2515
2556
  .select()
2516
2557
  .from(actionInvocations)
2517
- .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.dispatchedNodeId, nodeId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
2558
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.dispatchedNodeId, nodeId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES),
2559
+ // The dispatch trigger stamps the accepting node generation. A reconnect
2560
+ // can dispatch new work while settlement runs, including in the same
2561
+ // timestamp second; only attempts older than this claim are recoverable.
2562
+ ...(opts.claimRevision !== undefined
2563
+ ? [lt(actionInvocations.nodeLivenessRevision, opts.claimRevision)]
2564
+ : [])));
2518
2565
  let rescheduled = 0;
2519
2566
  for (const invocation of rows) {
2520
2567
  try {
2521
- if (await rescheduleNodeInvocation(db, registry, invocation, { retryAfterAt: nextRetryAfter(invocation.dispatchAttempts + 1) })) {
2568
+ if (await rescheduleNodeInvocation(db, registry, invocation, {
2569
+ retryAfterAt: nextRetryAfter(invocation.dispatchAttempts + 1),
2570
+ ...(opts.claimRevision !== undefined
2571
+ ? { expectedNodeLivenessRevision: invocation.nodeLivenessRevision }
2572
+ : {}),
2573
+ })) {
2522
2574
  rescheduled++;
2523
2575
  }
2524
2576
  }
@@ -2534,7 +2586,9 @@ export async function rescheduleInvocationsForLostNode(db, registry, workspaceId
2534
2586
  spawnReservedAt: null,
2535
2587
  retryAfterAt: nextRetryAfter(invocation.dispatchAttempts + 1),
2536
2588
  })
2537
- .where(eq(actionInvocations.id, invocation.id));
2589
+ .where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), ...(opts.claimRevision !== undefined
2590
+ ? [eq(actionInvocations.nodeLivenessRevision, invocation.nodeLivenessRevision)]
2591
+ : [])));
2538
2592
  }
2539
2593
  }
2540
2594
  return rescheduled;