@relaycast/engine 5.1.1 → 6.0.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 (70) hide show
  1. package/dist/adapters/node/database.d.ts.map +1 -1
  2. package/dist/adapters/node/database.js +204 -27
  3. package/dist/adapters/node/database.js.map +1 -1
  4. package/dist/adapters/node/realtime.d.ts +29 -1
  5. package/dist/adapters/node/realtime.d.ts.map +1 -1
  6. package/dist/adapters/node/realtime.js +241 -50
  7. package/dist/adapters/node/realtime.js.map +1 -1
  8. package/dist/auth/tokenKind.d.ts +1 -1
  9. package/dist/auth/tokenKind.d.ts.map +1 -1
  10. package/dist/auth/tokenKind.js +4 -1
  11. package/dist/auth/tokenKind.js.map +1 -1
  12. package/dist/db/migrations/0028_node_providers.sql +55 -0
  13. package/dist/db/schema.d.ts +376 -0
  14. package/dist/db/schema.d.ts.map +1 -1
  15. package/dist/db/schema.js +59 -1
  16. package/dist/db/schema.js.map +1 -1
  17. package/dist/engine/action.d.ts +47 -1
  18. package/dist/engine/action.d.ts.map +1 -1
  19. package/dist/engine/action.js +342 -43
  20. package/dist/engine/action.js.map +1 -1
  21. package/dist/engine/delivery.d.ts.map +1 -1
  22. package/dist/engine/delivery.js +2 -1
  23. package/dist/engine/delivery.js.map +1 -1
  24. package/dist/engine/node.d.ts +71 -28
  25. package/dist/engine/node.d.ts.map +1 -1
  26. package/dist/engine/node.js +357 -189
  27. package/dist/engine/node.js.map +1 -1
  28. package/dist/engine/nodeContext.d.ts.map +1 -1
  29. package/dist/engine/nodeContext.js +17 -6
  30. package/dist/engine/nodeContext.js.map +1 -1
  31. package/dist/engine/nodeDeliver.d.ts.map +1 -1
  32. package/dist/engine/nodeDeliver.js +4 -1
  33. package/dist/engine/nodeDeliver.js.map +1 -1
  34. package/dist/engine/nodeLock.d.ts +2 -0
  35. package/dist/engine/nodeLock.d.ts.map +1 -0
  36. package/dist/engine/nodeLock.js +31 -0
  37. package/dist/engine/nodeLock.js.map +1 -0
  38. package/dist/engine/nodeProvider.d.ts +62 -0
  39. package/dist/engine/nodeProvider.d.ts.map +1 -0
  40. package/dist/engine/nodeProvider.js +256 -0
  41. package/dist/engine/nodeProvider.js.map +1 -0
  42. package/dist/engine/placement.d.ts +10 -0
  43. package/dist/engine/placement.d.ts.map +1 -1
  44. package/dist/engine/placement.js +10 -0
  45. package/dist/engine/placement.js.map +1 -1
  46. package/dist/engine/trigger.d.ts.map +1 -1
  47. package/dist/engine/trigger.js +17 -3
  48. package/dist/engine/trigger.js.map +1 -1
  49. package/dist/middleware/auth.d.ts +6 -0
  50. package/dist/middleware/auth.d.ts.map +1 -1
  51. package/dist/middleware/auth.js +6 -0
  52. package/dist/middleware/auth.js.map +1 -1
  53. package/dist/node-control.d.ts +1 -1
  54. package/dist/node-control.d.ts.map +1 -1
  55. package/dist/node-control.js +11 -1
  56. package/dist/node-control.js.map +1 -1
  57. package/dist/ports/auth.d.ts +1 -1
  58. package/dist/ports/auth.d.ts.map +1 -1
  59. package/dist/ports/realtime.d.ts +30 -2
  60. package/dist/ports/realtime.d.ts.map +1 -1
  61. package/dist/routes/deliveryRouting.d.ts.map +1 -1
  62. package/dist/routes/deliveryRouting.js +6 -1
  63. package/dist/routes/deliveryRouting.js.map +1 -1
  64. package/dist/routes/message.d.ts.map +1 -1
  65. package/dist/routes/message.js +44 -14
  66. package/dist/routes/message.js.map +1 -1
  67. package/dist/routes/node.d.ts.map +1 -1
  68. package/dist/routes/node.js +92 -3
  69. package/dist/routes/node.js.map +1 -1
  70. package/package.json +3 -3
@@ -4,6 +4,7 @@ import { generateId } from './snowflake.js';
4
4
  import { codedError } from '../lib/httpError.js';
5
5
  import { toFleetWireJson } from './deliveryWire.js';
6
6
  import { claimSpawnNode, chooseNodeForAction, isNodeLive, releaseNodeCapacity, reserveNodeCapacity } from './placement.js';
7
+ import { DEFAULT_PROVIDER_NAME, capacityProviderName, getProvider, isProviderLive } from './nodeProvider.js';
7
8
  const OPEN_INVOCATION_STATUSES = ['pending', 'dispatched', 'invoked'];
8
9
  export const ACTION_DISPATCH_TIMEOUT_MS = 30_000;
9
10
  const ACTION_RETRY_BACKOFF_MS = 5_000;
@@ -84,11 +85,39 @@ function publicAction(row) {
84
85
  created_at: row.createdAt.toISOString(),
85
86
  };
86
87
  }
87
- async function fetchAction(db, workspaceId, actionName) {
88
+ /** Resolution priority when a name has several handlers: agent-hosted, then a
89
+ * node action with a workspace-global alias, then a plain node-scoped action. */
90
+ function actionResolutionRank(row) {
91
+ if (row.handlerNodeId === null)
92
+ return 0;
93
+ if (row.isGlobal)
94
+ return 1;
95
+ return 2;
96
+ }
97
+ /**
98
+ * Resolve an action by name for invocation. The workspace-scoped invoke
99
+ * (`POST /v1/actions/:name/invoke`) reaches only agent-hosted actions and node
100
+ * actions that claimed a workspace-global alias — plain node-scoped actions are
101
+ * node-addressed there. `includeNodeScoped` lifts that filter for callers that
102
+ * bind to a name without a node (message triggers), so a trigger can fire a
103
+ * fleet-provider action; the resolved node-scoped row is then dispatched
104
+ * node-addressed by {@link invokeAction}. `(workspaceId, name)` is not unique
105
+ * across nodes, so a stable id tiebreak keeps resolution deterministic.
106
+ */
107
+ async function fetchAction(db, workspaceId, actionName, includeNodeScoped = false) {
108
+ const rows = await db
109
+ .select()
110
+ .from(actions)
111
+ .where(and(eq(actions.workspaceId, workspaceId), eq(actions.name, actionName), eq(actions.isActive, true), ...(includeNodeScoped ? [] : [or(isNull(actions.handlerNodeId), eq(actions.isGlobal, true))])));
112
+ return rows.sort((a, b) => actionResolutionRank(a) - actionResolutionRank(b) ||
113
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0] ?? null;
114
+ }
115
+ /** Resolve a node-addressed action by its unique `(node, name)` key. */
116
+ async function fetchNodeAction(db, workspaceId, nodeId, actionName) {
88
117
  const [action] = await db
89
118
  .select()
90
119
  .from(actions)
91
- .where(and(eq(actions.workspaceId, workspaceId), eq(actions.name, actionName), eq(actions.isActive, true)));
120
+ .where(and(eq(actions.workspaceId, workspaceId), eq(actions.handlerNodeId, nodeId), eq(actions.name, actionName), eq(actions.isActive, true)));
92
121
  return action ?? null;
93
122
  }
94
123
  export async function registerAction(db, workspaceId, data) {
@@ -224,6 +253,52 @@ async function createInvocation(db, workspaceId, action, data) {
224
253
  .returning();
225
254
  return invocation;
226
255
  }
256
+ /** Is a node provider currently invokable — connection up AND heartbeat handlers_live. */
257
+ async function isNodeProviderLive(db, registry, workspaceId, nodeId, providerName) {
258
+ if (!registry.isProviderConnected(workspaceId, nodeId, providerName))
259
+ return false;
260
+ const provider = await getProvider(db, workspaceId, nodeId, providerName);
261
+ return !!provider && isProviderLive(provider);
262
+ }
263
+ async function failInvocationForUnavailableProvider(db, workspaceId, invocationId) {
264
+ await db
265
+ .update(actionInvocations)
266
+ .set({ status: 'failed', error: 'handler_unavailable', completedAt: new Date(), spawnReservedAt: null })
267
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
268
+ }
269
+ /**
270
+ * Dispatch an invoke to a specific provider on a node. When the provider is
271
+ * offline the invoke fails fast (the invocation is failed) unless it opted into
272
+ * the per-provider offline queue. Retry placement can request a non-terminal
273
+ * unavailable result so it can try another node before failing the invocation.
274
+ */
275
+ async function dispatchNodeProviderInvocation(args, options = {}) {
276
+ const live = await isNodeProviderLive(args.db, args.registry, args.workspaceId, args.nodeId, args.providerName);
277
+ if (!live) {
278
+ if (!args.queue) {
279
+ // Release any capacity reserved for this fail-fast spawn so the node's
280
+ // reserved-agent count doesn't stay inflated.
281
+ if (args.reservationHeld) {
282
+ await releaseNodeCapacity(args.db, args.workspaceId, args.nodeId);
283
+ }
284
+ if (options.failIfUnavailable === false) {
285
+ return { accepted: false, pending: false, providerUnavailable: true };
286
+ }
287
+ await failInvocationForUnavailableProvider(args.db, args.workspaceId, args.invocationId);
288
+ throw codedError(`Provider "${args.providerName}" is offline for action "${args.action}"`, 'handler_unavailable', 503);
289
+ }
290
+ // The DB is the authoritative offline queue. Do not call sendToProvider
291
+ // here: a connected provider whose heartbeat says handlers_live=false still
292
+ // has a socket, and sendToProvider would deliver the frame immediately.
293
+ const accepted = await dispatchNodeAttempt(args.db, args.workspaceId, args.invocationId, args.nodeId, {
294
+ pending: true,
295
+ reservationHeld: args.reservationHeld,
296
+ actionId: args.actionId,
297
+ });
298
+ return { accepted, pending: true };
299
+ }
300
+ return dispatchNodeInvocation({ ...args });
301
+ }
227
302
  async function dispatchRelease(args) {
228
303
  if (!args.registry) {
229
304
  throw codedError('Node dispatch is not available', 'node_dispatch_unavailable', 503);
@@ -249,12 +324,14 @@ async function dispatchRelease(args) {
249
324
  caller_name: args.data.caller_name,
250
325
  action_name: 'release',
251
326
  });
327
+ // Release is a capacity operation handled by the provider hosting the agent.
252
328
  const dispatched = await dispatchNodeInvocation({
253
329
  db: args.db,
254
330
  registry: args.registry,
255
331
  workspaceId: args.workspaceId,
256
332
  invocationId: invocation.id,
257
333
  nodeId: agent.locationNodeId,
334
+ providerName: agent.providerName,
258
335
  action: 'release',
259
336
  input,
260
337
  });
@@ -269,10 +346,24 @@ async function dispatchRelease(args) {
269
346
  created_at: invocation.createdAt.toISOString(),
270
347
  };
271
348
  }
349
+ function spawnResult(invocation, nodeId, dispatched) {
350
+ return {
351
+ invocation_id: invocation.id,
352
+ action_name: 'spawn',
353
+ handler_agent_id: null,
354
+ handler_node_id: nodeId,
355
+ dispatched_node_id: dispatched.accepted ? nodeId : null,
356
+ input: recordInput(invocation.input),
357
+ status: dispatched.accepted ? (dispatched.pending ? 'pending' : 'dispatched') : 'pending',
358
+ created_at: invocation.createdAt.toISOString(),
359
+ };
360
+ }
272
361
  async function dispatchSpawn(args) {
273
362
  if (!args.registry) {
274
363
  throw codedError('Node dispatch is not available', 'node_dispatch_unavailable', 503);
275
364
  }
365
+ const input = recordInput(args.data.input);
366
+ const capability = dispatchActionNameForInvocation('spawn', input);
276
367
  const invocation = await createInvocation(args.db, args.workspaceId, null, {
277
368
  input: args.data.input,
278
369
  caller_id: args.data.caller_id,
@@ -280,33 +371,126 @@ async function dispatchSpawn(args) {
280
371
  });
281
372
  const placement = await claimSpawnNode(args.db, args.workspaceId, {
282
373
  actionName: 'spawn',
283
- input: args.data.input,
374
+ input,
284
375
  callerId: args.data.caller_id,
376
+ preferredNodeId: args.targetNodeId,
285
377
  });
378
+ const nodeId = placement.node.id;
379
+ // A registered `spawn:<harness>` action shadows native capacity on this node.
380
+ // Capacity-direct delegation (ctx.spawnAgent) bypasses the shadow so a handler
381
+ // that delegates cannot re-enter itself.
382
+ if (!args.bypassShadow) {
383
+ const shadow = await fetchNodeAction(args.db, args.workspaceId, nodeId, capability);
384
+ if (shadow && shadow.handlerProvider) {
385
+ if (!placement.queued)
386
+ await releaseNodeCapacity(args.db, args.workspaceId, nodeId);
387
+ const dispatched = await dispatchNodeProviderInvocation({
388
+ db: args.db,
389
+ registry: args.registry,
390
+ workspaceId: args.workspaceId,
391
+ invocationId: invocation.id,
392
+ nodeId,
393
+ providerName: shadow.handlerProvider,
394
+ action: capability,
395
+ input,
396
+ queue: shadow.queue,
397
+ });
398
+ return spawnResult(invocation, nodeId, dispatched);
399
+ }
400
+ }
401
+ const capProvider = (await capacityProviderName(args.db, args.workspaceId, nodeId, capability)) ?? DEFAULT_PROVIDER_NAME;
286
402
  const dispatched = await dispatchNodeInvocation({
287
403
  db: args.db,
288
404
  registry: args.registry,
289
405
  workspaceId: args.workspaceId,
290
406
  invocationId: invocation.id,
291
- nodeId: placement.node.id,
292
- action: placement.capability,
293
- input: recordInput(invocation.input),
407
+ nodeId,
408
+ providerName: capProvider,
409
+ action: capability,
410
+ input,
294
411
  pending: placement.queued,
295
412
  reservationHeld: !placement.queued,
296
413
  });
414
+ return spawnResult(invocation, nodeId, dispatched);
415
+ }
416
+ /**
417
+ * Capacity-direct spawn used by handler-context `ctx.spawnAgent`: it targets a
418
+ * node's capacity executor and never re-enters action dispatch, so a
419
+ * `spawn:<harness>` shadow handler that delegates cannot recurse into itself.
420
+ */
421
+ export async function dispatchCapacitySpawn(db, workspaceId, data, options = {}) {
422
+ return dispatchSpawn({
423
+ db,
424
+ registry: options.nodeConnections,
425
+ workspaceId,
426
+ data,
427
+ targetNodeId: data.target_node_id,
428
+ bypassShadow: true,
429
+ });
430
+ }
431
+ /**
432
+ * Invoke a node-addressed action: `POST /v1/nodes/:node/actions/:name/invoke`.
433
+ * Resolves capability -> provider -> socket on the given node. A `spawn:*` name
434
+ * with no shadow action falls through to native capacity on that node.
435
+ */
436
+ export async function invokeNodeAction(db, workspaceId, nodeId, actionName, data, options = {}) {
437
+ if (!options.nodeConnections) {
438
+ throw codedError('Node dispatch is not available', 'node_dispatch_unavailable', 503);
439
+ }
440
+ const action = await fetchNodeAction(db, workspaceId, nodeId, actionName);
441
+ if (!action) {
442
+ if (isSpawnInvocation(actionName)) {
443
+ // Carry the harness from the URL (`spawn:<harness>`) into placement so the
444
+ // native fallback targets the right capacity even when the body omits it.
445
+ const harness = actionName.startsWith('spawn:') ? actionName.slice('spawn:'.length) : undefined;
446
+ const spawnInput = harness ? { ...(data.input ?? {}), capability: harness } : data.input;
447
+ return dispatchSpawn({ db, registry: options.nodeConnections, workspaceId, data: { ...data, input: spawnInput }, targetNodeId: nodeId });
448
+ }
449
+ if (isReleaseInvocation(actionName)) {
450
+ return dispatchRelease({ db, registry: options.nodeConnections, workspaceId, data });
451
+ }
452
+ throw codedError(`Action "${actionName}" not found on node`, 'action_not_found', 404);
453
+ }
454
+ if (action.availableTo && action.availableTo.length > 0) {
455
+ if (!data.caller_name || !action.availableTo.includes(data.caller_name)) {
456
+ const who = data.caller_name ? `Agent "${data.caller_name}"` : 'Caller';
457
+ throw codedError(`${who} is not authorized to invoke action "${actionName}"`, 'action_denied', 403);
458
+ }
459
+ }
460
+ const invocation = await createInvocation(db, workspaceId, action, {
461
+ input: data.input,
462
+ caller_id: data.caller_id,
463
+ caller_name: data.caller_name,
464
+ });
465
+ const providerName = action.handlerProvider ?? DEFAULT_PROVIDER_NAME;
466
+ const reservedNode = isSpawnInvocation(action.name)
467
+ ? await reserveNodeCapacity(db, workspaceId, nodeId)
468
+ : null;
469
+ const dispatched = await dispatchNodeProviderInvocation({
470
+ db,
471
+ registry: options.nodeConnections,
472
+ workspaceId,
473
+ invocationId: invocation.id,
474
+ nodeId,
475
+ providerName,
476
+ action: action.name,
477
+ input: recordInput(invocation.input),
478
+ queue: action.queue,
479
+ reservationHeld: !!reservedNode,
480
+ });
297
481
  return {
298
482
  invocation_id: invocation.id,
299
- action_name: 'spawn',
483
+ action_name: actionName,
300
484
  handler_agent_id: null,
301
- handler_node_id: placement.node.id,
302
- dispatched_node_id: dispatched.accepted ? placement.node.id : null,
485
+ handler_node_id: nodeId,
486
+ dispatched_node_id: dispatched.accepted ? nodeId : null,
303
487
  input: recordInput(invocation.input),
304
488
  status: dispatched.accepted ? (dispatched.pending ? 'pending' : 'dispatched') : 'pending',
305
489
  created_at: invocation.createdAt.toISOString(),
306
490
  };
307
491
  }
308
492
  export async function invokeAction(db, workspaceId, actionName, data, options = {}) {
309
- const action = await fetchAction(db, workspaceId, actionName);
493
+ const action = await fetchAction(db, workspaceId, actionName, options.includeNodeScoped);
310
494
  if (!action && actionName === 'spawn') {
311
495
  return dispatchSpawn({
312
496
  db,
@@ -349,14 +533,21 @@ export async function invokeAction(db, workspaceId, actionName, data, options =
349
533
  const reservedNode = isSpawnInvocation(action.name)
350
534
  ? await reserveNodeCapacity(db, workspaceId, action.handlerNodeId)
351
535
  : null;
352
- const dispatched = await dispatchNodeInvocation({
536
+ const providerName = action.handlerProvider ?? DEFAULT_PROVIDER_NAME;
537
+ // Route through the per-provider liveness gate so an offline provider fails
538
+ // fast unless the capability opted into queue. The synthetic `default`
539
+ // provider keeps the legacy queue-on-offline behavior (broker reconnect
540
+ // resilience), so its aliases queue and drain on reconnect as before.
541
+ const dispatched = await dispatchNodeProviderInvocation({
353
542
  db,
354
543
  registry: options.nodeConnections,
355
544
  workspaceId,
356
545
  invocationId: invocation.id,
357
546
  nodeId: action.handlerNodeId,
547
+ providerName,
358
548
  action: action.name,
359
549
  input: recordInput(invocation.input),
550
+ queue: action.queue || providerName === DEFAULT_PROVIDER_NAME,
360
551
  reservationHeld: !!reservedNode,
361
552
  });
362
553
  return {
@@ -382,6 +573,7 @@ export async function invokeAction(db, workspaceId, actionName, data, options =
382
573
  name: agents.name,
383
574
  locationType: agents.locationType,
384
575
  locationNodeId: agents.locationNodeId,
576
+ providerName: agents.providerName,
385
577
  })
386
578
  .from(agents)
387
579
  .where(and(eq(agents.workspaceId, workspaceId), eq(agents.id, action.handlerAgentId)));
@@ -399,6 +591,7 @@ export async function invokeAction(db, workspaceId, actionName, data, options =
399
591
  workspaceId,
400
592
  invocationId: invocation.id,
401
593
  nodeId: handlerAgent.locationNodeId,
594
+ providerName: handlerAgent.providerName,
402
595
  action: action.name,
403
596
  input: recordInput(invocation.input),
404
597
  agent: { id: handlerAgent.id, name: handlerAgent.name },
@@ -501,6 +694,7 @@ async function dispatchNodeAttempt(db, workspaceId, invocationId, nodeId, opts =
501
694
  ...stateFields,
502
695
  dispatchedNodeId: nodeId,
503
696
  spawnReservedAt: opts.reservationHeld ? new Date() : null,
697
+ ...(opts.actionId ? { actionId: opts.actionId } : {}),
504
698
  ...attemptFields,
505
699
  })
506
700
  .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)))
@@ -526,15 +720,20 @@ export async function markDrainedInvocationDispatched(db, workspaceId, invocatio
526
720
  return !!updated;
527
721
  }
528
722
  async function dispatchNodeInvocation(args) {
529
- const connectedBefore = args.registry.isNodeConnected(args.workspaceId, args.nodeId);
530
- const sent = await args.registry.sendToNode(args.workspaceId, args.nodeId, {
723
+ const frame = {
531
724
  v: 1,
532
725
  type: 'action.invoke',
533
726
  invocation_id: args.invocationId,
534
727
  action: args.action,
535
728
  ...(args.agent ? { agent_id: args.agent.id, agent_name: args.agent.name } : {}),
536
729
  input: toFleetWireJson(args.input),
537
- });
730
+ };
731
+ const connectedBefore = args.providerName
732
+ ? args.registry.isProviderConnected(args.workspaceId, args.nodeId, args.providerName)
733
+ : args.registry.isNodeConnected(args.workspaceId, args.nodeId);
734
+ const sent = args.providerName
735
+ ? await args.registry.sendToProvider(args.workspaceId, args.nodeId, args.providerName, frame)
736
+ : await args.registry.sendToNode(args.workspaceId, args.nodeId, frame);
538
737
  if (!sent)
539
738
  return { accepted: false, pending: false };
540
739
  const pending = !!args.pending || !connectedBefore;
@@ -543,6 +742,7 @@ async function dispatchNodeInvocation(args) {
543
742
  retryAfterAt: args.retryAfterAt,
544
743
  reservationHeld: args.reservationHeld,
545
744
  skipIncrementAttempts: args.skipIncrementAttempts,
745
+ actionId: args.actionId,
546
746
  });
547
747
  return { accepted, pending };
548
748
  }
@@ -576,6 +776,7 @@ async function targetAgentForInvocation(db, invocation) {
576
776
  agentName: agents.name,
577
777
  locationType: agents.locationType,
578
778
  nodeId: agents.locationNodeId,
779
+ providerName: agents.providerName,
579
780
  })
580
781
  .from(actionInvocations)
581
782
  .innerJoin(actions, eq(actionInvocations.actionId, actions.id))
@@ -583,7 +784,7 @@ async function targetAgentForInvocation(db, invocation) {
583
784
  .where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id)));
584
785
  if (!row || row.locationType !== 'via_node' || !row.nodeId)
585
786
  return null;
586
- return { agentId: row.agentId, agentName: row.agentName, nodeId: row.nodeId };
787
+ return { agentId: row.agentId, agentName: row.agentName, nodeId: row.nodeId, providerName: row.providerName };
587
788
  }
588
789
  export async function drainNodeInvocations(db, registry, workspaceId, nodeId) {
589
790
  const now = new Date();
@@ -596,10 +797,12 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId) {
596
797
  spawnReservedAt: actionInvocations.spawnReservedAt,
597
798
  dispatchedNodeId: actionInvocations.dispatchedNodeId,
598
799
  handlerNodeId: actions.handlerNodeId,
800
+ handlerProvider: actions.handlerProvider,
599
801
  handlerAgentId: agents.id,
600
802
  handlerAgentName: agents.name,
601
803
  handlerAgentLocationType: agents.locationType,
602
804
  handlerAgentNodeId: agents.locationNodeId,
805
+ handlerAgentProvider: agents.providerName,
603
806
  })
604
807
  .from(actionInvocations)
605
808
  .leftJoin(actions, eq(actionInvocations.actionId, actions.id))
@@ -617,11 +820,37 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId) {
617
820
  const targetNodeId = targetAgent?.nodeId ?? row.handlerNodeId ?? row.dispatchedNodeId;
618
821
  if (targetNodeId !== nodeId)
619
822
  continue;
823
+ const input = recordInput(row.input);
824
+ // Resolve the target provider so the offline queue drains per provider. A
825
+ // spawn with a registered shadow re-dispatches to the shadow (no capacity
826
+ // reservation); otherwise native capacity handles it.
827
+ let shadowProvider = null;
828
+ if (isSpawnInvocation(row.actionName)) {
829
+ const shadow = await fetchNodeAction(db, workspaceId, nodeId, dispatchActionNameForInvocation(row.actionName, input));
830
+ shadowProvider = shadow?.handlerProvider ?? null;
831
+ }
832
+ const providerName = targetAgent
833
+ ? row.handlerAgentProvider ?? DEFAULT_PROVIDER_NAME
834
+ : row.handlerNodeId
835
+ ? row.handlerProvider ?? DEFAULT_PROVIDER_NAME
836
+ : shadowProvider
837
+ ?? (isSpawnInvocation(row.actionName)
838
+ ? (await capacityProviderName(db, workspaceId, nodeId, dispatchActionNameForInvocation(row.actionName, input))) ?? DEFAULT_PROVIDER_NAME
839
+ : DEFAULT_PROVIDER_NAME);
840
+ const nativeSpawn = isSpawnInvocation(row.actionName) && !shadowProvider;
841
+ // Skip draining to a provider that is disconnected or whose heartbeat says
842
+ // handlers are not live. A missing provider row is a legacy single-socket
843
+ // node, for which connection state remains the available liveness signal.
844
+ if (!registry.isProviderConnected(workspaceId, nodeId, providerName))
845
+ continue;
846
+ const provider = await getProvider(db, workspaceId, nodeId, providerName);
847
+ const explicitlyAttached = registry.isProviderAttached?.(workspaceId, nodeId, providerName) ?? true;
848
+ if (provider && explicitlyAttached && !isProviderLive(provider))
849
+ continue;
620
850
  let reservedForDrain = false;
621
851
  try {
622
- const input = recordInput(row.input);
623
- let reservationHeld = isSpawnInvocation(row.actionName) && !!row.spawnReservedAt;
624
- if (isSpawnInvocation(row.actionName) && !reservationHeld) {
852
+ let reservationHeld = nativeSpawn && !!row.spawnReservedAt;
853
+ if (nativeSpawn && !reservationHeld) {
625
854
  const reserved = await reserveNodeCapacity(db, workspaceId, nodeId);
626
855
  if (!reserved) {
627
856
  const [node] = await db
@@ -649,6 +878,7 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId) {
649
878
  workspaceId,
650
879
  invocationId: row.id,
651
880
  nodeId,
881
+ providerName,
652
882
  action: dispatchActionNameForInvocation(row.actionName, input),
653
883
  input,
654
884
  agent: targetAgent ? { id: targetAgent.id, name: targetAgent.name } : null,
@@ -673,9 +903,27 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId) {
673
903
  return drained;
674
904
  }
675
905
  export async function rescheduleNodeInvocation(db, registry, invocation, opts = {}) {
676
- if (invocation.dispatchedNodeId && isSpawnInvocation(invocation.actionName)) {
906
+ // Only release capacity the invocation actually holds. A shadowed spawn
907
+ // dropped its native reservation at dispatch (spawnReservedAt is null), so it
908
+ // must not release capacity that now belongs to another spawn.
909
+ if (invocation.dispatchedNodeId && isSpawnInvocation(invocation.actionName) && invocation.spawnReservedAt) {
677
910
  await releaseNodeCapacity(db, invocation.workspaceId, invocation.dispatchedNodeId);
678
911
  }
912
+ // A shadowed spawn stays pinned to its node: while a `spawn:<harness>` shadow
913
+ // is registered there, native capacity for that harness is unreachable — even
914
+ // across a node loss — so it never falls through to generic placement (which
915
+ // would silently bypass the shadow). It re-drains when the provider returns.
916
+ if (invocation.dispatchedNodeId && isSpawnInvocation(invocation.actionName)) {
917
+ const capability = dispatchActionNameForInvocation(invocation.actionName, recordInput(invocation.input));
918
+ const shadow = await fetchNodeAction(db, invocation.workspaceId, invocation.dispatchedNodeId, capability);
919
+ if (shadow) {
920
+ await db
921
+ .update(actionInvocations)
922
+ .set({ status: 'pending', dispatchedAt: null, spawnReservedAt: null, retryAfterAt: null })
923
+ .where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
924
+ return false;
925
+ }
926
+ }
679
927
  const targetAgent = await targetAgentForInvocation(db, invocation);
680
928
  if (targetAgent) {
681
929
  const dispatched = await dispatchNodeInvocation({
@@ -684,6 +932,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
684
932
  workspaceId: invocation.workspaceId,
685
933
  invocationId: invocation.id,
686
934
  nodeId: targetAgent.nodeId,
935
+ providerName: targetAgent.providerName,
687
936
  action: invocation.actionName,
688
937
  input: recordInput(invocation.input),
689
938
  agent: { id: targetAgent.agentId, name: targetAgent.agentName },
@@ -703,33 +952,79 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
703
952
  const actionToSend = dispatchActionNameForInvocation(invocation.actionName, input);
704
953
  const baseExclude = Array.from(new Set([...attempted, ...current]));
705
954
  const candidates = opts.allowAttemptedFallback ? [baseExclude, []] : [baseExclude];
706
- for (const excludeNodeIds of candidates) {
707
- try {
708
- const placement = await selectRetryPlacement(db, invocation, excludeNodeIds);
709
- if (placement.queued) {
710
- await dispatchNodeAttempt(db, invocation.workspaceId, invocation.id, placement.node.id, {
711
- pending: true,
712
- retryAfterAt: opts.retryAfterAt ?? null,
713
- reservationHeld: false,
955
+ const providerRejectedNodeIds = new Set();
956
+ for (const candidateExcludes of candidates) {
957
+ const excludeNodeIds = new Set([...candidateExcludes, ...providerRejectedNodeIds]);
958
+ while (true) {
959
+ try {
960
+ const placement = await selectRetryPlacement(db, invocation, Array.from(excludeNodeIds));
961
+ // Resolve the owning provider before accepting node-level queued
962
+ // placement. A node can be live through its broker while the named
963
+ // provider for this action is offline, and provider queue policy must
964
+ // decide whether that retry may wait.
965
+ const target = await fetchNodeAction(db, invocation.workspaceId, placement.node.id, actionToSend);
966
+ const reservationHeld = isSpawnInvocation(invocation.actionName) && !placement.queued;
967
+ if (target?.handlerProvider) {
968
+ // A spawn shadow is an action, not native capacity. claimSpawnNode
969
+ // reserved capacity before discovering the shadow, so release it just
970
+ // like the initial dispatch path does before provider dispatch.
971
+ if (reservationHeld) {
972
+ await releaseNodeCapacity(db, invocation.workspaceId, placement.node.id);
973
+ }
974
+ const dispatched = await dispatchNodeProviderInvocation({
975
+ db,
976
+ registry,
977
+ workspaceId: invocation.workspaceId,
978
+ invocationId: invocation.id,
979
+ nodeId: placement.node.id,
980
+ providerName: target.handlerProvider,
981
+ action: actionToSend,
982
+ actionId: target.id,
983
+ input,
984
+ queue: target.queue,
985
+ reservationHeld: false,
986
+ }, { failIfUnavailable: false });
987
+ if (dispatched.providerUnavailable) {
988
+ providerRejectedNodeIds.add(placement.node.id);
989
+ excludeNodeIds.add(placement.node.id);
990
+ continue;
991
+ }
992
+ return dispatched.accepted;
993
+ }
994
+ // No named action owner means native spawn/capacity or a legacy
995
+ // single-socket node; retain the node-level pending/default behavior.
996
+ if (placement.queued) {
997
+ await dispatchNodeAttempt(db, invocation.workspaceId, invocation.id, placement.node.id, {
998
+ pending: true,
999
+ retryAfterAt: opts.retryAfterAt ?? null,
1000
+ reservationHeld: false,
1001
+ actionId: target?.id,
1002
+ });
1003
+ return true;
1004
+ }
1005
+ const dispatched = await dispatchNodeInvocation({
1006
+ db,
1007
+ registry,
1008
+ workspaceId: invocation.workspaceId,
1009
+ invocationId: invocation.id,
1010
+ nodeId: placement.node.id,
1011
+ action: actionToSend,
1012
+ actionId: target?.id,
1013
+ input,
1014
+ reservationHeld,
714
1015
  });
715
- return true;
1016
+ return dispatched.accepted;
1017
+ }
1018
+ catch {
1019
+ // This candidate phase is exhausted; optionally retry attempted nodes.
1020
+ break;
716
1021
  }
717
- const dispatched = await dispatchNodeInvocation({
718
- db,
719
- registry,
720
- workspaceId: invocation.workspaceId,
721
- invocationId: invocation.id,
722
- nodeId: placement.node.id,
723
- action: actionToSend,
724
- input,
725
- reservationHeld: isSpawnInvocation(invocation.actionName) && !placement.queued,
726
- });
727
- return dispatched.accepted;
728
- }
729
- catch {
730
- // Try the next candidate set.
731
1022
  }
732
1023
  }
1024
+ if (providerRejectedNodeIds.size > 0) {
1025
+ await failInvocationForUnavailableProvider(db, invocation.workspaceId, invocation.id);
1026
+ return false;
1027
+ }
733
1028
  await db
734
1029
  .update(actionInvocations)
735
1030
  .set({
@@ -825,6 +1120,7 @@ export async function completeNodeInvocation(db, registry, workspaceId, nodeId,
825
1120
  durationMs: actionInvocations.durationMs,
826
1121
  dispatchedNodeId: actionInvocations.dispatchedNodeId,
827
1122
  dispatchedAt: actionInvocations.dispatchedAt,
1123
+ spawnReservedAt: actionInvocations.spawnReservedAt,
828
1124
  attemptedNodeIds: actionInvocations.attemptedNodeIds,
829
1125
  dispatchAttempts: actionInvocations.dispatchAttempts,
830
1126
  createdAt: actionInvocations.createdAt,
@@ -865,7 +1161,10 @@ export async function completeNodeInvocation(db, registry, workspaceId, nodeId,
865
1161
  })
866
1162
  .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), eq(actionInvocations.dispatchedNodeId, nodeId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)))
867
1163
  .returning();
868
- if (updated && isSpawnInvocation(updated.actionName) && updated.dispatchedNodeId) {
1164
+ // Release only capacity this invocation actually reserved. A shadowed spawn
1165
+ // holds no native reservation (spawnReservedAt is null), so releasing here
1166
+ // would decrement capacity owned by another spawn.
1167
+ if (updated && isSpawnInvocation(updated.actionName) && updated.dispatchedNodeId && existing.spawnReservedAt) {
869
1168
  await releaseNodeCapacity(db, workspaceId, updated.dispatchedNodeId);
870
1169
  }
871
1170
  if (updated) {