@relaycast/engine 8.2.0 → 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.
- package/dist/adapters/node/realtime.d.ts +6 -2
- package/dist/adapters/node/realtime.d.ts.map +1 -1
- package/dist/adapters/node/realtime.js +54 -9
- package/dist/adapters/node/realtime.js.map +1 -1
- package/dist/db/migrations/0042_action_invocation_handler_snapshot.sql +6 -0
- package/dist/db/migrations/0042_d1_read_path_indexes.sql +13 -0
- package/dist/db/schema.d.ts +38 -0
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +11 -0
- package/dist/db/schema.js.map +1 -1
- package/dist/engine/action.d.ts +40 -17
- package/dist/engine/action.d.ts.map +1 -1
- package/dist/engine/action.js +347 -20
- package/dist/engine/action.js.map +1 -1
- package/dist/engine/agent.d.ts.map +1 -1
- package/dist/engine/agent.js +12 -13
- package/dist/engine/agent.js.map +1 -1
- package/dist/engine/delivery.d.ts.map +1 -1
- package/dist/engine/delivery.js +4 -1
- package/dist/engine/delivery.js.map +1 -1
- package/dist/engine/node.d.ts.map +1 -1
- package/dist/engine/node.js +59 -8
- package/dist/engine/node.js.map +1 -1
- package/dist/lib/httpQuery.d.ts +3 -3
- package/dist/lib/httpQuery.d.ts.map +1 -1
- package/dist/node-invocations.d.ts +1 -1
- package/dist/node-invocations.d.ts.map +1 -1
- package/dist/node-invocations.js +1 -1
- package/dist/node-invocations.js.map +1 -1
- package/dist/ports/realtime.d.ts +33 -3
- package/dist/ports/realtime.d.ts.map +1 -1
- package/dist/ports/realtime.js.map +1 -1
- package/dist/routes/action.d.ts.map +1 -1
- package/dist/routes/action.js +26 -16
- package/dist/routes/action.js.map +1 -1
- package/package.json +3 -3
package/dist/engine/action.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
export async function drainNodeInvocations(db, registry, workspaceId, nodeId) {
|
|
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,
|
|
@@ -1220,7 +1477,9 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId) {
|
|
|
1220
1477
|
.from(actionInvocations)
|
|
1221
1478
|
.leftJoin(actions, eq(actionInvocations.actionId, actions.id))
|
|
1222
1479
|
.leftJoin(agents, eq(actions.handlerAgentId, agents.id))
|
|
1223
|
-
.where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.status, 'pending'),
|
|
1480
|
+
.where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.status, 'pending'), options.includeDeferred
|
|
1481
|
+
? undefined
|
|
1482
|
+
: or(isNull(actionInvocations.retryAfterAt), lte(actionInvocations.retryAfterAt, now)), or(eq(actionInvocations.dispatchedNodeId, nodeId), eq(actions.handlerNodeId, nodeId), and(eq(agents.locationType, 'via_node'), eq(agents.locationNodeId, nodeId)))))
|
|
1224
1483
|
.orderBy(asc(actionInvocations.createdAt));
|
|
1225
1484
|
let drained = 0;
|
|
1226
1485
|
for (const row of rows) {
|
|
@@ -1291,6 +1550,7 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId) {
|
|
|
1291
1550
|
action: dispatchActionNameForInvocation(row.actionName, input),
|
|
1292
1551
|
input,
|
|
1293
1552
|
agent: targetAgent ? { id: targetAgent.id, name: targetAgent.name } : null,
|
|
1553
|
+
actionId: targetAgent ? row.actionId ?? undefined : undefined,
|
|
1294
1554
|
retryAfterAt: new Date(Date.now() + ACTION_DISPATCH_TIMEOUT_MS),
|
|
1295
1555
|
reservationHeld,
|
|
1296
1556
|
skipIncrementAttempts: row.dispatchedNodeId === nodeId,
|
|
@@ -1345,6 +1605,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
|
|
|
1345
1605
|
action: invocation.actionName,
|
|
1346
1606
|
input: recordInput(invocation.input),
|
|
1347
1607
|
agent: { id: targetAgent.agentId, name: targetAgent.agentName },
|
|
1608
|
+
actionId: targetAgent.actionId,
|
|
1348
1609
|
retryAfterAt: opts.retryAfterAt ?? null,
|
|
1349
1610
|
});
|
|
1350
1611
|
return dispatched.accepted;
|
|
@@ -1639,10 +1900,76 @@ async function failUnreachableAgentInvocations(db, registry, ttlMs, completionDe
|
|
|
1639
1900
|
}
|
|
1640
1901
|
}
|
|
1641
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
|
+
}
|
|
1642
1968
|
export async function sweepTimedOutInvocations(db, registry, opts = {}) {
|
|
1643
1969
|
const sweepOpts = typeof opts === 'number' || opts === null ? {} : opts;
|
|
1644
1970
|
const timeoutMs = typeof opts === 'number' ? opts : sweepOpts.timeoutMs ?? ACTION_DISPATCH_TIMEOUT_MS;
|
|
1645
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);
|
|
1646
1973
|
const now = new Date();
|
|
1647
1974
|
const cutoff = new Date(Date.now() - timeoutMs);
|
|
1648
1975
|
const rows = await db
|