@relaycast/engine 8.8.0 → 8.9.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 (54) hide show
  1. package/dist/adapters/node/realtime.d.ts.map +1 -1
  2. package/dist/adapters/node/realtime.js +13 -4
  3. package/dist/adapters/node/realtime.js.map +1 -1
  4. package/dist/db/migrations/0053_agent_retention_indexes.sql +8 -0
  5. package/dist/db/migrations/0054_node_status_heartbeat_index.sql +8 -0
  6. package/dist/db/migrations/0055_session_event_idempotency.sql +8 -0
  7. package/dist/db/migrations/0056_session_event_status_completion.sql +38 -0
  8. package/dist/db/schema.d.ts +89 -0
  9. package/dist/db/schema.d.ts.map +1 -1
  10. package/dist/db/schema.js +27 -0
  11. package/dist/db/schema.js.map +1 -1
  12. package/dist/engine/action.d.ts +4 -0
  13. package/dist/engine/action.d.ts.map +1 -1
  14. package/dist/engine/action.js +104 -35
  15. package/dist/engine/action.js.map +1 -1
  16. package/dist/engine/agentRetention.d.ts +39 -0
  17. package/dist/engine/agentRetention.d.ts.map +1 -0
  18. package/dist/engine/agentRetention.js +117 -0
  19. package/dist/engine/agentRetention.js.map +1 -0
  20. package/dist/engine/delivery.d.ts.map +1 -1
  21. package/dist/engine/delivery.js +6 -1
  22. package/dist/engine/delivery.js.map +1 -1
  23. package/dist/engine/node.d.ts +51 -28
  24. package/dist/engine/node.d.ts.map +1 -1
  25. package/dist/engine/node.js +140 -10
  26. package/dist/engine/node.js.map +1 -1
  27. package/dist/engine/retention.d.ts +12 -0
  28. package/dist/engine/retention.d.ts.map +1 -1
  29. package/dist/engine/retention.js +3 -0
  30. package/dist/engine/retention.js.map +1 -1
  31. package/dist/engine/rowidRetention.d.ts +33 -0
  32. package/dist/engine/rowidRetention.d.ts.map +1 -0
  33. package/dist/engine/rowidRetention.js +190 -0
  34. package/dist/engine/rowidRetention.js.map +1 -0
  35. package/dist/engine/sessionEvent.d.ts +54 -1
  36. package/dist/engine/sessionEvent.d.ts.map +1 -1
  37. package/dist/engine/sessionEvent.js +195 -2
  38. package/dist/engine/sessionEvent.js.map +1 -1
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/ports/realtime.d.ts +7 -2
  43. package/dist/ports/realtime.d.ts.map +1 -1
  44. package/dist/ports/realtime.js.map +1 -1
  45. package/dist/routes/agent.d.ts.map +1 -1
  46. package/dist/routes/agent.js +113 -10
  47. package/dist/routes/agent.js.map +1 -1
  48. package/dist/routes/deliveryRouting.d.ts +1 -0
  49. package/dist/routes/deliveryRouting.d.ts.map +1 -1
  50. package/dist/routes/deliveryRouting.js +8 -4
  51. package/dist/routes/deliveryRouting.js.map +1 -1
  52. package/dist/routes/node.js +39 -15
  53. package/dist/routes/node.js.map +1 -1
  54. package/package.json +3 -3
@@ -83,6 +83,7 @@ function isAcceptedDeletedRegisteredActionInvocation(invocation) {
83
83
  && invocation.providerAcceptedAttempt === invocation.dispatchAttempts;
84
84
  }
85
85
  const RELEASE_GENERATION_CONFLICT_CODE = 'agent_release_generation_conflict';
86
+ const RELEASE_IDENTITY_MISMATCH_CODE = 'agent_identity_mismatch';
86
87
  function releaseExpectedTokenHash(input) {
87
88
  const value = input.expected_token_hash;
88
89
  if (value === undefined)
@@ -92,11 +93,21 @@ function releaseExpectedTokenHash(input) {
92
93
  }
93
94
  return value;
94
95
  }
95
- function releaseGenerationStillCurrent(workspaceId, agentId, expectedTokenHash) {
96
+ function releaseExpectedAgentId(input) {
97
+ const value = input.expected_agent_id;
98
+ if (value === undefined)
99
+ return null;
100
+ if (typeof value !== 'string' || value.trim().length === 0) {
101
+ throw codedError('release action input.expected_agent_id must be a non-empty immutable agent id', 'invalid_release_request', 400);
102
+ }
103
+ return value;
104
+ }
105
+ function releaseGenerationStillCurrent(workspaceId, agentId, expectedTokenHash, expectedAgentId = null) {
96
106
  return sql `EXISTS (
97
107
  SELECT 1 FROM ${agents}
98
108
  WHERE ${agents.workspaceId} = ${workspaceId}
99
109
  AND ${agents.id} = ${agentId}
110
+ ${expectedAgentId ? sql `AND ${agents.id} = ${expectedAgentId}` : sql ``}
100
111
  ${expectedTokenHash ? sql `AND ${agents.tokenHash} = ${expectedTokenHash}` : sql ``}
101
112
  )`;
102
113
  }
@@ -490,8 +501,10 @@ async function waitForInvocationReplayOutcome(db, workspaceId, invocation, optio
490
501
  // replay shortcut, otherwise the same idempotency key changes 409 into 201.
491
502
  if (isBuiltinReleaseInvocation(current)
492
503
  && current.status === 'failed'
493
- && current.error === RELEASE_GENERATION_CONFLICT_CODE) {
494
- throw codedError('Action invocation failed because the release generation changed', RELEASE_GENERATION_CONFLICT_CODE, 409);
504
+ && (current.error === RELEASE_GENERATION_CONFLICT_CODE || current.error === RELEASE_IDENTITY_MISMATCH_CODE)) {
505
+ throw codedError(current.error === RELEASE_IDENTITY_MISMATCH_CODE
506
+ ? 'Action invocation failed because the immutable agent identity changed'
507
+ : 'Action invocation failed because the release generation changed', current.error, 409);
495
508
  }
496
509
  // Registered node actions only enter action_deleted before the provider
497
510
  // accepts the frame. Surface that durable result before the route snapshot
@@ -747,11 +760,43 @@ async function dispatchRelease(args) {
747
760
  throw codedError('release action input.name is required', 'invalid_release_request', 400);
748
761
  }
749
762
  const expectedTokenHash = releaseExpectedTokenHash(input);
763
+ const expectedAgentId = releaseExpectedAgentId(input);
764
+ // Claim the operation before resolving the mutable name. A completed exact
765
+ // release tombstones/renames the row, so a lost-response replay must find
766
+ // this immutable invocation before it looks up the name again.
767
+ const exactClaim = expectedAgentId ? await createInvocation(args.db, args.workspaceId, null, {
768
+ input,
769
+ caller_id: args.data.caller_id,
770
+ caller_name: args.data.caller_name,
771
+ action_name: 'release',
772
+ invocation_id: args.invocationId ?? (args.idempotencyKey && (args.idempotencyActorId ?? args.data.caller_id)
773
+ ? await idempotentInvocationId(args.workspaceId, args.idempotencyActorId ?? args.data.caller_id, 'release', args.idempotencyKey)
774
+ : undefined),
775
+ }) : null;
776
+ if (exactClaim?.replayed) {
777
+ const { invocation } = exactClaim;
778
+ const settled = await waitForInvocationReplayOutcome(args.db, args.workspaceId, invocation);
779
+ return markInvocationReplay(invocationAck(settled, { actionName: 'release' }));
780
+ }
750
781
  const [agent] = await args.db
751
782
  .select()
752
783
  .from(agents)
753
- .where(and(eq(agents.workspaceId, args.workspaceId), eq(agents.name, name), ...(expectedTokenHash ? [eq(agents.tokenHash, expectedTokenHash)] : [])));
784
+ .where(and(eq(agents.workspaceId, args.workspaceId), eq(agents.name, name), ...(expectedAgentId ? [eq(agents.id, expectedAgentId)] : []), ...(expectedTokenHash ? [eq(agents.tokenHash, expectedTokenHash)] : [])));
754
785
  if (!agent) {
786
+ if (expectedAgentId) {
787
+ await args.db
788
+ .update(actionInvocations)
789
+ .set({ status: 'failed', error: RELEASE_IDENTITY_MISMATCH_CODE, completedAt: new Date() })
790
+ .where(and(eq(actionInvocations.workspaceId, args.workspaceId), eq(actionInvocations.id, exactClaim.invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
791
+ const [sameName] = await args.db
792
+ .select({ id: agents.id })
793
+ .from(agents)
794
+ .where(and(eq(agents.workspaceId, args.workspaceId), eq(agents.name, name)));
795
+ if (sameName) {
796
+ throw codedError(`Agent "${name}" no longer matches the expected immutable identity`, RELEASE_IDENTITY_MISMATCH_CODE, 409);
797
+ }
798
+ throw codedError(`Agent "${name}" with expected identity is absent`, RELEASE_IDENTITY_MISMATCH_CODE, 409);
799
+ }
755
800
  if (expectedTokenHash) {
756
801
  const [sameName] = await args.db
757
802
  .select({ id: agents.id })
@@ -763,12 +808,17 @@ async function dispatchRelease(args) {
763
808
  }
764
809
  throw codedError(`Agent "${name}" not found`, 'agent_not_found', 404);
765
810
  }
766
- const { invocation, replayed } = await createInvocation(args.db, args.workspaceId, null, {
811
+ // Legacy requests retain their prior behavior: invalid names/generation
812
+ // guards fail before creating an invocation. Exact requests have already
813
+ // claimed so their post-release replay survives the tombstone rename.
814
+ const { invocation, replayed } = exactClaim ?? await createInvocation(args.db, args.workspaceId, null, {
767
815
  input,
768
816
  caller_id: args.data.caller_id,
769
817
  caller_name: args.data.caller_name,
770
818
  action_name: 'release',
771
- invocation_id: args.invocationId,
819
+ invocation_id: args.invocationId ?? (args.idempotencyKey && (args.idempotencyActorId ?? args.data.caller_id)
820
+ ? await idempotentInvocationId(args.workspaceId, args.idempotencyActorId ?? args.data.caller_id, 'release', args.idempotencyKey)
821
+ : undefined),
772
822
  });
773
823
  if (replayed) {
774
824
  const settled = await waitForInvocationReplayOutcome(args.db, args.workspaceId, invocation);
@@ -790,7 +840,7 @@ async function dispatchRelease(args) {
790
840
  AND ${actionInvocations.id} = ${invocation.id}
791
841
  AND ${actionInvocations.status} = 'completed'
792
842
  )`;
793
- const generationStillCurrent = releaseGenerationStillCurrent(args.workspaceId, agent.id, expectedTokenHash);
843
+ const generationStillCurrent = releaseGenerationStillCurrent(args.workspaceId, agent.id, expectedTokenHash, expectedAgentId);
794
844
  const atomicExitNodeId = sql `COALESCE(
795
845
  (
796
846
  SELECT ${agentNodeBindings.nodeId}
@@ -830,10 +880,10 @@ async function dispatchRelease(args) {
830
880
  .update(actionInvocations)
831
881
  .set({
832
882
  status: 'failed',
833
- error: RELEASE_GENERATION_CONFLICT_CODE,
883
+ error: expectedAgentId ? RELEASE_IDENTITY_MISMATCH_CODE : RELEASE_GENERATION_CONFLICT_CODE,
834
884
  completedAt,
835
885
  })
836
- .where(and(eq(actionInvocations.workspaceId, args.workspaceId), eq(actionInvocations.id, invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), expectedTokenHash ? sql `NOT (${generationStillCurrent})` : sql `0`))
886
+ .where(and(eq(actionInvocations.workspaceId, args.workspaceId), eq(actionInvocations.id, invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), expectedTokenHash || expectedAgentId ? sql `NOT (${generationStillCurrent})` : sql `0`))
837
887
  .returning({ id: actionInvocations.id }));
838
888
  // Win completion and snapshot the current binding before deactivating
839
889
  // it. The returned node drives the response and agent.exited event, so a
@@ -910,12 +960,14 @@ async function dispatchRelease(args) {
910
960
  })
911
961
  .where(and(eq(agents.workspaceId, args.workspaceId), eq(agents.id, agent.id), ...(expectedTokenHash ? [eq(agents.tokenHash, expectedTokenHash)] : []), invocationCompleted)));
912
962
  return writes;
913
- }, expectedTokenHash ? { requireAtomic: true } : undefined);
963
+ }, expectedTokenHash || expectedAgentId ? { requireAtomic: true } : undefined);
914
964
  const generationConflict = results[0];
915
965
  const completed = results[1];
916
966
  const completedExitNodeId = completed[0]?.handlerNodeId ?? null;
917
- if (expectedTokenHash && (generationConflict.length > 0 || completed.length === 0)) {
918
- throw codedError(`Agent "${name}" no longer matches the expected token generation`, RELEASE_GENERATION_CONFLICT_CODE, 409);
967
+ if ((expectedTokenHash || expectedAgentId) && (generationConflict.length > 0 || completed.length === 0)) {
968
+ throw codedError(expectedAgentId
969
+ ? `Agent "${name}" no longer matches the expected immutable identity`
970
+ : `Agent "${name}" no longer matches the expected token generation`, expectedAgentId ? RELEASE_IDENTITY_MISMATCH_CODE : RELEASE_GENERATION_CONFLICT_CODE, 409);
919
971
  }
920
972
  // External completion effects belong after the durable atomic unit: an
921
973
  // aborted local reap must never publish agent.exited.
@@ -941,14 +993,14 @@ async function dispatchRelease(args) {
941
993
  };
942
994
  const failClosed = async () => {
943
995
  const completedAt = new Date();
944
- if (expectedTokenHash) {
945
- const generationStillCurrent = releaseGenerationStillCurrent(args.workspaceId, agent.id, expectedTokenHash);
996
+ if (expectedTokenHash || expectedAgentId) {
997
+ const generationStillCurrent = releaseGenerationStillCurrent(args.workspaceId, agent.id, expectedTokenHash, expectedAgentId);
946
998
  const results = await runAtomicWrites(args.db, (writeDb) => [
947
999
  writeDb
948
1000
  .update(actionInvocations)
949
1001
  .set({
950
1002
  status: 'failed',
951
- error: RELEASE_GENERATION_CONFLICT_CODE,
1003
+ error: expectedAgentId ? RELEASE_IDENTITY_MISMATCH_CODE : RELEASE_GENERATION_CONFLICT_CODE,
952
1004
  completedAt,
953
1005
  })
954
1006
  .where(and(eq(actionInvocations.workspaceId, args.workspaceId), eq(actionInvocations.id, invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), sql `NOT (${generationStillCurrent})`))
@@ -961,7 +1013,9 @@ async function dispatchRelease(args) {
961
1013
  ], { requireAtomic: true });
962
1014
  const generationConflict = results[0];
963
1015
  if (generationConflict.length > 0) {
964
- throw codedError(`Agent "${name}" no longer matches the expected token generation`, RELEASE_GENERATION_CONFLICT_CODE, 409);
1016
+ throw codedError(expectedAgentId
1017
+ ? `Agent "${name}" no longer matches the expected immutable identity`
1018
+ : `Agent "${name}" no longer matches the expected token generation`, expectedAgentId ? RELEASE_IDENTITY_MISMATCH_CODE : RELEASE_GENERATION_CONFLICT_CODE, 409);
965
1019
  }
966
1020
  }
967
1021
  else {
@@ -1008,20 +1062,22 @@ async function dispatchRelease(args) {
1008
1062
  if (dispatched.settled) {
1009
1063
  if (dispatched.settled.status === 'failed') {
1010
1064
  const errorCode = dispatched.settled.error ?? 'handler_unavailable';
1011
- throw codedError('Release invocation failed after provider dispatch', errorCode, errorCode === RELEASE_GENERATION_CONFLICT_CODE ? 409 : 503);
1065
+ throw codedError('Release invocation failed after provider dispatch', errorCode, errorCode === RELEASE_GENERATION_CONFLICT_CODE || errorCode === RELEASE_IDENTITY_MISMATCH_CODE ? 409 : 503);
1012
1066
  }
1013
1067
  return invocationAck(dispatched.settled, { actionName: 'release' });
1014
1068
  }
1015
1069
  // The provider can disconnect between the liveness check and send. Complete
1016
1070
  // the DB lifecycle locally instead of creating an ownerless pending request.
1017
1071
  if (!dispatched.accepted) {
1018
- if (expectedTokenHash) {
1072
+ if (expectedTokenHash || expectedAgentId) {
1019
1073
  const [settled] = await args.db
1020
1074
  .select({ error: actionInvocations.error })
1021
1075
  .from(actionInvocations)
1022
1076
  .where(and(eq(actionInvocations.workspaceId, args.workspaceId), eq(actionInvocations.id, invocation.id)));
1023
- if (settled?.error === RELEASE_GENERATION_CONFLICT_CODE) {
1024
- throw codedError(`Agent "${name}" no longer matches the expected token generation`, RELEASE_GENERATION_CONFLICT_CODE, 409);
1077
+ if (settled?.error === RELEASE_GENERATION_CONFLICT_CODE || settled?.error === RELEASE_IDENTITY_MISMATCH_CODE) {
1078
+ throw codedError(expectedAgentId
1079
+ ? `Agent "${name}" no longer matches the expected immutable identity`
1080
+ : `Agent "${name}" no longer matches the expected token generation`, expectedAgentId ? RELEASE_IDENTITY_MISMATCH_CODE : RELEASE_GENERATION_CONFLICT_CODE, 409);
1025
1081
  }
1026
1082
  if (settled?.error === 'node_dispatch_unavailable') {
1027
1083
  throw codedError('Node adapter does not support generation-authorized release dispatch', 'node_dispatch_unavailable', 503);
@@ -1053,6 +1109,8 @@ export async function dispatchAgentRelease(db, workspaceId, data, options = {})
1053
1109
  completionDeps: options.completionDeps,
1054
1110
  workspaceId,
1055
1111
  data,
1112
+ idempotencyKey: options.idempotencyKey,
1113
+ idempotencyActorId: options.idempotencyActorId,
1056
1114
  });
1057
1115
  }
1058
1116
  function spawnResult(invocation, nodeId, dispatched) {
@@ -1532,12 +1590,13 @@ async function completeGuardedReleaseNodeInvocation(db, workspaceId, nodeId, pro
1532
1590
  && AGENT_TOKEN_HASH_PATTERN.test(persistedTokenHash)
1533
1591
  ? persistedTokenHash
1534
1592
  : null;
1535
- if (!name || !expectedTokenHash) {
1593
+ const expectedAgentId = releaseExpectedAgentId(input);
1594
+ if (!name || (!expectedTokenHash && !expectedAgentId)) {
1536
1595
  const [failed] = await db
1537
1596
  .update(actionInvocations)
1538
1597
  .set({
1539
1598
  status: 'failed',
1540
- error: RELEASE_GENERATION_CONFLICT_CODE,
1599
+ error: expectedAgentId ? RELEASE_IDENTITY_MISMATCH_CODE : RELEASE_GENERATION_CONFLICT_CODE,
1541
1600
  completedAt: new Date(),
1542
1601
  spawnReservedAt: null,
1543
1602
  })
@@ -1548,13 +1607,13 @@ async function completeGuardedReleaseNodeInvocation(db, workspaceId, nodeId, pro
1548
1607
  const [agent] = await db
1549
1608
  .select()
1550
1609
  .from(agents)
1551
- .where(and(eq(agents.workspaceId, workspaceId), eq(agents.name, name)));
1610
+ .where(and(eq(agents.workspaceId, workspaceId), eq(agents.name, name), ...(expectedAgentId ? [eq(agents.id, expectedAgentId)] : [])));
1552
1611
  if (!agent) {
1553
1612
  const [failed] = await db
1554
1613
  .update(actionInvocations)
1555
1614
  .set({
1556
1615
  status: 'failed',
1557
- error: RELEASE_GENERATION_CONFLICT_CODE,
1616
+ error: expectedAgentId ? RELEASE_IDENTITY_MISMATCH_CODE : RELEASE_GENERATION_CONFLICT_CODE,
1558
1617
  completedAt: new Date(),
1559
1618
  spawnReservedAt: null,
1560
1619
  })
@@ -1563,7 +1622,7 @@ async function completeGuardedReleaseNodeInvocation(db, workspaceId, nodeId, pro
1563
1622
  return failed ? publicInvocation(failed) : null;
1564
1623
  }
1565
1624
  const completedAt = new Date();
1566
- const generationStillCurrent = releaseGenerationStillCurrent(workspaceId, agent.id, expectedTokenHash);
1625
+ const generationStillCurrent = releaseGenerationStillCurrent(workspaceId, agent.id, expectedTokenHash, expectedAgentId);
1567
1626
  const activeBindingStillCurrent = sql `EXISTS (
1568
1627
  SELECT 1 FROM ${agentNodeBindings}
1569
1628
  WHERE ${agentNodeBindings.workspaceId} = ${workspaceId}
@@ -1601,7 +1660,7 @@ async function completeGuardedReleaseNodeInvocation(db, workspaceId, nodeId, pro
1601
1660
  .update(actionInvocations)
1602
1661
  .set({
1603
1662
  status: 'failed',
1604
- error: RELEASE_GENERATION_CONFLICT_CODE,
1663
+ error: expectedAgentId ? RELEASE_IDENTITY_MISMATCH_CODE : RELEASE_GENERATION_CONFLICT_CODE,
1605
1664
  completedAt,
1606
1665
  spawnReservedAt: null,
1607
1666
  })
@@ -1637,7 +1696,7 @@ async function completeGuardedReleaseNodeInvocation(db, workspaceId, nodeId, pro
1637
1696
  })
1638
1697
  .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocation.id), eq(actionInvocations.dispatchedNodeId, nodeId), eq(actionInvocations.dispatchedProvider, providerName), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES), generationStillCurrent))
1639
1698
  .returning());
1640
- const currentAgent = and(eq(agents.workspaceId, workspaceId), eq(agents.id, agent.id), eq(agents.name, name), eq(agents.tokenHash, expectedTokenHash), invocationCompleted);
1699
+ const currentAgent = and(eq(agents.workspaceId, workspaceId), eq(agents.id, agent.id), eq(agents.name, name), ...(expectedTokenHash ? [eq(agents.tokenHash, expectedTokenHash)] : []), invocationCompleted);
1641
1700
  if (input.delete_agent === true) {
1642
1701
  writes.push(writeDb
1643
1702
  .update(agents)
@@ -2004,12 +2063,18 @@ async function dispatchNodeInvocation(args) {
2004
2063
  const guardedReleaseHash = args.invocationOrigin === 'builtin' && isReleaseInvocation(args.action)
2005
2064
  ? releaseExpectedTokenHash(args.input)
2006
2065
  : null;
2007
- if (guardedReleaseHash) {
2066
+ const guardedReleaseAgentId = args.invocationOrigin === 'builtin' && isReleaseInvocation(args.action)
2067
+ ? releaseExpectedAgentId(args.input)
2068
+ : null;
2069
+ const releaseConflictCode = guardedReleaseAgentId
2070
+ ? RELEASE_IDENTITY_MISMATCH_CODE
2071
+ : RELEASE_GENERATION_CONFLICT_CODE;
2072
+ if (guardedReleaseHash || guardedReleaseAgentId) {
2008
2073
  const name = typeof args.input.name === 'string' ? args.input.name : '';
2009
2074
  const [current] = await args.db
2010
2075
  .select({ id: agents.id })
2011
2076
  .from(agents)
2012
- .where(and(eq(agents.workspaceId, args.workspaceId), eq(agents.name, name), eq(agents.tokenHash, guardedReleaseHash), sql `EXISTS (
2077
+ .where(and(eq(agents.workspaceId, args.workspaceId), eq(agents.name, name), ...(guardedReleaseHash ? [eq(agents.tokenHash, guardedReleaseHash)] : []), ...(guardedReleaseAgentId ? [eq(agents.id, guardedReleaseAgentId)] : []), sql `EXISTS (
2013
2078
  SELECT 1 FROM ${agentNodeBindings}
2014
2079
  WHERE ${agentNodeBindings.workspaceId} = ${args.workspaceId}
2015
2080
  AND ${agentNodeBindings.agentId} = ${agents.id}
@@ -2021,7 +2086,7 @@ async function dispatchNodeInvocation(args) {
2021
2086
  .update(actionInvocations)
2022
2087
  .set({
2023
2088
  status: 'failed',
2024
- error: RELEASE_GENERATION_CONFLICT_CODE,
2089
+ error: releaseConflictCode,
2025
2090
  completedAt: new Date(),
2026
2091
  })
2027
2092
  .where(and(eq(actionInvocations.workspaceId, args.workspaceId), eq(actionInvocations.id, args.invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
@@ -2099,15 +2164,19 @@ async function dispatchNodeInvocation(args) {
2099
2164
  invocationActionName: registeredNodeClaim.actionName,
2100
2165
  actionName: args.action,
2101
2166
  }) ?? false)
2102
- : guardedReleaseHash
2167
+ : guardedReleaseHash || guardedReleaseAgentId
2103
2168
  ? await (args.registry.sendAuthorizedActionToProvider?.(args.workspaceId, args.nodeId, args.providerName, frame, {
2104
2169
  kind: 'release-generation-v1',
2105
2170
  invocationId: args.invocationId,
2106
2171
  agentName: typeof args.input.name === 'string' ? args.input.name : '',
2107
- expectedTokenHash: guardedReleaseHash,
2172
+ ...(guardedReleaseHash && guardedReleaseAgentId
2173
+ ? { expectedTokenHash: guardedReleaseHash, expectedAgentId: guardedReleaseAgentId }
2174
+ : guardedReleaseHash
2175
+ ? { expectedTokenHash: guardedReleaseHash }
2176
+ : { expectedAgentId: guardedReleaseAgentId }),
2108
2177
  }) ?? false)
2109
2178
  : await args.registry.sendToProvider(args.workspaceId, args.nodeId, args.providerName, frame);
2110
- if (!sent && guardedReleaseHash) {
2179
+ if (!sent && (guardedReleaseHash || guardedReleaseAgentId)) {
2111
2180
  // An adapter compiled against the older owner-authorization contract may
2112
2181
  // expose the method but reject the new proof kind. Settle any still-open
2113
2182
  // row before the caller considers local fallback, so a guarded delete can
@@ -2163,7 +2232,7 @@ async function dispatchNodeInvocation(args) {
2163
2232
  const settled = await settleDeletedRegisteredAction(args.db, args.workspaceId, args.invocationId);
2164
2233
  return { accepted: false, pending: false, sent: true, settled };
2165
2234
  }
2166
- if (!accepted && guardedReleaseHash) {
2235
+ if (!accepted && (guardedReleaseHash || guardedReleaseAgentId)) {
2167
2236
  const [settled] = await args.db
2168
2237
  .select()
2169
2238
  .from(actionInvocations)
@@ -2637,7 +2706,7 @@ export async function completeNodeInvocation(db, registry, workspaceId, nodeId,
2637
2706
  const existingInput = recordInput(existing.input);
2638
2707
  if (!data.error
2639
2708
  && isBuiltinReleaseInvocation(existing)
2640
- && existingInput.expected_token_hash !== undefined) {
2709
+ && (existingInput.expected_token_hash !== undefined || existingInput.expected_agent_id !== undefined)) {
2641
2710
  return completeGuardedReleaseNodeInvocation(db, workspaceId, nodeId, providerName, existing, data, deps);
2642
2711
  }
2643
2712
  const [updated] = await db