@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.
- package/dist/adapters/node/database.d.ts.map +1 -1
- package/dist/adapters/node/database.js +204 -27
- package/dist/adapters/node/database.js.map +1 -1
- package/dist/adapters/node/realtime.d.ts +29 -1
- package/dist/adapters/node/realtime.d.ts.map +1 -1
- package/dist/adapters/node/realtime.js +241 -50
- package/dist/adapters/node/realtime.js.map +1 -1
- package/dist/auth/tokenKind.d.ts +1 -1
- package/dist/auth/tokenKind.d.ts.map +1 -1
- package/dist/auth/tokenKind.js +4 -1
- package/dist/auth/tokenKind.js.map +1 -1
- package/dist/db/migrations/0028_node_providers.sql +55 -0
- package/dist/db/schema.d.ts +376 -0
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +59 -1
- package/dist/db/schema.js.map +1 -1
- package/dist/engine/action.d.ts +47 -1
- package/dist/engine/action.d.ts.map +1 -1
- package/dist/engine/action.js +342 -43
- package/dist/engine/action.js.map +1 -1
- package/dist/engine/delivery.d.ts.map +1 -1
- package/dist/engine/delivery.js +2 -1
- package/dist/engine/delivery.js.map +1 -1
- package/dist/engine/node.d.ts +71 -28
- package/dist/engine/node.d.ts.map +1 -1
- package/dist/engine/node.js +357 -189
- package/dist/engine/node.js.map +1 -1
- package/dist/engine/nodeContext.d.ts.map +1 -1
- package/dist/engine/nodeContext.js +17 -6
- package/dist/engine/nodeContext.js.map +1 -1
- package/dist/engine/nodeDeliver.d.ts.map +1 -1
- package/dist/engine/nodeDeliver.js +4 -1
- package/dist/engine/nodeDeliver.js.map +1 -1
- package/dist/engine/nodeLock.d.ts +2 -0
- package/dist/engine/nodeLock.d.ts.map +1 -0
- package/dist/engine/nodeLock.js +31 -0
- package/dist/engine/nodeLock.js.map +1 -0
- package/dist/engine/nodeProvider.d.ts +62 -0
- package/dist/engine/nodeProvider.d.ts.map +1 -0
- package/dist/engine/nodeProvider.js +256 -0
- package/dist/engine/nodeProvider.js.map +1 -0
- package/dist/engine/placement.d.ts +10 -0
- package/dist/engine/placement.d.ts.map +1 -1
- package/dist/engine/placement.js +10 -0
- package/dist/engine/placement.js.map +1 -1
- package/dist/engine/trigger.d.ts.map +1 -1
- package/dist/engine/trigger.js +17 -3
- package/dist/engine/trigger.js.map +1 -1
- package/dist/middleware/auth.d.ts +6 -0
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +6 -0
- package/dist/middleware/auth.js.map +1 -1
- package/dist/node-control.d.ts +1 -1
- package/dist/node-control.d.ts.map +1 -1
- package/dist/node-control.js +11 -1
- package/dist/node-control.js.map +1 -1
- package/dist/ports/auth.d.ts +1 -1
- package/dist/ports/auth.d.ts.map +1 -1
- package/dist/ports/realtime.d.ts +30 -2
- package/dist/ports/realtime.d.ts.map +1 -1
- package/dist/routes/deliveryRouting.d.ts.map +1 -1
- package/dist/routes/deliveryRouting.js +6 -1
- package/dist/routes/deliveryRouting.js.map +1 -1
- package/dist/routes/message.d.ts.map +1 -1
- package/dist/routes/message.js +44 -14
- package/dist/routes/message.js.map +1 -1
- package/dist/routes/node.d.ts.map +1 -1
- package/dist/routes/node.js +92 -3
- package/dist/routes/node.js.map +1 -1
- package/package.json +3 -3
package/dist/engine/action.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
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:
|
|
483
|
+
action_name: actionName,
|
|
300
484
|
handler_agent_id: null,
|
|
301
|
-
handler_node_id:
|
|
302
|
-
dispatched_node_id: dispatched.accepted ?
|
|
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
|
|
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
|
|
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
|
-
|
|
623
|
-
|
|
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
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|