@relaycast/engine 8.2.1 → 8.2.2

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.
@@ -4,12 +4,14 @@ import { generateId } from './snowflake.js';
4
4
  import { RELEASED_AGENT_STATUS, releasedAgentName } from './agent.js';
5
5
  import { randomHex, sha256Hex } from '../lib/crypto.js';
6
6
  import { codedError } from '../lib/httpError.js';
7
+ import { D1_SAFE_IN_QUERY_CHUNK_SIZE } from '../lib/queryChunks.js';
7
8
  import { toFleetWireJson } from './deliveryWire.js';
8
9
  import { emitAgentExitedEffects, emitInvocationCompletionEffects, fleetInvocationId, } from './invocationCompletion.js';
9
10
  import { runAtomic, runAtomicWrites } from '../ports/database.js';
10
11
  import { claimSpawnNode, chooseNodeForAction, isNodeLive, releaseNodeCapacity, reserveNodeCapacity } from './placement.js';
11
12
  import { DEFAULT_PROVIDER_NAME, capacityProviderName, getProvider, isProviderLive } from './nodeProvider.js';
12
13
  const OPEN_INVOCATION_STATUSES = ['pending', 'dispatched', 'invoked'];
14
+ const REPLAYED_INVOCATION = Symbol('replayed-action-invocation');
13
15
  export const ACTION_DISPATCH_TIMEOUT_MS = 30_000;
14
16
  /**
15
17
  * How long an agent handler's connection must be CONTINUOUSLY unreachable
@@ -20,6 +22,31 @@ export const ACTION_DISPATCH_TIMEOUT_MS = 30_000;
20
22
  * a signal instead of an unbounded hang.
21
23
  */
22
24
  export const ACTION_HANDLER_UNREACHABLE_TTL_MS = 120_000;
25
+ /**
26
+ * Absolute age bound for a `pending` invocation that was NEVER dispatched to a
27
+ * node (`dispatch_attempts = 0`). The handler-unreachable TTL only fires once a
28
+ * dispatched handler connection has been observed offline; an invocation that
29
+ * never left the queue has no handler to observe, so the clock never starts
30
+ * and the row can sit `pending` forever. When the queue eventually drains
31
+ * (e.g. a node returns after a long outage), week-old spawn briefs come back
32
+ * to life as fresh agents that can evict the live resident under the same
33
+ * name — a delayed-action identity hazard, not inert backlog. Sized deliberately:
34
+ * long enough to ride out a weekend-scale node outage plus a day of buffer,
35
+ * far shorter than the observed multi-month backlog that motivated it.
36
+ */
37
+ export const PENDING_INVOCATION_MAX_AGE_MS = 72 * 60 * 60 * 1000;
38
+ /**
39
+ * Sleep between the never-dispatched sweep's candidate SELECT and its atomic
40
+ * UPDATE. `dispatchNodeInvocation` sends the frame before recording
41
+ * `dispatchAttempts += 1`, so a sweep landing between those two dispatcher
42
+ * calls could otherwise mark an in-flight dispatch `never_dispatched_expired`
43
+ * while the handler already has the frame. The atomic UPDATE re-checks
44
+ * `dispatch_attempts = 0`, so once the dispatcher's UPDATE lands, the sweep's
45
+ * WHERE excludes the row. This grace window (default 5s) covers the send→record
46
+ * gap by a wide margin — the dispatcher's UPDATE is one D1 round-trip — while
47
+ * still bounding sweep latency. Set to 0 in tests to keep them fast.
48
+ */
49
+ export const NEVER_DISPATCHED_SWEEP_GRACE_MS = 5_000;
23
50
  const ACTION_RETRY_BACKOFF_MS = 5_000;
24
51
  const NODE_DRAIN_REQUEUE_RETRY_MS = 5_000;
25
52
  function capabilityName(capability) {
@@ -83,6 +110,19 @@ function recordInput(value) {
83
110
  ? value
84
111
  : {};
85
112
  }
113
+ function canonicalJson(value) {
114
+ if (Array.isArray(value)) {
115
+ return `[${value.map((item) => canonicalJson(item)).join(',')}]`;
116
+ }
117
+ if (value && typeof value === 'object') {
118
+ const record = value;
119
+ return `{${Object.keys(record)
120
+ .sort()
121
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
122
+ .join(',')}}`;
123
+ }
124
+ return JSON.stringify(value) ?? 'null';
125
+ }
86
126
  function publicAction(row) {
87
127
  return {
88
128
  id: row.id,
@@ -329,22 +369,128 @@ export async function deleteAction(db, workspaceId, name, options = {}) {
329
369
  return deleted;
330
370
  }
331
371
  async function createInvocation(db, workspaceId, action, data) {
332
- const invocationId = `inv_${generateId()}`;
333
- const [invocation] = await db
372
+ const invocationId = data.invocation_id ?? `inv_${generateId()}`;
373
+ const expectedActionName = action?.name ?? data.action_name ?? 'spawn';
374
+ const [created] = await db
334
375
  .insert(actionInvocations)
335
376
  .values({
336
377
  id: invocationId,
337
378
  workspaceId,
338
379
  actionId: action?.id ?? null,
339
- actionName: action?.name ?? data.action_name ?? 'spawn',
380
+ actionName: expectedActionName,
340
381
  callerId: data.caller_id ?? null,
341
382
  callerName: data.caller_name ?? null,
383
+ handlerAgentId: data.handler_agent_id ?? action?.handlerAgentId ?? null,
384
+ handlerNodeId: data.handler_node_id ?? action?.handlerNodeId ?? null,
342
385
  input: data.input ?? {},
343
386
  status: data.status ?? 'pending',
344
387
  })
388
+ .onConflictDoNothing()
345
389
  .returning();
390
+ if (created)
391
+ return { invocation: created, replayed: false };
392
+ // Only a deterministic idempotency claim is expected to conflict. Its row
393
+ // is the durable pre-dispatch claim: a concurrent request or later retry
394
+ // observes the same invocation instead of sending another provider frame.
395
+ if (!data.invocation_id) {
396
+ throw new Error(`Action invocation id collision: ${invocationId}`);
397
+ }
398
+ const [existing] = await db
399
+ .select()
400
+ .from(actionInvocations)
401
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId)));
402
+ if (!existing) {
403
+ throw codedError('Idempotency claim could not be read after conflict', 'idempotency_unavailable', 503);
404
+ }
405
+ assertInvocationClaimMatches(existing, expectedActionName, data);
406
+ return { invocation: existing, replayed: true };
407
+ }
408
+ function assertInvocationClaimMatches(existing, expectedActionName, data) {
409
+ const samePayload = existing.actionName === expectedActionName
410
+ && existing.callerId === (data.caller_id ?? null)
411
+ && canonicalJson(recordInput(existing.input)) === canonicalJson(recordInput(data.input));
412
+ if (!samePayload) {
413
+ throw codedError('Idempotency-Key was reused with a different request payload', 'idempotency_key_reused', 409);
414
+ }
415
+ }
416
+ async function idempotentInvocationId(workspaceId, callerId, actionName, key) {
417
+ const digest = await sha256Hex(['action-invoke-v1', workspaceId, callerId, actionName, key].join('\0'));
418
+ return `inv_idem_${digest}`;
419
+ }
420
+ async function waitForInvocationReplayOutcome(db, workspaceId, invocation, options = {}) {
421
+ let current = invocation;
422
+ const deadline = Date.now() + 500;
423
+ while (!current.dispatchedNodeId
424
+ && !(options.acceptDispatchStarted && current.dispatchAttempts > 0)) {
425
+ if (!OPEN_INVOCATION_STATUSES.some((status) => status === current.status)) {
426
+ if (current.status === 'failed') {
427
+ throw codedError('Action invocation failed before provider dispatch completed', current.error ?? 'handler_unavailable', 503);
428
+ }
429
+ return current;
430
+ }
431
+ if (Date.now() >= deadline) {
432
+ // The winning request may have died after its durable claim but before
433
+ // provider dispatch started or completed. Fail retryably without
434
+ // releasing the key for another execution.
435
+ throw codedError('Idempotent action dispatch is still pending', 'idempotency_unavailable', 503);
436
+ }
437
+ await new Promise((resolve) => setTimeout(resolve, 10));
438
+ const [updated] = await db
439
+ .select()
440
+ .from(actionInvocations)
441
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocation.id)));
442
+ if (!updated) {
443
+ throw codedError('Idempotency claim disappeared during dispatch', 'idempotency_unavailable', 503);
444
+ }
445
+ current = updated;
446
+ }
447
+ return current;
448
+ }
449
+ /**
450
+ * Resolve a losing durable claim from the immutable invocation snapshots.
451
+ *
452
+ * This is shared by the optimistic pre-read and every branch-local insert
453
+ * conflict. The action row resolved by the losing request may have changed
454
+ * scope or handler after the winning claim was created, so it is never a safe
455
+ * source for replay classification.
456
+ */
457
+ async function replayInvocationClaim(db, workspaceId, invocation) {
458
+ if (isSpawnInvocation(invocation.actionName)
459
+ || isReleaseInvocation(invocation.actionName)
460
+ || (!!invocation.handlerNodeId && !invocation.handlerAgentId)) {
461
+ return waitForInvocationReplayOutcome(db, workspaceId, invocation);
462
+ }
463
+ if (invocation.handlerAgentId) {
464
+ // A handler takeover can win after the durable claim insert but before the
465
+ // winner revalidates the handler and starts provider dispatch. Do not
466
+ // acknowledge that pre-send claim. Once the durable attempt marker exists,
467
+ // preserve the accepted post-send replay contract even if a later takeover
468
+ // terminally fails the row.
469
+ return waitForInvocationReplayOutcome(db, workspaceId, invocation, { acceptDispatchStarted: true });
470
+ }
346
471
  return invocation;
347
472
  }
473
+ function invocationAck(invocation, { actionName = invocation.actionName, handlerAgentId = invocation.handlerAgentId, handlerNodeId = invocation.handlerNodeId, } = {}) {
474
+ return {
475
+ invocation_id: invocation.id,
476
+ action_name: actionName,
477
+ handler_agent_id: handlerAgentId,
478
+ // The snapshot is the handler returned by the original 201. The mutable
479
+ // dispatched node remains available separately for current lifecycle state.
480
+ handler_node_id: handlerNodeId ?? invocation.dispatchedNodeId,
481
+ dispatched_node_id: invocation.dispatchedNodeId,
482
+ input: recordInput(invocation.input),
483
+ status: invocation.status,
484
+ created_at: invocation.createdAt.toISOString(),
485
+ };
486
+ }
487
+ function markInvocationReplay(result) {
488
+ Object.defineProperty(result, REPLAYED_INVOCATION, { value: true });
489
+ return result;
490
+ }
491
+ export function wasInvocationReplayed(result) {
492
+ return REPLAYED_INVOCATION in result;
493
+ }
348
494
  /**
349
495
  * Liveness rule for the connection that hosts an agent handler, mirroring the
350
496
  * drain-time rule: the provider socket must be connected, and when a provider
@@ -493,14 +639,20 @@ async function dispatchRelease(args) {
493
639
  if (!agent) {
494
640
  throw codedError(`Agent "${name}" not found`, 'agent_not_found', 404);
495
641
  }
496
- const invocation = await createInvocation(args.db, args.workspaceId, null, {
642
+ const { invocation, replayed } = await createInvocation(args.db, args.workspaceId, null, {
497
643
  input,
498
644
  caller_id: args.data.caller_id,
499
645
  caller_name: args.data.caller_name,
500
646
  action_name: 'release',
647
+ invocation_id: args.invocationId,
501
648
  });
649
+ if (replayed) {
650
+ const settled = await waitForInvocationReplayOutcome(args.db, args.workspaceId, invocation);
651
+ return markInvocationReplay(invocationAck(settled, { actionName: 'release' }));
652
+ }
502
653
  const completeLocally = async () => {
503
654
  const completedAt = new Date();
655
+ const exitNodeId = nodeId ?? agent.locationNodeId;
504
656
  // Keyed on the agent id, not on the clock: the id is already unique per
505
657
  // workspace, so the tombstone can never collide with an existing row (a
506
658
  // second release of the same row is idempotent). A timestamped name would
@@ -591,6 +743,9 @@ async function dispatchRelease(args) {
591
743
  .update(actionInvocations)
592
744
  .set({
593
745
  status: 'completed',
746
+ // This is part of the same atomic unit as completion so a lost 201
747
+ // replays the exact handler node returned below.
748
+ handlerNodeId: sql `COALESCE(${actionInvocations.handlerNodeId}, ${exitNodeId})`,
594
749
  output: {
595
750
  released: true,
596
751
  // The roster row is retained as a tombstone so the agent's history
@@ -608,7 +763,6 @@ async function dispatchRelease(args) {
608
763
  const completed = results.at(-1);
609
764
  // External completion effects belong after the durable atomic unit: an
610
765
  // aborted local reap must never publish agent.exited.
611
- const exitNodeId = nodeId ?? agent.locationNodeId;
612
766
  if (completed.length > 0 && args.completionDeps && exitNodeId) {
613
767
  await emitAgentExitedEffects(args.completionDeps, args.workspaceId, {
614
768
  agentId: agent.id,
@@ -697,18 +851,44 @@ async function dispatchSpawn(args) {
697
851
  }
698
852
  const input = recordInput(args.data.input);
699
853
  const capability = dispatchActionNameForInvocation('spawn', input);
700
- const invocation = await createInvocation(args.db, args.workspaceId, null, {
854
+ const { invocation, replayed } = await createInvocation(args.db, args.workspaceId, null, {
701
855
  input: args.data.input,
702
856
  caller_id: args.data.caller_id,
703
857
  caller_name: args.data.caller_name,
858
+ invocation_id: args.invocationId,
704
859
  });
705
- const placement = await claimSpawnNode(args.db, args.workspaceId, {
706
- actionName: 'spawn',
707
- input,
708
- callerId: args.data.caller_id,
709
- preferredNodeId: args.targetNodeId,
710
- });
860
+ if (replayed) {
861
+ const dispatched = await waitForInvocationReplayOutcome(args.db, args.workspaceId, invocation);
862
+ return markInvocationReplay(invocationAck(dispatched, { actionName: 'spawn' }));
863
+ }
864
+ let placement;
865
+ try {
866
+ placement = await claimSpawnNode(args.db, args.workspaceId, {
867
+ actionName: 'spawn',
868
+ input,
869
+ callerId: args.data.caller_id,
870
+ preferredNodeId: args.targetNodeId,
871
+ });
872
+ }
873
+ catch (error) {
874
+ // Placement is transactional and no provider frame can exist yet. Release
875
+ // only this untouched pre-placement claim so a keyed retry can try again
876
+ // after capacity recovers. Once any target/attempt state exists, the claim
877
+ // remains immutable and the normal replay path owns it.
878
+ await args.db
879
+ .delete(actionInvocations)
880
+ .where(and(eq(actionInvocations.workspaceId, args.workspaceId), eq(actionInvocations.id, invocation.id), eq(actionInvocations.status, 'pending'), isNull(actionInvocations.handlerNodeId), isNull(actionInvocations.dispatchedNodeId), isNull(actionInvocations.spawnReservedAt), eq(actionInvocations.dispatchAttempts, 0)));
881
+ throw error;
882
+ }
711
883
  const nodeId = placement.node.id;
884
+ // Placement and the durable idempotency claim are separate writes on D1.
885
+ // Publish the selected response target immediately; a concurrent replay in
886
+ // this narrow interval waits for this snapshot instead of returning null.
887
+ if (!await snapshotInvocationHandlerNode(args.db, args.workspaceId, invocation.id, nodeId)) {
888
+ if (!placement.queued)
889
+ await releaseNodeCapacity(args.db, args.workspaceId, nodeId);
890
+ throw codedError('Spawn invocation is no longer available for placement', 'idempotency_unavailable', 503);
891
+ }
712
892
  // A registered `spawn:<harness>` action shadows native capacity on this node.
713
893
  // Capacity-direct delegation (ctx.spawnAgent) bypasses the shadow so a handler
714
894
  // that delegates cannot re-enter itself.
@@ -790,7 +970,7 @@ export async function invokeNodeAction(db, workspaceId, nodeId, actionName, data
790
970
  throw codedError(`${who} is not authorized to invoke action "${actionName}"`, 'action_denied', 403);
791
971
  }
792
972
  }
793
- const invocation = await createInvocation(db, workspaceId, action, {
973
+ const { invocation } = await createInvocation(db, workspaceId, action, {
794
974
  input: data.input,
795
975
  caller_id: data.caller_id,
796
976
  caller_name: data.caller_name,
@@ -824,12 +1004,29 @@ export async function invokeNodeAction(db, workspaceId, nodeId, actionName, data
824
1004
  }
825
1005
  export async function invokeAction(db, workspaceId, actionName, data, options = {}) {
826
1006
  const action = await fetchAction(db, workspaceId, actionName, options.includeNodeScoped);
1007
+ let invocationId;
1008
+ if (options.idempotencyKey !== undefined) {
1009
+ if (!data.caller_id) {
1010
+ throw codedError('Authenticated caller is required for idempotent action invocation', 'idempotency_actor_required', 400);
1011
+ }
1012
+ invocationId = await idempotentInvocationId(workspaceId, data.caller_id, actionName, options.idempotencyKey);
1013
+ const [existing] = await db
1014
+ .select()
1015
+ .from(actionInvocations)
1016
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId)));
1017
+ if (existing) {
1018
+ assertInvocationClaimMatches(existing, actionName, data);
1019
+ const replay = await replayInvocationClaim(db, workspaceId, existing);
1020
+ return markInvocationReplay(invocationAck(replay, { actionName }));
1021
+ }
1022
+ }
827
1023
  if (!action && actionName === 'spawn') {
828
1024
  return dispatchSpawn({
829
1025
  db,
830
1026
  registry: options.nodeConnections,
831
1027
  workspaceId,
832
1028
  data,
1029
+ invocationId,
833
1030
  });
834
1031
  }
835
1032
  if (!action && actionName === 'release') {
@@ -839,6 +1036,7 @@ export async function invokeAction(db, workspaceId, actionName, data, options =
839
1036
  completionDeps: options.completionDeps,
840
1037
  workspaceId,
841
1038
  data,
1039
+ invocationId,
842
1040
  });
843
1041
  }
844
1042
  if (!action) {
@@ -855,11 +1053,16 @@ export async function invokeAction(db, workspaceId, actionName, data, options =
855
1053
  if (!options.nodeConnections) {
856
1054
  throw codedError('Node dispatch is not available', 'node_dispatch_unavailable', 503);
857
1055
  }
858
- const invocation = await createInvocation(db, workspaceId, action, {
1056
+ const { invocation, replayed } = await createInvocation(db, workspaceId, action, {
859
1057
  input: data.input,
860
1058
  caller_id: data.caller_id,
861
1059
  caller_name: data.caller_name,
1060
+ invocation_id: invocationId,
862
1061
  });
1062
+ if (replayed) {
1063
+ const settled = await replayInvocationClaim(db, workspaceId, invocation);
1064
+ return markInvocationReplay(invocationAck(settled, { actionName }));
1065
+ }
863
1066
  // Only mark the reservation held when we actually incremented the node's
864
1067
  // reserved-capacity counter, so completion/reschedule release stays balanced.
865
1068
  // If the reservation can't be taken (node offline / at capacity) the queued
@@ -924,11 +1127,21 @@ export async function invokeAction(db, workspaceId, actionName, data, options =
924
1127
  throw codedError(`Action "${actionName}" handler "${handlerAgent.name}" has no live connection`, 'handler_unavailable', 503);
925
1128
  }
926
1129
  }
927
- const invocation = await createInvocation(db, workspaceId, action, {
1130
+ const { invocation, replayed } = await createInvocation(db, workspaceId, action, {
928
1131
  input: data.input,
929
1132
  caller_id: data.caller_id,
930
1133
  caller_name: data.caller_name,
1134
+ handler_node_id: handlerAgent.locationNodeId,
1135
+ invocation_id: invocationId,
931
1136
  });
1137
+ if (replayed) {
1138
+ const settled = await replayInvocationClaim(db, workspaceId, invocation);
1139
+ return markInvocationReplay(invocationAck(settled, { actionName }));
1140
+ }
1141
+ if (!options.nodeConnections.sendAuthorizedActionToProvider) {
1142
+ await failOpenInvocationRows(db, workspaceId, [invocation.id], 'node_dispatch_unavailable');
1143
+ throw codedError('Node adapter does not support owner-authorized agent action dispatch', 'node_dispatch_unavailable', 503);
1144
+ }
932
1145
  // Re-validate the handler pointer AFTER the insert. A takeover committing
933
1146
  // between action resolution above and the insert misses this invocation in
934
1147
  // its stranded snapshot (the row was not visible yet), which would leave it
@@ -955,7 +1168,14 @@ export async function invokeAction(db, workspaceId, actionName, data, options =
955
1168
  action: action.name,
956
1169
  input: recordInput(invocation.input),
957
1170
  agent: { id: handlerAgent.id, name: handlerAgent.name },
1171
+ actionId: action.id,
958
1172
  });
1173
+ if (!dispatched.accepted) {
1174
+ // A takeover can invalidate the last-moment adapter gate. Re-read the
1175
+ // durable claim so the original request and its replay agree on the same
1176
+ // pre-send failure instead of returning a pending 201.
1177
+ await waitForInvocationReplayOutcome(db, workspaceId, invocation, { acceptDispatchStarted: true });
1178
+ }
959
1179
  return {
960
1180
  invocation_id: invocation.id,
961
1181
  action_name: actionName,
@@ -1107,6 +1327,9 @@ async function dispatchNodeAttempt(db, workspaceId, invocationId, nodeId, opts)
1107
1327
  .update(actionInvocations)
1108
1328
  .set({
1109
1329
  ...stateFields,
1330
+ // The first selected target is part of the immutable 201 response. Keep
1331
+ // it even when this invocation is later rescheduled to a different node.
1332
+ handlerNodeId: sql `COALESCE(${actionInvocations.handlerNodeId}, ${nodeId})`,
1110
1333
  dispatchedNodeId: nodeId,
1111
1334
  dispatchedProvider: opts.providerName,
1112
1335
  spawnReservedAt: opts.reservationHeld ? new Date() : null,
@@ -1117,6 +1340,16 @@ async function dispatchNodeAttempt(db, workspaceId, invocationId, nodeId, opts)
1117
1340
  .returning();
1118
1341
  return !!updated;
1119
1342
  }
1343
+ async function snapshotInvocationHandlerNode(db, workspaceId, invocationId, nodeId) {
1344
+ const [updated] = await db
1345
+ .update(actionInvocations)
1346
+ .set({
1347
+ handlerNodeId: sql `COALESCE(${actionInvocations.handlerNodeId}, ${nodeId})`,
1348
+ })
1349
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)))
1350
+ .returning({ id: actionInvocations.id });
1351
+ return !!updated;
1352
+ }
1120
1353
  /**
1121
1354
  * Transition a drained offline-queue invocation into the live `dispatched` state
1122
1355
  * once its queued `action.invoke` frame is actually delivered on node
@@ -1144,8 +1377,24 @@ async function dispatchNodeInvocation(args) {
1144
1377
  ...(args.agent ? { agent_id: args.agent.id, agent_name: args.agent.name } : {}),
1145
1378
  input: toFleetWireJson(args.input),
1146
1379
  };
1380
+ // Agent-hosted createInvocation() already persisted the immutable handler
1381
+ // node. Avoid another awaited DB write after handler revalidation; the socket
1382
+ // owner performs the last-moment gate below immediately before the send.
1383
+ const snapshotted = args.agent
1384
+ ? true
1385
+ : await snapshotInvocationHandlerNode(args.db, args.workspaceId, args.invocationId, args.nodeId);
1386
+ if (!snapshotted)
1387
+ return { accepted: false, pending: false };
1147
1388
  const connectedBefore = args.registry.isProviderConnected(args.workspaceId, args.nodeId, args.providerName);
1148
- const sent = await args.registry.sendToProvider(args.workspaceId, args.nodeId, args.providerName, frame);
1389
+ const sent = args.agent && args.actionId
1390
+ ? await (args.registry.sendAuthorizedActionToProvider?.(args.workspaceId, args.nodeId, args.providerName, frame, {
1391
+ kind: 'agent-action-v1',
1392
+ invocationId: args.invocationId,
1393
+ actionId: args.actionId,
1394
+ handlerAgentId: args.agent.id,
1395
+ recordAttempt: !args.skipIncrementAttempts,
1396
+ }) ?? false)
1397
+ : await args.registry.sendToProvider(args.workspaceId, args.nodeId, args.providerName, frame);
1149
1398
  if (!sent)
1150
1399
  return { accepted: false, pending: false };
1151
1400
  const pending = !!args.pending || !connectedBefore;
@@ -1154,7 +1403,7 @@ async function dispatchNodeInvocation(args) {
1154
1403
  providerName: args.providerName,
1155
1404
  retryAfterAt: args.retryAfterAt,
1156
1405
  reservationHeld: args.reservationHeld,
1157
- skipIncrementAttempts: args.skipIncrementAttempts,
1406
+ skipIncrementAttempts: args.skipIncrementAttempts || !!args.agent,
1158
1407
  actionId: args.actionId,
1159
1408
  });
1160
1409
  return { accepted, pending };
@@ -1185,6 +1434,7 @@ async function selectRetryPlacement(db, invocation, excludeNodeIds) {
1185
1434
  async function targetAgentForInvocation(db, invocation) {
1186
1435
  const [row] = await db
1187
1436
  .select({
1437
+ actionId: actions.id,
1188
1438
  agentId: agents.id,
1189
1439
  agentName: agents.name,
1190
1440
  locationType: agents.locationType,
@@ -1197,13 +1447,20 @@ async function targetAgentForInvocation(db, invocation) {
1197
1447
  .where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id)));
1198
1448
  if (!row || row.locationType !== 'via_node' || !row.nodeId)
1199
1449
  return null;
1200
- return { agentId: row.agentId, agentName: row.agentName, nodeId: row.nodeId, providerName: row.providerName };
1450
+ return {
1451
+ actionId: row.actionId,
1452
+ agentId: row.agentId,
1453
+ agentName: row.agentName,
1454
+ nodeId: row.nodeId,
1455
+ providerName: row.providerName,
1456
+ };
1201
1457
  }
1202
1458
  export async function drainNodeInvocations(db, registry, workspaceId, nodeId, options = {}) {
1203
1459
  const now = new Date();
1204
1460
  const rows = await db
1205
1461
  .select({
1206
1462
  id: actionInvocations.id,
1463
+ actionId: actions.id,
1207
1464
  workspaceId: actionInvocations.workspaceId,
1208
1465
  actionName: actionInvocations.actionName,
1209
1466
  input: actionInvocations.input,
@@ -1293,6 +1550,7 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId, op
1293
1550
  action: dispatchActionNameForInvocation(row.actionName, input),
1294
1551
  input,
1295
1552
  agent: targetAgent ? { id: targetAgent.id, name: targetAgent.name } : null,
1553
+ actionId: targetAgent ? row.actionId ?? undefined : undefined,
1296
1554
  retryAfterAt: new Date(Date.now() + ACTION_DISPATCH_TIMEOUT_MS),
1297
1555
  reservationHeld,
1298
1556
  skipIncrementAttempts: row.dispatchedNodeId === nodeId,
@@ -1347,6 +1605,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
1347
1605
  action: invocation.actionName,
1348
1606
  input: recordInput(invocation.input),
1349
1607
  agent: { id: targetAgent.agentId, name: targetAgent.agentName },
1608
+ actionId: targetAgent.actionId,
1350
1609
  retryAfterAt: opts.retryAfterAt ?? null,
1351
1610
  });
1352
1611
  return dispatched.accepted;
@@ -1641,10 +1900,76 @@ async function failUnreachableAgentInvocations(db, registry, ttlMs, completionDe
1641
1900
  }
1642
1901
  }
1643
1902
  }
1903
+ /**
1904
+ * Absolute-age bound for never-dispatched pending invocations. The
1905
+ * handler-unreachable TTL keys off a dispatched connection going quiet; an
1906
+ * invocation that never left the queue has no handler to observe, so it needs
1907
+ * its own bound. Fails rows that are `pending`, have `dispatch_attempts = 0`,
1908
+ * and are older than `maxAgeMs` with a distinguishing error so operators can
1909
+ * tell a never-dispatched expiry from a handler-that-went-quiet expiry.
1910
+ */
1911
+ async function failNeverDispatchedExpiredInvocations(db, maxAgeMs, graceMs, completionDeps) {
1912
+ const cutoff = new Date(Date.now() - maxAgeMs);
1913
+ const rows = await db
1914
+ .select({ id: actionInvocations.id, workspaceId: actionInvocations.workspaceId })
1915
+ .from(actionInvocations)
1916
+ .where(and(eq(actionInvocations.status, 'pending'), eq(actionInvocations.dispatchAttempts, 0), lte(actionInvocations.createdAt, cutoff)));
1917
+ if (rows.length === 0)
1918
+ return;
1919
+ // Give any concurrent dispatcher's send→record window (`dispatchNodeAttempt`
1920
+ // UPDATE, one D1 round-trip) time to close before the atomic UPDATE fires.
1921
+ // Combined with the UPDATE's `dispatch_attempts = 0` re-check, this makes an
1922
+ // in-flight dispatch reliably invisible to the age sweep instead of racing
1923
+ // with it. See `NEVER_DISPATCHED_SWEEP_GRACE_MS`.
1924
+ if (graceMs > 0) {
1925
+ await new Promise((resolve) => setTimeout(resolve, graceMs));
1926
+ }
1927
+ const byWorkspace = new Map();
1928
+ for (const row of rows) {
1929
+ const list = byWorkspace.get(row.workspaceId) ?? [];
1930
+ list.push(row.id);
1931
+ byWorkspace.set(row.workspaceId, list);
1932
+ }
1933
+ for (const [workspaceId, ids] of byWorkspace) {
1934
+ // Chunk ids so the IN clause stays under D1's 100-bound-parameter cap. A per-
1935
+ // chunk try/catch keeps one bad chunk from stalling the whole workspace, so a
1936
+ // large stale backlog can actually drain across sweeps.
1937
+ for (let i = 0; i < ids.length; i += D1_SAFE_IN_QUERY_CHUNK_SIZE) {
1938
+ const chunk = ids.slice(i, i + D1_SAFE_IN_QUERY_CHUNK_SIZE);
1939
+ try {
1940
+ const failed = await failNeverDispatchedInvocationRows(db, workspaceId, chunk, cutoff, 'never_dispatched_expired');
1941
+ if (completionDeps && failed.length > 0) {
1942
+ await emitFailedInvocationEffects(completionDeps, workspaceId, failed);
1943
+ }
1944
+ }
1945
+ catch {
1946
+ // Leave this chunk for the next sweep; other chunks still get their shot.
1947
+ }
1948
+ }
1949
+ }
1950
+ }
1951
+ /**
1952
+ * Terminally fail never-dispatched rows atomically: the UPDATE re-checks the
1953
+ * SELECT predicates (`status = 'pending'`, `dispatch_attempts = 0`, `created_at
1954
+ * <= cutoff`), so a row that was concurrently dispatched between the sweep's
1955
+ * SELECT and this UPDATE is skipped instead of being killed mid-flight. Never-
1956
+ * dispatched rows carry no spawn reservation (dispatch_attempts = 0 means no
1957
+ * node was ever chosen), so no capacity release is needed.
1958
+ */
1959
+ async function failNeverDispatchedInvocationRows(db, workspaceId, invocationIds, cutoff, error) {
1960
+ if (invocationIds.length === 0)
1961
+ return [];
1962
+ return await db
1963
+ .update(actionInvocations)
1964
+ .set({ status: 'failed', error, completedAt: new Date(), spawnReservedAt: null })
1965
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), inArray(actionInvocations.id, invocationIds), eq(actionInvocations.status, 'pending'), eq(actionInvocations.dispatchAttempts, 0), lte(actionInvocations.createdAt, cutoff)))
1966
+ .returning();
1967
+ }
1644
1968
  export async function sweepTimedOutInvocations(db, registry, opts = {}) {
1645
1969
  const sweepOpts = typeof opts === 'number' || opts === null ? {} : opts;
1646
1970
  const timeoutMs = typeof opts === 'number' ? opts : sweepOpts.timeoutMs ?? ACTION_DISPATCH_TIMEOUT_MS;
1647
1971
  await failUnreachableAgentInvocations(db, registry, sweepOpts.handlerUnreachableTtlMs ?? ACTION_HANDLER_UNREACHABLE_TTL_MS, sweepOpts.completionDeps);
1972
+ await failNeverDispatchedExpiredInvocations(db, sweepOpts.pendingInvocationMaxAgeMs ?? PENDING_INVOCATION_MAX_AGE_MS, sweepOpts.neverDispatchedSweepGraceMs ?? NEVER_DISPATCHED_SWEEP_GRACE_MS, sweepOpts.completionDeps);
1648
1973
  const now = new Date();
1649
1974
  const cutoff = new Date(Date.now() - timeoutMs);
1650
1975
  const rows = await db