@relaycast/engine 12.0.0 → 12.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/node/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +1 -0
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/presence.d.ts +18 -0
- package/dist/adapters/node/presence.d.ts.map +1 -1
- package/dist/adapters/node/presence.js +82 -0
- package/dist/adapters/node/presence.js.map +1 -1
- package/dist/adapters/node/realtime.d.ts +4 -0
- package/dist/adapters/node/realtime.d.ts.map +1 -1
- package/dist/adapters/node/realtime.js +32 -5
- package/dist/adapters/node/realtime.js.map +1 -1
- package/dist/db/schema.d.ts +2 -2
- package/dist/engine/a2a.d.ts +3 -0
- package/dist/engine/a2a.d.ts.map +1 -1
- package/dist/engine/a2a.js +1 -0
- package/dist/engine/a2a.js.map +1 -1
- package/dist/engine/action.d.ts +14 -9
- package/dist/engine/action.d.ts.map +1 -1
- package/dist/engine/action.js +342 -80
- package/dist/engine/action.js.map +1 -1
- package/dist/engine/agent.d.ts +9 -3
- package/dist/engine/agent.d.ts.map +1 -1
- package/dist/engine/agent.js +58 -12
- package/dist/engine/agent.js.map +1 -1
- package/dist/engine/delivery.d.ts.map +1 -1
- package/dist/engine/delivery.js +9 -0
- package/dist/engine/delivery.js.map +1 -1
- package/dist/engine/deliveryWrites.d.ts +23 -0
- package/dist/engine/deliveryWrites.d.ts.map +1 -1
- package/dist/engine/deliveryWrites.js +50 -8
- package/dist/engine/deliveryWrites.js.map +1 -1
- package/dist/engine/eventDelivery.d.ts +1 -1
- package/dist/engine/groupDm.d.ts.map +1 -1
- package/dist/engine/groupDm.js +4 -0
- package/dist/engine/groupDm.js.map +1 -1
- package/dist/engine/inboundWebhook.d.ts.map +1 -1
- package/dist/engine/inboundWebhook.js +20 -4
- package/dist/engine/inboundWebhook.js.map +1 -1
- package/dist/engine/inbox.d.ts.map +1 -1
- package/dist/engine/inbox.js +17 -0
- package/dist/engine/inbox.js.map +1 -1
- package/dist/engine/invocationClaim.d.ts +32 -0
- package/dist/engine/invocationClaim.d.ts.map +1 -0
- package/dist/engine/invocationClaim.js +42 -0
- package/dist/engine/invocationClaim.js.map +1 -0
- package/dist/engine/message.d.ts.map +1 -1
- package/dist/engine/message.js +4 -0
- package/dist/engine/message.js.map +1 -1
- package/dist/engine/messageMetadata.d.ts +2 -0
- package/dist/engine/messageMetadata.d.ts.map +1 -1
- package/dist/engine/messageMetadata.js +3 -2
- package/dist/engine/messageMetadata.js.map +1 -1
- package/dist/engine/node.d.ts +59 -44
- package/dist/engine/node.d.ts.map +1 -1
- package/dist/engine/node.js +179 -26
- package/dist/engine/node.js.map +1 -1
- package/dist/engine/nodeLiveness.d.ts +34 -0
- package/dist/engine/nodeLiveness.d.ts.map +1 -0
- package/dist/engine/nodeLiveness.js +52 -0
- package/dist/engine/nodeLiveness.js.map +1 -0
- package/dist/engine/placement.d.ts.map +1 -1
- package/dist/engine/placement.js +15 -18
- package/dist/engine/placement.js.map +1 -1
- package/dist/engine/presence.d.ts +173 -1
- package/dist/engine/presence.d.ts.map +1 -1
- package/dist/engine/presence.js +112 -8
- package/dist/engine/presence.js.map +1 -1
- package/dist/engine/registrationAdmission.d.ts +40 -0
- package/dist/engine/registrationAdmission.d.ts.map +1 -0
- package/dist/engine/registrationAdmission.js +78 -0
- package/dist/engine/registrationAdmission.js.map +1 -0
- package/dist/engine/taskInvocation.d.ts +1 -1
- package/dist/engine/thread.d.ts.map +1 -1
- package/dist/engine/thread.js +8 -0
- package/dist/engine/thread.js.map +1 -1
- package/dist/engine/workspace.d.ts +26 -1
- package/dist/engine/workspace.d.ts.map +1 -1
- package/dist/engine/workspace.js +108 -7
- package/dist/engine/workspace.js.map +1 -1
- package/dist/engine/workspaceDrain.d.ts +38 -0
- package/dist/engine/workspaceDrain.d.ts.map +1 -0
- package/dist/engine/workspaceDrain.js +202 -0
- package/dist/engine/workspaceDrain.js.map +1 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +31 -0
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/engineDiagnostics.d.ts +26 -0
- package/dist/lib/engineDiagnostics.d.ts.map +1 -0
- package/dist/lib/engineDiagnostics.js +100 -0
- package/dist/lib/engineDiagnostics.js.map +1 -0
- package/dist/lib/httpError.d.ts.map +1 -1
- package/dist/lib/httpError.js +5 -1
- package/dist/lib/httpError.js.map +1 -1
- package/dist/middleware/admission.d.ts +7 -0
- package/dist/middleware/admission.d.ts.map +1 -1
- package/dist/middleware/admission.js +19 -1
- package/dist/middleware/admission.js.map +1 -1
- package/dist/middleware/rateLimit.d.ts.map +1 -1
- package/dist/middleware/rateLimit.js +8 -1
- package/dist/middleware/rateLimit.js.map +1 -1
- package/dist/ports/admission.d.ts +24 -3
- package/dist/ports/admission.d.ts.map +1 -1
- package/dist/ports/database.d.ts.map +1 -1
- package/dist/ports/database.js +6 -1
- package/dist/ports/database.js.map +1 -1
- package/dist/ports/diagnostics.d.ts +42 -0
- package/dist/ports/diagnostics.d.ts.map +1 -0
- package/dist/ports/diagnostics.js +13 -0
- package/dist/ports/diagnostics.js.map +1 -0
- package/dist/ports/index.d.ts +13 -1
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/index.js.map +1 -1
- package/dist/ports/nodeLiveness.d.ts +23 -0
- package/dist/ports/nodeLiveness.d.ts.map +1 -0
- package/dist/ports/nodeLiveness.js +12 -0
- package/dist/ports/nodeLiveness.js.map +1 -0
- package/dist/ports/presence.d.ts +21 -0
- package/dist/ports/presence.d.ts.map +1 -1
- package/dist/ports/realtime.d.ts +18 -0
- package/dist/ports/realtime.d.ts.map +1 -1
- package/dist/ports/realtime.js.map +1 -1
- package/dist/routes/a2a.d.ts.map +1 -1
- package/dist/routes/a2a.js +2 -0
- package/dist/routes/a2a.js.map +1 -1
- package/dist/routes/action.js +3 -0
- package/dist/routes/action.js.map +1 -1
- package/dist/routes/agent.d.ts.map +1 -1
- package/dist/routes/agent.js +5 -1
- package/dist/routes/agent.js.map +1 -1
- package/dist/routes/node.d.ts.map +1 -1
- package/dist/routes/node.js +2 -1
- package/dist/routes/node.js.map +1 -1
- package/dist/routes/workspace.js +10 -2
- package/dist/routes/workspace.js.map +1 -1
- package/package.json +3 -3
package/dist/engine/action.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { nodeLive, nodeReady, providerLive } from './nodeLiveness.js';
|
|
1
2
|
import { and, asc, eq, inArray, isNotNull, isNull, like, lt, lte, or, sql } from 'drizzle-orm';
|
|
2
3
|
import { actions, actionInvocations, agents, agentNodeBindings, channelMembers, dmParticipants, nodes } from '../db/schema.js';
|
|
3
4
|
import { waitForPendingInvocationRetry } from './invocationRetry.js';
|
|
@@ -9,10 +10,12 @@ import { D1_SAFE_IN_QUERY_CHUNK_SIZE } from '../lib/queryChunks.js';
|
|
|
9
10
|
import { toFleetWireJson } from './deliveryWire.js';
|
|
10
11
|
import { emitAgentExitedEffects, emitInvocationCompletionEffects, fleetInvocationId, } from './invocationCompletion.js';
|
|
11
12
|
import { runAtomic, runAtomicWrites } from '../ports/database.js';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
13
|
+
import { exclusiveClaimWrite, retryAfterAtMatches } from './invocationClaim.js';
|
|
14
|
+
import { claimSpawnNode, chooseNodeForAction, releaseNodeCapacity, reserveNodeCapacity } from './placement.js';
|
|
15
|
+
import { DEFAULT_PROVIDER_NAME, capacityProviderName, getProvider } from './nodeProvider.js';
|
|
14
16
|
import { AGENT_TOKEN_HASH_PATTERN } from '@relaycast/types';
|
|
15
17
|
import { createTaskState, expireTaskInvocation, expireTaskInvocations, taskExecution } from './taskInvocation.js';
|
|
18
|
+
import { withEngineOperation } from '../lib/engineDiagnostics.js';
|
|
16
19
|
const OPEN_INVOCATION_STATUSES = ['pending', 'dispatched', 'invoked'];
|
|
17
20
|
const REPLAYED_INVOCATION = Symbol('replayed-action-invocation');
|
|
18
21
|
export const ACTION_DISPATCH_TIMEOUT_MS = 30_000;
|
|
@@ -40,14 +43,13 @@ export const ACTION_HANDLER_UNREACHABLE_TTL_MS = 120_000;
|
|
|
40
43
|
export const PENDING_INVOCATION_MAX_AGE_MS = 72 * 60 * 60 * 1000;
|
|
41
44
|
/**
|
|
42
45
|
* Sleep between the never-dispatched sweep's candidate SELECT and its atomic
|
|
43
|
-
* UPDATE.
|
|
44
|
-
* `
|
|
45
|
-
*
|
|
46
|
-
* while the handler already has the frame.
|
|
47
|
-
* `dispatch_attempts = 0`, so once the
|
|
48
|
-
*
|
|
49
|
-
* gap
|
|
50
|
-
* still bounding sweep latency. Set to 0 in tests to keep them fast.
|
|
46
|
+
* UPDATE. Builtin spawn and agent-hosted sends claim before the frame, so
|
|
47
|
+
* `dispatch_attempts` is already set. Release and other unclaimed sends still
|
|
48
|
+
* record the attempt after the frame. A sweep landing in that gap could mark
|
|
49
|
+
* the row `never_dispatched_expired` while the handler already has the frame.
|
|
50
|
+
* The atomic UPDATE re-checks `dispatch_attempts = 0`, so once the attempt is
|
|
51
|
+
* recorded the row is excluded. This grace window (default 5s) covers that
|
|
52
|
+
* record gap. Set to 0 in tests to keep them fast.
|
|
51
53
|
*/
|
|
52
54
|
export const NEVER_DISPATCHED_SWEEP_GRACE_MS = 5_000;
|
|
53
55
|
const ACTION_RETRY_BACKOFF_MS = 5_000;
|
|
@@ -130,11 +132,10 @@ function nextRetryAfter(attempts) {
|
|
|
130
132
|
return new Date(Date.now() + backoff);
|
|
131
133
|
}
|
|
132
134
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* `dispatchedAt` — and the reschedule path cover drained invocations too.
|
|
135
|
+
* Live `dispatched` fields (`dispatchedAt`, `retryAfterAt`). The timeout sweep
|
|
136
|
+
* keys off `dispatchedAt`. A builtin spawn or agent-hosted claim writes them
|
|
137
|
+
* before the frame; offline-queue drain writes them when a queued frame is
|
|
138
|
+
* delivered.
|
|
138
139
|
*/
|
|
139
140
|
function dispatchedStateFields(opts = {}) {
|
|
140
141
|
return {
|
|
@@ -608,14 +609,14 @@ async function isHandlerConnectionLive(db, registry, workspaceId, nodeId, provid
|
|
|
608
609
|
if (!provider)
|
|
609
610
|
return true;
|
|
610
611
|
const explicitlyAttached = registry.isProviderAttached?.(workspaceId, nodeId, providerName) ?? true;
|
|
611
|
-
return !explicitlyAttached ||
|
|
612
|
+
return !explicitlyAttached || await providerLive(db, workspaceId, nodeId, provider);
|
|
612
613
|
}
|
|
613
614
|
/** Is a node provider currently invokable — connection up AND heartbeat handlers_live. */
|
|
614
615
|
async function isNodeProviderLive(db, registry, workspaceId, nodeId, providerName) {
|
|
615
616
|
if (!registry.isProviderConnected(workspaceId, nodeId, providerName))
|
|
616
617
|
return false;
|
|
617
618
|
const provider = await getProvider(db, workspaceId, nodeId, providerName);
|
|
618
|
-
return !!provider &&
|
|
619
|
+
return !!provider && await providerLive(db, workspaceId, nodeId, provider);
|
|
619
620
|
}
|
|
620
621
|
async function failInvocationForUnavailableProvider(db, workspaceId, invocationId, expectedNodeLivenessRevision) {
|
|
621
622
|
await db
|
|
@@ -763,7 +764,33 @@ async function dispatchNodeProviderInvocation(args, options = {}) {
|
|
|
763
764
|
}
|
|
764
765
|
return { accepted, pending: true };
|
|
765
766
|
}
|
|
766
|
-
|
|
767
|
+
// A retry passes the generation it selected. Re-reading here would adopt a
|
|
768
|
+
// claim that won while this retry was placing, and both would send. Callers
|
|
769
|
+
// without a snapshot still read the row so a dispatched spawn does not fall
|
|
770
|
+
// back to the fresh-row defaults.
|
|
771
|
+
const suppliedClaim = args.claimStatus
|
|
772
|
+
? {
|
|
773
|
+
claimStatus: args.claimStatus,
|
|
774
|
+
observedRetryAfterAt: args.observedRetryAfterAt ?? null,
|
|
775
|
+
observedDispatchAttempts: args.observedDispatchAttempts ?? 0,
|
|
776
|
+
observedDispatchedNodeId: args.observedDispatchedNodeId ?? null,
|
|
777
|
+
}
|
|
778
|
+
: null;
|
|
779
|
+
const [claimRow] = suppliedClaim
|
|
780
|
+
? []
|
|
781
|
+
: await args.db
|
|
782
|
+
.select({
|
|
783
|
+
status: actionInvocations.status,
|
|
784
|
+
retryAfterAt: actionInvocations.retryAfterAt,
|
|
785
|
+
dispatchAttempts: actionInvocations.dispatchAttempts,
|
|
786
|
+
dispatchedNodeId: actionInvocations.dispatchedNodeId,
|
|
787
|
+
})
|
|
788
|
+
.from(actionInvocations)
|
|
789
|
+
.where(and(eq(actionInvocations.workspaceId, args.workspaceId), eq(actionInvocations.id, args.invocationId)));
|
|
790
|
+
return dispatchNodeInvocation({
|
|
791
|
+
...args,
|
|
792
|
+
...(suppliedClaim ?? (claimRow ? observedInvocationClaim(claimRow) : {})),
|
|
793
|
+
});
|
|
767
794
|
}
|
|
768
795
|
async function dispatchRelease(args) {
|
|
769
796
|
const input = recordInput(args.data.input);
|
|
@@ -1064,7 +1091,7 @@ async function dispatchRelease(args) {
|
|
|
1064
1091
|
.select({ status: nodes.status, lastHeartbeatAt: nodes.lastHeartbeatAt })
|
|
1065
1092
|
.from(nodes)
|
|
1066
1093
|
.where(and(eq(nodes.workspaceId, args.workspaceId), eq(nodes.id, nodeId)));
|
|
1067
|
-
if (!directNode || !
|
|
1094
|
+
if (!directNode || !(await nodeLive(args.db, { ...directNode, workspaceId: args.workspaceId, id: nodeId })))
|
|
1068
1095
|
hostLive = false;
|
|
1069
1096
|
}
|
|
1070
1097
|
if (!hostLive || !registry) {
|
|
@@ -1268,11 +1295,12 @@ async function dispatchSpawn(args) {
|
|
|
1268
1295
|
// Placement reserved capacity for an attempt that never landed. Return it
|
|
1269
1296
|
// first, then mark the row unsent: only the mark makes it drainable, so a
|
|
1270
1297
|
// drain never sees it while this send is in flight or still holding the
|
|
1271
|
-
// reservation.
|
|
1272
|
-
|
|
1298
|
+
// reservation. A thrown send has an unknown outcome, so it is not marked
|
|
1299
|
+
// and only the never-dispatched sweep ends it.
|
|
1300
|
+
if (!dispatched.accepted && !placement.queued && !dispatched.reservationStillHeld) {
|
|
1273
1301
|
await releaseNodeCapacity(args.db, args.workspaceId, nodeId);
|
|
1274
1302
|
}
|
|
1275
|
-
if (!dispatched.sent) {
|
|
1303
|
+
if (!dispatched.sent && !dispatched.sendOutcomeUnknown) {
|
|
1276
1304
|
await markUnsentNativeSpawn(args.db, args.workspaceId, invocation.id, nodeId);
|
|
1277
1305
|
}
|
|
1278
1306
|
return spawnResult(invocation, nodeId, dispatched);
|
|
@@ -1399,7 +1427,10 @@ export async function invokeAction(db, workspaceId, actionName, data, options =
|
|
|
1399
1427
|
invocationId,
|
|
1400
1428
|
});
|
|
1401
1429
|
}
|
|
1402
|
-
|
|
1430
|
+
// Destructive roster mutation. The input shape is not the guard: a payload
|
|
1431
|
+
// that already carries `name` and `delete_agent` still does not release
|
|
1432
|
+
// unless this caller set `authorizeBuiltinRelease`.
|
|
1433
|
+
if (!action && actionName === 'release' && options.authorizeBuiltinRelease) {
|
|
1403
1434
|
return dispatchRelease({
|
|
1404
1435
|
db,
|
|
1405
1436
|
registry: options.nodeConnections,
|
|
@@ -1557,6 +1588,21 @@ export async function invokeAction(db, workspaceId, actionName, data, options =
|
|
|
1557
1588
|
});
|
|
1558
1589
|
}
|
|
1559
1590
|
}
|
|
1591
|
+
// The agent CAS recorded dispatch before the frame. A handler can complete
|
|
1592
|
+
// that row before this response is built, and the ack returns that terminal row.
|
|
1593
|
+
if (dispatched.sent && dispatched.accepted) {
|
|
1594
|
+
const [current] = await db
|
|
1595
|
+
.select()
|
|
1596
|
+
.from(actionInvocations)
|
|
1597
|
+
.where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocation.id)));
|
|
1598
|
+
if (current && !OPEN_INVOCATION_STATUSES.some((status) => status === current.status)) {
|
|
1599
|
+
return invocationAck(current, {
|
|
1600
|
+
actionName,
|
|
1601
|
+
handlerAgentId: action.handlerAgentId,
|
|
1602
|
+
handlerNodeId: handlerAgent.locationNodeId,
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1560
1606
|
if (!dispatched.sent) {
|
|
1561
1607
|
if (invocationId !== undefined) {
|
|
1562
1608
|
// A keyed claim must stay consistent with its replays. Re-read it so a
|
|
@@ -1839,7 +1885,16 @@ async function dispatchNodeAttempt(db, workspaceId, invocationId, nodeId, opts)
|
|
|
1839
1885
|
...(opts.targetActionId ? { actionId: opts.targetActionId } : {}),
|
|
1840
1886
|
...attemptFields,
|
|
1841
1887
|
})
|
|
1842
|
-
.where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId),
|
|
1888
|
+
.where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), ...(opts.claim
|
|
1889
|
+
? [
|
|
1890
|
+
eq(actionInvocations.status, opts.claim.status),
|
|
1891
|
+
retryAfterAtMatches(opts.claim.retryAfterAt),
|
|
1892
|
+
eq(actionInvocations.dispatchAttempts, opts.claim.dispatchAttempts),
|
|
1893
|
+
...(opts.claim.spawnReservedAt
|
|
1894
|
+
? [eq(actionInvocations.spawnReservedAt, opts.claim.spawnReservedAt)]
|
|
1895
|
+
: []),
|
|
1896
|
+
]
|
|
1897
|
+
: [inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)]), ...(opts.expectedNodeLivenessRevision !== undefined
|
|
1843
1898
|
? [eq(actionInvocations.nodeLivenessRevision, opts.expectedNodeLivenessRevision)]
|
|
1844
1899
|
: []), ...(opts.expectedAction ? [
|
|
1845
1900
|
eq(actionInvocations.invocationOrigin, 'registered_action'),
|
|
@@ -1863,6 +1918,25 @@ async function dispatchNodeAttempt(db, workspaceId, invocationId, nodeId, opts)
|
|
|
1863
1918
|
.returning();
|
|
1864
1919
|
return !!updated;
|
|
1865
1920
|
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Drop a spawn claim whose frame was not accepted. The compare matches the
|
|
1923
|
+
* attempt count and deadline this claim wrote, so a failed older send cannot
|
|
1924
|
+
* reset a newer winner. Restore the observed attempt count, route, deadline,
|
|
1925
|
+
* and reservation token so the pending row is due for another sender.
|
|
1926
|
+
*/
|
|
1927
|
+
async function restoreUnsentSpawnClaim(db, workspaceId, invocationId, claim) {
|
|
1928
|
+
await db
|
|
1929
|
+
.update(actionInvocations)
|
|
1930
|
+
.set({
|
|
1931
|
+
status: 'pending',
|
|
1932
|
+
dispatchedAt: null,
|
|
1933
|
+
dispatchedNodeId: claim.restoreDispatchedNodeId,
|
|
1934
|
+
spawnReservedAt: claim.restoreSpawnReservedAt,
|
|
1935
|
+
retryAfterAt: claim.restoreRetryAfterAt,
|
|
1936
|
+
dispatchAttempts: claim.restoreDispatchAttempts,
|
|
1937
|
+
})
|
|
1938
|
+
.where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, ['pending', 'dispatched']), eq(actionInvocations.dispatchAttempts, claim.dispatchAttempts), retryAfterAtMatches(claim.retryAfterAt)));
|
|
1939
|
+
}
|
|
1866
1940
|
function registeredActionDispatchPredicate(args) {
|
|
1867
1941
|
const handlerPredicate = args.handlerAgentId
|
|
1868
1942
|
? sql `AND ${actions.handlerAgentId} = ${args.handlerAgentId} AND ${actions.handlerNodeId} IS NULL`
|
|
@@ -2068,6 +2142,17 @@ async function dispatchNodeInvocation(args) {
|
|
|
2068
2142
|
return { accepted: false, pending: false, sent: false };
|
|
2069
2143
|
const connectedBefore = args.registry.isProviderConnected(args.workspaceId, args.nodeId, args.providerName);
|
|
2070
2144
|
const pending = !!args.pending || !connectedBefore;
|
|
2145
|
+
const claimStatus = args.claimStatus ?? 'pending';
|
|
2146
|
+
const observedRetryAfterAt = args.observedRetryAfterAt ?? null;
|
|
2147
|
+
const observedDispatchAttempts = args.observedDispatchAttempts ?? 0;
|
|
2148
|
+
const claimWrite = exclusiveClaimWrite({
|
|
2149
|
+
observedStatus: claimStatus,
|
|
2150
|
+
observedRetryAfterAt,
|
|
2151
|
+
observedDispatchAttempts,
|
|
2152
|
+
nextStatus: pending ? 'pending' : 'dispatched',
|
|
2153
|
+
nextRetryAfterAt: args.retryAfterAt ?? null,
|
|
2154
|
+
incrementAttempts: !args.skipIncrementAttempts,
|
|
2155
|
+
});
|
|
2071
2156
|
const expectedActionId = args.invocationOrigin === 'registered_action'
|
|
2072
2157
|
? (args.expectedActionId ?? args.actionId)
|
|
2073
2158
|
: null;
|
|
@@ -2097,38 +2182,127 @@ async function dispatchNodeInvocation(args) {
|
|
|
2097
2182
|
if (registeredNodeClaim?.taskState) {
|
|
2098
2183
|
frame.task_execution = taskExecution({ id: args.invocationId, ...registeredNodeClaim });
|
|
2099
2184
|
}
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2185
|
+
// Native spawn has no registered handoff. Claim before the frame so a
|
|
2186
|
+
// concurrent drain and sweep cannot both send. The claim matches the
|
|
2187
|
+
// observed status, so a dispatched timeout retry can win, and it changes
|
|
2188
|
+
// the attempt count or the deadline so a second scheduler loses. Success
|
|
2189
|
+
// does not update the row again.
|
|
2190
|
+
const nativeSpawnSend = isSpawnInvocation(args.action) && !args.agent && !registeredNodeClaim;
|
|
2191
|
+
let nativeSpawnClaimed = false;
|
|
2192
|
+
if (nativeSpawnSend) {
|
|
2193
|
+
try {
|
|
2194
|
+
nativeSpawnClaimed = await dispatchNodeAttempt(args.db, args.workspaceId, args.invocationId, args.nodeId, {
|
|
2195
|
+
pending,
|
|
2196
|
+
providerName: args.providerName,
|
|
2197
|
+
retryAfterAt: claimWrite.retryAfterAt,
|
|
2198
|
+
reservationHeld: args.reservationHeld,
|
|
2199
|
+
skipIncrementAttempts: args.skipIncrementAttempts,
|
|
2200
|
+
expectedNodeLivenessRevision: args.expectedNodeLivenessRevision,
|
|
2201
|
+
claim: {
|
|
2202
|
+
status: claimStatus,
|
|
2203
|
+
retryAfterAt: observedRetryAfterAt,
|
|
2204
|
+
dispatchAttempts: observedDispatchAttempts,
|
|
2205
|
+
...(args.preserveSpawnReservation && args.observedSpawnReservedAt
|
|
2206
|
+
? { spawnReservedAt: args.observedSpawnReservedAt }
|
|
2207
|
+
: {}),
|
|
2208
|
+
},
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
catch {
|
|
2212
|
+
// The claim write did not commit: no frame left, and the row has no
|
|
2213
|
+
// reservation marker for a later sweep to release. A false result lets
|
|
2214
|
+
// the caller return the slot it already reserved and leave the
|
|
2215
|
+
// invocation pending for another sender.
|
|
2216
|
+
return { accepted: false, pending: false, sent: false };
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
if (nativeSpawnSend && !nativeSpawnClaimed) {
|
|
2220
|
+
return { accepted: false, pending: false, sent: false };
|
|
2221
|
+
}
|
|
2222
|
+
const restoreSpawnClaim = () => restoreUnsentSpawnClaim(args.db, args.workspaceId, args.invocationId, {
|
|
2223
|
+
dispatchAttempts: claimWrite.dispatchAttempts,
|
|
2224
|
+
retryAfterAt: claimWrite.retryAfterAt,
|
|
2225
|
+
restoreRetryAfterAt: observedRetryAfterAt,
|
|
2226
|
+
restoreDispatchAttempts: observedDispatchAttempts,
|
|
2227
|
+
restoreDispatchedNodeId: args.observedDispatchedNodeId ?? null,
|
|
2228
|
+
restoreSpawnReservedAt: args.preserveSpawnReservation ? args.observedSpawnReservedAt ?? null : null,
|
|
2229
|
+
});
|
|
2230
|
+
// A failed restore leaves the marker set. Returning the slot as well lets
|
|
2231
|
+
// the timeout sweep release it a second time.
|
|
2232
|
+
const restoreSpawnClaimOrHold = async () => {
|
|
2233
|
+
try {
|
|
2234
|
+
await restoreSpawnClaim();
|
|
2235
|
+
return false;
|
|
2236
|
+
}
|
|
2237
|
+
catch {
|
|
2238
|
+
return true;
|
|
2239
|
+
}
|
|
2240
|
+
};
|
|
2241
|
+
let sent = false;
|
|
2242
|
+
try {
|
|
2243
|
+
sent = args.agent && args.actionId
|
|
2112
2244
|
? await (args.registry.sendAuthorizedActionToProvider?.(args.workspaceId, args.nodeId, args.providerName, frame, {
|
|
2113
|
-
kind: '
|
|
2245
|
+
kind: 'agent-action-v1',
|
|
2114
2246
|
invocationId: args.invocationId,
|
|
2115
|
-
actionId:
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2247
|
+
actionId: args.actionId,
|
|
2248
|
+
handlerAgentId: args.agent.id,
|
|
2249
|
+
recordAttempt: !args.skipIncrementAttempts,
|
|
2250
|
+
pending,
|
|
2251
|
+
retryAfterAt: claimWrite.retryAfterAt,
|
|
2252
|
+
claimStatus,
|
|
2253
|
+
observedRetryAfterAt,
|
|
2254
|
+
observedDispatchAttempts,
|
|
2255
|
+
reservationHeld: !!args.reservationHeld,
|
|
2256
|
+
...(args.expectedNodeLivenessRevision !== undefined
|
|
2257
|
+
? { expectedNodeLivenessRevision: args.expectedNodeLivenessRevision }
|
|
2258
|
+
: {}),
|
|
2119
2259
|
}) ?? false)
|
|
2120
|
-
:
|
|
2260
|
+
: registeredNodeClaim
|
|
2121
2261
|
? await (args.registry.sendAuthorizedActionToProvider?.(args.workspaceId, args.nodeId, args.providerName, frame, {
|
|
2122
|
-
kind: '
|
|
2262
|
+
kind: 'registered-node-action-v2',
|
|
2123
2263
|
invocationId: args.invocationId,
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
? { expectedTokenHash: guardedReleaseHash }
|
|
2129
|
-
: { expectedAgentId: guardedReleaseAgentId }),
|
|
2264
|
+
actionId: registeredNodeClaim.actionId,
|
|
2265
|
+
dispatchAttempt: registeredNodeClaim.dispatchAttempts,
|
|
2266
|
+
invocationActionName: registeredNodeClaim.actionName,
|
|
2267
|
+
actionName: args.action,
|
|
2130
2268
|
}) ?? false)
|
|
2131
|
-
:
|
|
2269
|
+
: guardedReleaseHash || guardedReleaseAgentId
|
|
2270
|
+
? await (args.registry.sendAuthorizedActionToProvider?.(args.workspaceId, args.nodeId, args.providerName, frame, {
|
|
2271
|
+
kind: 'release-generation-v1',
|
|
2272
|
+
invocationId: args.invocationId,
|
|
2273
|
+
agentName: typeof args.input.name === 'string' ? args.input.name : '',
|
|
2274
|
+
...(guardedReleaseHash && guardedReleaseAgentId
|
|
2275
|
+
? { expectedTokenHash: guardedReleaseHash, expectedAgentId: guardedReleaseAgentId }
|
|
2276
|
+
: guardedReleaseHash
|
|
2277
|
+
? { expectedTokenHash: guardedReleaseHash }
|
|
2278
|
+
: { expectedAgentId: guardedReleaseAgentId }),
|
|
2279
|
+
}) ?? false)
|
|
2280
|
+
: await args.registry.sendToProvider(args.workspaceId, args.nodeId, args.providerName, frame);
|
|
2281
|
+
}
|
|
2282
|
+
catch (error) {
|
|
2283
|
+
// A thrown send never reached the false-result path. Restore only this
|
|
2284
|
+
// claim, then report a false result so the caller releases the capacity
|
|
2285
|
+
// it reserved. The outcome is unknown, so callers must not queue another
|
|
2286
|
+
// send. Registered-action crashes still propagate: their claim has to stay
|
|
2287
|
+
// in place for the keyed replay.
|
|
2288
|
+
if (nativeSpawnClaimed) {
|
|
2289
|
+
const reservationStillHeld = await restoreSpawnClaimOrHold();
|
|
2290
|
+
return {
|
|
2291
|
+
accepted: false,
|
|
2292
|
+
pending: false,
|
|
2293
|
+
sent: false,
|
|
2294
|
+
sendOutcomeUnknown: true,
|
|
2295
|
+
...(reservationStillHeld ? { reservationStillHeld: true } : {}),
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
throw error;
|
|
2299
|
+
}
|
|
2300
|
+
if (!sent && nativeSpawnClaimed) {
|
|
2301
|
+
const reservationStillHeld = await restoreSpawnClaimOrHold();
|
|
2302
|
+
if (reservationStillHeld) {
|
|
2303
|
+
return { accepted: false, pending: false, sent: false, reservationStillHeld: true };
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2132
2306
|
if (!sent && (guardedReleaseHash || guardedReleaseAgentId)) {
|
|
2133
2307
|
// An adapter compiled against the older owner-authorization contract may
|
|
2134
2308
|
// expose the method but reject the new proof kind. Settle any still-open
|
|
@@ -2157,10 +2331,11 @@ async function dispatchNodeInvocation(args) {
|
|
|
2157
2331
|
}
|
|
2158
2332
|
return { accepted: false, pending: false, sent: false };
|
|
2159
2333
|
}
|
|
2160
|
-
// Registered node-action state was committed by the
|
|
2161
|
-
//
|
|
2334
|
+
// Registered node-action and native-spawn state was committed by the
|
|
2335
|
+
// pre-send claim. Agent-action state was committed by the socket owner's
|
|
2336
|
+
// authorization CAS, then re-read below in case the handler already finished.
|
|
2162
2337
|
// Do not write either generation again after the frame is accepted.
|
|
2163
|
-
if (registeredNodeClaim) {
|
|
2338
|
+
if (registeredNodeClaim || nativeSpawnClaimed) {
|
|
2164
2339
|
return { accepted: true, pending, sent: true };
|
|
2165
2340
|
}
|
|
2166
2341
|
if (args.agent && args.actionId) {
|
|
@@ -2313,6 +2488,7 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId, op
|
|
|
2313
2488
|
input: actionInvocations.input,
|
|
2314
2489
|
providerAcceptedAttempt: actionInvocations.providerAcceptedAttempt,
|
|
2315
2490
|
dispatchAttempts: actionInvocations.dispatchAttempts,
|
|
2491
|
+
retryAfterAt: actionInvocations.retryAfterAt,
|
|
2316
2492
|
spawnReservedAt: actionInvocations.spawnReservedAt,
|
|
2317
2493
|
dispatchedNodeId: actionInvocations.dispatchedNodeId,
|
|
2318
2494
|
dispatchedProvider: actionInvocations.dispatchedProvider,
|
|
@@ -2407,7 +2583,7 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId, op
|
|
|
2407
2583
|
})
|
|
2408
2584
|
.from(nodes)
|
|
2409
2585
|
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
|
|
2410
|
-
if (node &&
|
|
2586
|
+
if (node && await nodeReady(db, { ...node, workspaceId, id: nodeId })) {
|
|
2411
2587
|
await db
|
|
2412
2588
|
.update(actionInvocations)
|
|
2413
2589
|
.set({ retryAfterAt: new Date(Date.now() + NODE_DRAIN_REQUEUE_RETRY_MS) })
|
|
@@ -2434,15 +2610,23 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId, op
|
|
|
2434
2610
|
retryAfterAt: new Date(Date.now() + ACTION_DISPATCH_TIMEOUT_MS),
|
|
2435
2611
|
reservationHeld,
|
|
2436
2612
|
skipIncrementAttempts: row.dispatchedNodeId === nodeId,
|
|
2613
|
+
claimStatus: 'pending',
|
|
2614
|
+
observedRetryAfterAt: row.retryAfterAt,
|
|
2615
|
+
observedDispatchAttempts: row.dispatchAttempts,
|
|
2616
|
+
observedDispatchedNodeId: row.dispatchedNodeId,
|
|
2617
|
+
observedSpawnReservedAt: row.spawnReservedAt,
|
|
2618
|
+
preserveSpawnReservation: reservationHeld && !reservedForDrain,
|
|
2437
2619
|
});
|
|
2438
2620
|
if (dispatched.accepted) {
|
|
2439
2621
|
drained++;
|
|
2440
2622
|
}
|
|
2441
|
-
else if (reservedForDrain) {
|
|
2623
|
+
else if (reservedForDrain && !dispatched.reservationStillHeld) {
|
|
2442
2624
|
await releaseNodeCapacity(db, workspaceId, nodeId);
|
|
2443
2625
|
reservedForDrain = false;
|
|
2444
2626
|
}
|
|
2445
|
-
|
|
2627
|
+
// A thrown send may have delivered the frame. Leave an unsent spawn
|
|
2628
|
+
// unmarked so a later drain does not send it again.
|
|
2629
|
+
if (!dispatched.sent && !dispatched.sendOutcomeUnknown)
|
|
2446
2630
|
await remarkUnsent();
|
|
2447
2631
|
}
|
|
2448
2632
|
catch {
|
|
@@ -2459,6 +2643,16 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId, op
|
|
|
2459
2643
|
}
|
|
2460
2644
|
return drained;
|
|
2461
2645
|
}
|
|
2646
|
+
function observedInvocationClaim(invocation) {
|
|
2647
|
+
if (invocation.status !== 'pending' && invocation.status !== 'dispatched' && invocation.status !== 'invoked')
|
|
2648
|
+
return {};
|
|
2649
|
+
return {
|
|
2650
|
+
claimStatus: invocation.status,
|
|
2651
|
+
observedRetryAfterAt: invocation.retryAfterAt ?? null,
|
|
2652
|
+
observedDispatchAttempts: invocation.dispatchAttempts ?? 0,
|
|
2653
|
+
observedDispatchedNodeId: invocation.dispatchedNodeId ?? null,
|
|
2654
|
+
};
|
|
2655
|
+
}
|
|
2462
2656
|
export async function rescheduleNodeInvocation(db, registry, invocation, opts = {}) {
|
|
2463
2657
|
// Once the exact provider accepted this generation, pruning only clears its
|
|
2464
2658
|
// materialized action FK. It must not make timeout, disconnect, or inventory
|
|
@@ -2473,9 +2667,36 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
|
|
|
2473
2667
|
}
|
|
2474
2668
|
// Only release capacity the invocation actually holds. A shadowed spawn
|
|
2475
2669
|
// dropped its native reservation at dispatch (spawnReservedAt is null), so it
|
|
2476
|
-
// must not release capacity that now belongs to another spawn.
|
|
2670
|
+
// must not release capacity that now belongs to another spawn. Clear the
|
|
2671
|
+
// marker once so overlapping retries release that one hold, not every hold
|
|
2672
|
+
// on the node.
|
|
2477
2673
|
if (invocation.dispatchedNodeId && isSpawnInvocation(invocation.actionName) && invocation.spawnReservedAt) {
|
|
2478
|
-
|
|
2674
|
+
const heldAt = invocation.spawnReservedAt;
|
|
2675
|
+
const nodeId = invocation.dispatchedNodeId;
|
|
2676
|
+
const holdWhere = and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), eq(actionInvocations.status, invocation.status), eq(actionInvocations.dispatchAttempts, invocation.dispatchAttempts ?? 0), retryAfterAtMatches(invocation.retryAfterAt ?? null), ...(opts.expectedNodeLivenessRevision !== undefined
|
|
2677
|
+
? [eq(actionInvocations.nodeLivenessRevision, opts.expectedNodeLivenessRevision)]
|
|
2678
|
+
: []));
|
|
2679
|
+
try {
|
|
2680
|
+
// Clear the marker and decrement together. A transaction rolls both
|
|
2681
|
+
// back when the counter update throws. A handle without one can commit
|
|
2682
|
+
// the clear first; the catch puts that marker back.
|
|
2683
|
+
await runAtomic(db, async (tx) => {
|
|
2684
|
+
const [releasedHold] = await tx
|
|
2685
|
+
.update(actionInvocations)
|
|
2686
|
+
.set({ spawnReservedAt: null })
|
|
2687
|
+
.where(and(holdWhere, eq(actionInvocations.spawnReservedAt, heldAt)))
|
|
2688
|
+
.returning({ id: actionInvocations.id });
|
|
2689
|
+
if (releasedHold)
|
|
2690
|
+
await releaseNodeCapacity(tx, invocation.workspaceId, nodeId);
|
|
2691
|
+
});
|
|
2692
|
+
}
|
|
2693
|
+
catch (error) {
|
|
2694
|
+
await db
|
|
2695
|
+
.update(actionInvocations)
|
|
2696
|
+
.set({ spawnReservedAt: heldAt })
|
|
2697
|
+
.where(and(holdWhere, isNull(actionInvocations.spawnReservedAt)));
|
|
2698
|
+
throw error;
|
|
2699
|
+
}
|
|
2479
2700
|
}
|
|
2480
2701
|
// A shadowed spawn stays pinned to its node: while a `spawn:<harness>` shadow
|
|
2481
2702
|
// is registered there, native capacity for that harness is unreachable — even
|
|
@@ -2523,6 +2744,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
|
|
|
2523
2744
|
invocationOrigin: invocation.invocationOrigin,
|
|
2524
2745
|
retryAfterAt: opts.retryAfterAt ?? null,
|
|
2525
2746
|
expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
|
|
2747
|
+
...observedInvocationClaim(invocation),
|
|
2526
2748
|
});
|
|
2527
2749
|
return dispatched.accepted;
|
|
2528
2750
|
}
|
|
@@ -2572,6 +2794,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
|
|
|
2572
2794
|
queue: target.queue,
|
|
2573
2795
|
reservationHeld: false,
|
|
2574
2796
|
expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
|
|
2797
|
+
...observedInvocationClaim(invocation),
|
|
2575
2798
|
}, { failIfUnavailable: false });
|
|
2576
2799
|
if (dispatched.providerUnavailable) {
|
|
2577
2800
|
providerRejectedNodeIds.add(placement.node.id);
|
|
@@ -2589,15 +2812,32 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
|
|
|
2589
2812
|
const expectedActionId = invocation.invocationOrigin === 'registered_action'
|
|
2590
2813
|
? invocation.actionId
|
|
2591
2814
|
: null;
|
|
2815
|
+
const queuedClaim = invocation.status === 'pending' || invocation.status === 'dispatched' || invocation.status === 'invoked'
|
|
2816
|
+
? exclusiveClaimWrite({
|
|
2817
|
+
observedStatus: invocation.status,
|
|
2818
|
+
observedRetryAfterAt: invocation.retryAfterAt ?? null,
|
|
2819
|
+
observedDispatchAttempts: invocation.dispatchAttempts ?? 0,
|
|
2820
|
+
nextStatus: 'pending',
|
|
2821
|
+
nextRetryAfterAt: opts.retryAfterAt ?? null,
|
|
2822
|
+
incrementAttempts: true,
|
|
2823
|
+
})
|
|
2824
|
+
: null;
|
|
2592
2825
|
const accepted = await dispatchNodeAttempt(db, invocation.workspaceId, invocation.id, placement.node.id, {
|
|
2593
2826
|
providerName,
|
|
2594
2827
|
pending: true,
|
|
2595
|
-
retryAfterAt: opts.retryAfterAt ?? null,
|
|
2828
|
+
retryAfterAt: queuedClaim?.retryAfterAt ?? opts.retryAfterAt ?? null,
|
|
2596
2829
|
reservationHeld: false,
|
|
2597
2830
|
expectedAction: expectedActionId
|
|
2598
2831
|
? { id: expectedActionId, name: actionToSend }
|
|
2599
2832
|
: undefined,
|
|
2600
2833
|
targetActionId: target?.id,
|
|
2834
|
+
...(queuedClaim ? {
|
|
2835
|
+
claim: {
|
|
2836
|
+
status: invocation.status,
|
|
2837
|
+
retryAfterAt: invocation.retryAfterAt ?? null,
|
|
2838
|
+
dispatchAttempts: invocation.dispatchAttempts ?? 0,
|
|
2839
|
+
},
|
|
2840
|
+
} : {}),
|
|
2601
2841
|
expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
|
|
2602
2842
|
});
|
|
2603
2843
|
if (!accepted && invocation.invocationOrigin === 'registered_action') {
|
|
@@ -2605,21 +2845,41 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
|
|
|
2605
2845
|
}
|
|
2606
2846
|
return accepted;
|
|
2607
2847
|
}
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2848
|
+
let dispatched;
|
|
2849
|
+
try {
|
|
2850
|
+
dispatched = await dispatchNodeInvocation({
|
|
2851
|
+
db,
|
|
2852
|
+
registry,
|
|
2853
|
+
workspaceId: invocation.workspaceId,
|
|
2854
|
+
invocationId: invocation.id,
|
|
2855
|
+
nodeId: placement.node.id,
|
|
2856
|
+
providerName,
|
|
2857
|
+
action: actionToSend,
|
|
2858
|
+
actionId: target?.id ?? null,
|
|
2859
|
+
expectedActionId: invocation.actionId,
|
|
2860
|
+
invocationOrigin: invocation.invocationOrigin,
|
|
2861
|
+
input,
|
|
2862
|
+
reservationHeld,
|
|
2863
|
+
retryAfterAt: opts.retryAfterAt ?? null,
|
|
2864
|
+
expectedNodeLivenessRevision: opts.expectedNodeLivenessRevision,
|
|
2865
|
+
...observedInvocationClaim(invocation),
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
catch (error) {
|
|
2869
|
+
// A throw before the claim commits has no marker, so return the
|
|
2870
|
+
// new slot. A restore failure returns reservationStillHeld instead
|
|
2871
|
+
// of throwing, and the check below leaves that slot in place.
|
|
2872
|
+
if (reservationHeld) {
|
|
2873
|
+
await releaseNodeCapacity(db, invocation.workspaceId, placement.node.id);
|
|
2874
|
+
}
|
|
2875
|
+
throw error;
|
|
2876
|
+
}
|
|
2877
|
+
// claimSpawnNode incremented reservedAgents for this attempt. Release
|
|
2878
|
+
// it when the pending claim loses or the send never starts. Leave it
|
|
2879
|
+
// when the reservation marker is still set.
|
|
2880
|
+
if (!dispatched.accepted && !dispatched.sent && reservationHeld && !dispatched.reservationStillHeld) {
|
|
2881
|
+
await releaseNodeCapacity(db, invocation.workspaceId, placement.node.id);
|
|
2882
|
+
}
|
|
2623
2883
|
return dispatched.accepted;
|
|
2624
2884
|
}
|
|
2625
2885
|
catch {
|
|
@@ -2642,7 +2902,7 @@ export async function rescheduleNodeInvocation(db, registry, invocation, opts =
|
|
|
2642
2902
|
spawnReservedAt: null,
|
|
2643
2903
|
retryAfterAt: opts.retryAfterAt ?? nextRetryAfter(invocation.dispatchAttempts + 1),
|
|
2644
2904
|
})
|
|
2645
|
-
.where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id),
|
|
2905
|
+
.where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), eq(actionInvocations.status, invocation.status), eq(actionInvocations.dispatchAttempts, invocation.dispatchAttempts ?? 0), retryAfterAtMatches(invocation.retryAfterAt ?? null), ...(opts.expectedNodeLivenessRevision !== undefined
|
|
2646
2906
|
? [eq(actionInvocations.nodeLivenessRevision, opts.expectedNodeLivenessRevision)]
|
|
2647
2907
|
: [])));
|
|
2648
2908
|
return false;
|
|
@@ -2750,6 +3010,7 @@ export async function completeNodeInvocation(db, registry, workspaceId, nodeId,
|
|
|
2750
3010
|
spawnReservedAt: actionInvocations.spawnReservedAt,
|
|
2751
3011
|
attemptedNodeIds: actionInvocations.attemptedNodeIds,
|
|
2752
3012
|
dispatchAttempts: actionInvocations.dispatchAttempts,
|
|
3013
|
+
retryAfterAt: actionInvocations.retryAfterAt,
|
|
2753
3014
|
createdAt: actionInvocations.createdAt,
|
|
2754
3015
|
completedAt: actionInvocations.completedAt,
|
|
2755
3016
|
})
|
|
@@ -2866,11 +3127,9 @@ async function failNeverDispatchedExpiredInvocations(db, maxAgeMs, graceMs, comp
|
|
|
2866
3127
|
.where(and(eq(actionInvocations.status, 'pending'), eq(actionInvocations.dispatchAttempts, 0), lte(actionInvocations.createdAt, cutoff)));
|
|
2867
3128
|
if (rows.length === 0)
|
|
2868
3129
|
return;
|
|
2869
|
-
//
|
|
2870
|
-
//
|
|
2871
|
-
//
|
|
2872
|
-
// in-flight dispatch reliably invisible to the age sweep instead of racing
|
|
2873
|
-
// with it. See `NEVER_DISPATCHED_SWEEP_GRACE_MS`.
|
|
3130
|
+
// Unclaimed sends still record `dispatchAttempts` after the frame. Wait out
|
|
3131
|
+
// that gap, then the UPDATE's `dispatch_attempts = 0` re-check skips any row
|
|
3132
|
+
// the dispatcher has claimed. See `NEVER_DISPATCHED_SWEEP_GRACE_MS`.
|
|
2874
3133
|
if (graceMs > 0) {
|
|
2875
3134
|
await new Promise((resolve) => setTimeout(resolve, graceMs));
|
|
2876
3135
|
}
|
|
@@ -2916,6 +3175,9 @@ async function failNeverDispatchedInvocationRows(db, workspaceId, invocationIds,
|
|
|
2916
3175
|
.returning();
|
|
2917
3176
|
}
|
|
2918
3177
|
export async function sweepTimedOutInvocations(db, registry, opts = {}) {
|
|
3178
|
+
return withEngineOperation('invocations.sweep', undefined, () => sweepTimedOutInvocationsUnlabeled(db, registry, opts));
|
|
3179
|
+
}
|
|
3180
|
+
async function sweepTimedOutInvocationsUnlabeled(db, registry, opts) {
|
|
2919
3181
|
await expireTaskInvocations(db);
|
|
2920
3182
|
const sweepOpts = typeof opts === 'number' || opts === null ? {} : opts;
|
|
2921
3183
|
const timeoutMs = typeof opts === 'number' ? opts : sweepOpts.timeoutMs ?? ACTION_DISPATCH_TIMEOUT_MS;
|