@relaycast/engine 10.2.0 → 10.3.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/realtime.d.ts.map +1 -1
- package/dist/adapters/node/realtime.js +3 -1
- package/dist/adapters/node/realtime.js.map +1 -1
- package/dist/db/migrations/0071_node_liveness_revision.sql +123 -0
- package/dist/db/schema.d.ts +68 -0
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +20 -0
- package/dist/db/schema.js.map +1 -1
- package/dist/engine/action.d.ts +4 -1
- package/dist/engine/action.d.ts.map +1 -1
- package/dist/engine/action.js +84 -30
- package/dist/engine/action.js.map +1 -1
- package/dist/engine/delivery.d.ts.map +1 -1
- package/dist/engine/delivery.js +33 -2
- package/dist/engine/delivery.js.map +1 -1
- package/dist/engine/invocationCompletion.d.ts +2 -0
- package/dist/engine/invocationCompletion.d.ts.map +1 -1
- package/dist/engine/invocationCompletion.js +21 -6
- package/dist/engine/invocationCompletion.js.map +1 -1
- package/dist/engine/node.d.ts +46 -1
- package/dist/engine/node.d.ts.map +1 -1
- package/dist/engine/node.js +126 -29
- package/dist/engine/node.js.map +1 -1
- package/dist/engine/nodeProvider.d.ts +20 -0
- package/dist/engine/nodeProvider.d.ts.map +1 -1
- package/dist/engine/nodeProvider.js +69 -10
- package/dist/engine/nodeProvider.js.map +1 -1
- package/dist/engine/taskInvocation.d.ts +1 -0
- package/dist/engine/taskInvocation.d.ts.map +1 -1
- package/dist/engine/workspaceEvents.d.ts +11 -0
- package/dist/engine/workspaceEvents.d.ts.map +1 -1
- package/dist/engine/workspaceEvents.js +44 -0
- package/dist/engine/workspaceEvents.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 +6 -1
- package/dist/node-control.js.map +1 -1
- package/dist/ports/realtime.d.ts +2 -0
- package/dist/ports/realtime.d.ts.map +1 -1
- package/dist/ports/realtime.js.map +1 -1
- package/package.json +3 -3
package/dist/engine/node.js
CHANGED
|
@@ -12,7 +12,7 @@ import { generateId } from './snowflake.js';
|
|
|
12
12
|
import { assertRegistrableAgentName, agentLivenessLoss, agentLivenessRefresh, RELEASED_AGENT_STATUS } from './agent.js';
|
|
13
13
|
import { rotateAgentIdentity } from './agentIdentity.js';
|
|
14
14
|
import { isNodeLive, isReusableForMachineMatch, nodeHasCapacity, NODE_LIVENESS_TTL_MS } from './placement.js';
|
|
15
|
-
import { DEFAULT_PROVIDER_NAME, assertActionTaskMode, capabilityKind, heartbeatProvider, isProviderLive, markProviderOffline, materializeProviderActions, providerActionsMaterialized, recomputeNodeAggregate, removeProvider, upsertProvider, } from './nodeProvider.js';
|
|
15
|
+
import { DEFAULT_PROVIDER_NAME, assertActionTaskMode, capabilityKind, heartbeatProvider, isProviderLive, markProviderOffline, materializeProviderActions, providerActionsMaterialized, recomputeNodeAggregate, refreshNodeAggregate, removeProvider, upsertProvider, } from './nodeProvider.js';
|
|
16
16
|
import { serializeNodeOp } from './nodeLock.js';
|
|
17
17
|
import { completeNodeInvocation, dispatchCapacitySpawn, rescheduleInvocationsForLostNode, rescheduleNodeInvocation, } from './action.js';
|
|
18
18
|
import { emitInvocationCompletionEffects, emitAgentExitedEffects, emitNodeStatusEffects, fleetInvocationId } from './invocationCompletion.js';
|
|
@@ -704,7 +704,11 @@ export async function registerNode(db, workspaceId, authenticatedNodeId, message
|
|
|
704
704
|
}));
|
|
705
705
|
return { node: publicNode(updated), acceptance, provider };
|
|
706
706
|
}
|
|
707
|
-
export async function heartbeatNode(db, workspaceId, nodeId, providerName, message
|
|
707
|
+
export async function heartbeatNode(db, workspaceId, nodeId, providerName, message,
|
|
708
|
+
// A caller that already read the node row may pass it to skip the role
|
|
709
|
+
// probe: `null` means "the row was missing", an object supplies its role.
|
|
710
|
+
// `undefined` keeps the historical own-read behavior.
|
|
711
|
+
priorNode) {
|
|
708
712
|
// A heartbeat is provider-scoped by connection. It may carry the provider's
|
|
709
713
|
// roster snapshot so the engine can refresh the descriptor between (or in the
|
|
710
714
|
// absence of a fresh) node.register — e.g. after an engine restart where the
|
|
@@ -713,10 +717,12 @@ export async function heartbeatNode(db, workspaceId, nodeId, providerName, messa
|
|
|
713
717
|
if (message.node_id !== undefined && message.node_id !== nodeId) {
|
|
714
718
|
throw codedError('node_id does not match the authenticated node token', 'node_id_mismatch', 403);
|
|
715
719
|
}
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
+
const nodeState = priorNode === undefined
|
|
721
|
+
? (await db
|
|
722
|
+
.select({ role: nodes.role })
|
|
723
|
+
.from(nodes)
|
|
724
|
+
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId))))[0]
|
|
725
|
+
: priorNode;
|
|
720
726
|
if (!nodeState)
|
|
721
727
|
return null;
|
|
722
728
|
if (nodeState.role === 'direct') {
|
|
@@ -818,22 +824,55 @@ export async function heartbeatNode(db, workspaceId, nodeId, providerName, messa
|
|
|
818
824
|
// ordering the separate stamp used to guarantee: the aggregate derives
|
|
819
825
|
// lastHeartbeatAt from provider rows, which registration also writes,
|
|
820
826
|
// whereas this column is only ever set by an arriving frame.
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
827
|
+
// A capability-free beat cannot change the manifest union, so it takes the
|
|
828
|
+
// single-statement aggregate path; only a beat carrying `capabilities`
|
|
829
|
+
// pays the provider-listing recompute.
|
|
830
|
+
updated = message.capabilities === undefined
|
|
831
|
+
? await refreshNodeAggregate(tx, workspaceId, nodeId, {
|
|
832
|
+
...(message.version !== undefined ? { version: message.version } : {}),
|
|
833
|
+
provenLiveAt: now,
|
|
834
|
+
})
|
|
835
|
+
: await recomputeNodeAggregate(tx, workspaceId, nodeId, {
|
|
836
|
+
...(message.version !== undefined ? { version: message.version } : {}),
|
|
837
|
+
provenLiveAt: now,
|
|
838
|
+
});
|
|
825
839
|
});
|
|
826
840
|
return updated ? publicNode(updated) : null;
|
|
827
841
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
842
|
+
/**
|
|
843
|
+
* Flip a node's row offline in ONE conditional UPDATE — the only part of an
|
|
844
|
+
* offline transition a caller must serialize against reconnects. `expected`
|
|
845
|
+
* carries the heartbeat the caller's stale-read observed: any heartbeat or
|
|
846
|
+
* registration that landed since rewrote `last_heartbeat_at`, the CAS misses,
|
|
847
|
+
* and the caller must abandon the transition rather than clobber the live
|
|
848
|
+
* node. Returns the claimed row's name, timestamp, and monotonic revision; the
|
|
849
|
+
* revision is the bound the settle tail scopes its writes to. Returns undefined
|
|
850
|
+
* when the claim was lost.
|
|
851
|
+
*/
|
|
852
|
+
export async function claimNodeOffline(db, workspaceId, nodeId, expected) {
|
|
853
|
+
const claimAt = new Date();
|
|
854
|
+
const nextRevision = sql `MAX(
|
|
855
|
+
${nodes.livenessRevision},
|
|
856
|
+
COALESCE((
|
|
857
|
+
SELECT MAX(np.liveness_revision)
|
|
858
|
+
FROM node_providers np
|
|
859
|
+
WHERE np.workspace_id = ${workspaceId} AND np.node_id = ${nodeId}
|
|
860
|
+
), 0),
|
|
861
|
+
COALESCE((
|
|
862
|
+
SELECT MAX(a.node_liveness_revision)
|
|
863
|
+
FROM agents a
|
|
864
|
+
WHERE a.workspace_id = ${workspaceId} AND a.location_node_id = ${nodeId}
|
|
865
|
+
AND a.location_type = 'via_node'
|
|
866
|
+
AND a.status NOT IN ('offline', 'released')
|
|
867
|
+
), 0),
|
|
868
|
+
COALESCE((
|
|
869
|
+
SELECT MAX(ai.node_liveness_revision)
|
|
870
|
+
FROM action_invocations ai
|
|
871
|
+
WHERE ai.workspace_id = ${workspaceId} AND ai.dispatched_node_id = ${nodeId}
|
|
872
|
+
AND ai.status IN ('pending', 'dispatched', 'invoked')
|
|
873
|
+
), 0)
|
|
874
|
+
) + 1`;
|
|
875
|
+
const [claimed] = await db
|
|
837
876
|
.update(nodes)
|
|
838
877
|
.set({
|
|
839
878
|
status: 'offline',
|
|
@@ -841,29 +880,86 @@ export async function markNodeOffline(db, registry, workspaceId, nodeId, effects
|
|
|
841
880
|
load: 0,
|
|
842
881
|
loadReported: false,
|
|
843
882
|
activeAgents: 0,
|
|
844
|
-
|
|
883
|
+
// The written heartbeat IS the claim epoch: settle tails and later
|
|
884
|
+
// transitions detect a post-claim liveness write by observing a
|
|
885
|
+
// `last_heartbeat_at` newer than `claimAt`, so both values must be the
|
|
886
|
+
// same instant (second-granular column — two `new Date()` calls could
|
|
887
|
+
// straddle a boundary).
|
|
888
|
+
lastHeartbeatAt: claimAt,
|
|
889
|
+
// This is the durable claim boundary. Triggers stamp every later child
|
|
890
|
+
// liveness mutation above it, including writes in the same wall-clock
|
|
891
|
+
// second, so the settle tail never relies on timestamp ordering.
|
|
892
|
+
livenessRevision: nextRevision,
|
|
845
893
|
})
|
|
846
|
-
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)))
|
|
894
|
+
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId), eq(nodes.status, 'online'), eq(nodes.livenessRevision, expected.livenessRevision), expected.lastHeartbeatAt === null
|
|
895
|
+
? isNull(nodes.lastHeartbeatAt)
|
|
896
|
+
: eq(nodes.lastHeartbeatAt, expected.lastHeartbeatAt)))
|
|
897
|
+
.returning({ name: nodes.name, claimRevision: nodes.livenessRevision });
|
|
898
|
+
return claimed ? { name: claimed.name, claimAt, claimRevision: claimed.claimRevision } : undefined;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* The resumable tail of an offline transition: provider/agent liveness loss,
|
|
902
|
+
* invocation rescheduling, and the durable event. Safe to run after the
|
|
903
|
+
* node's claim row is released and concurrently with a reconnect, because
|
|
904
|
+
* every write is claim-scoped — `claimRevision` bounds the transition to rows
|
|
905
|
+
* stamped before the claim, so a provider, hosted agent, or invocation changed
|
|
906
|
+
* after it is never clobbered even when both writes share a timestamp second.
|
|
907
|
+
* Callers that cannot interleave (in-process serialized paths) omit the claim
|
|
908
|
+
* fields for the legacy unconditional sweep.
|
|
909
|
+
*/
|
|
910
|
+
export async function settleNodeOffline(db, registry, workspaceId, nodeId, opts = {}) {
|
|
911
|
+
const claimRevision = opts.claimRevision;
|
|
847
912
|
// Provider capability sets persist (offline nodes still show their manifest);
|
|
848
913
|
// only their liveness flips. Zero activeAgents too, so a later
|
|
849
914
|
// recomputeNodeAggregate never resurrects a dropped provider's agent count.
|
|
850
915
|
await db
|
|
851
916
|
.update(nodeProviders)
|
|
852
917
|
.set({ status: 'offline', handlersLive: false, load: 0, loadReported: false, activeAgents: 0, lastHeartbeatAt: new Date() })
|
|
853
|
-
.where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId)
|
|
918
|
+
.where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId), ...(claimRevision !== undefined
|
|
919
|
+
? [lt(nodeProviders.livenessRevision, claimRevision)]
|
|
920
|
+
: [])));
|
|
854
921
|
await db
|
|
855
922
|
.update(agents)
|
|
856
923
|
.set({ ...agentLivenessLoss(), lastSeen: new Date() })
|
|
857
|
-
.where(and(eq(agents.workspaceId, workspaceId), ne(agents.status, 'offline'), ne(agents.status, RELEASED_AGENT_STATUS), eq(agents.locationType, 'via_node'), eq(agents.locationNodeId, nodeId)
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
924
|
+
.where(and(eq(agents.workspaceId, workspaceId), ne(agents.status, 'offline'), ne(agents.status, RELEASED_AGENT_STATUS), eq(agents.locationType, 'via_node'), eq(agents.locationNodeId, nodeId), ...(claimRevision !== undefined
|
|
925
|
+
? [lt(agents.nodeLivenessRevision, claimRevision)]
|
|
926
|
+
: [])));
|
|
927
|
+
await rescheduleInvocationsForLostNode(db, registry, workspaceId, nodeId, { claimRevision });
|
|
928
|
+
if (opts.deps && opts.before && opts.before.status === 'online') {
|
|
929
|
+
await emitNodeStatusEffects(opts.deps, workspaceId, {
|
|
861
930
|
status: 'offline',
|
|
862
931
|
nodeId,
|
|
863
|
-
nodeName: before.name,
|
|
864
|
-
reason:
|
|
932
|
+
nodeName: opts.before.name,
|
|
933
|
+
reason: opts.reason,
|
|
934
|
+
...(claimRevision !== undefined ? { claimRevision } : {}),
|
|
865
935
|
});
|
|
866
936
|
}
|
|
937
|
+
return true;
|
|
938
|
+
}
|
|
939
|
+
export async function markNodeOffline(db, registry, workspaceId, nodeId, effects) {
|
|
940
|
+
// Capture the node's name + prior status BEFORE flipping it so a durable
|
|
941
|
+
// node.status.offline is emitted only on a real online -> offline transition
|
|
942
|
+
// (never on a node that was already offline).
|
|
943
|
+
const [before] = await db
|
|
944
|
+
.select({ status: nodes.status, name: nodes.name })
|
|
945
|
+
.from(nodes)
|
|
946
|
+
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
|
|
947
|
+
await db
|
|
948
|
+
.update(nodes)
|
|
949
|
+
.set({
|
|
950
|
+
status: 'offline',
|
|
951
|
+
handlersLive: false,
|
|
952
|
+
load: 0,
|
|
953
|
+
loadReported: false,
|
|
954
|
+
activeAgents: 0,
|
|
955
|
+
lastHeartbeatAt: new Date(),
|
|
956
|
+
})
|
|
957
|
+
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
|
|
958
|
+
await settleNodeOffline(db, registry, workspaceId, nodeId, {
|
|
959
|
+
before,
|
|
960
|
+
deps: effects?.deps,
|
|
961
|
+
reason: effects?.reason,
|
|
962
|
+
});
|
|
867
963
|
}
|
|
868
964
|
/**
|
|
869
965
|
* A provider's control connection dropped. Its capability set persists (marked
|
|
@@ -2295,6 +2391,7 @@ async function readHeartbeatDrainState(db, workspaceId, nodeId, providerName) {
|
|
|
2295
2391
|
db
|
|
2296
2392
|
.select({
|
|
2297
2393
|
status: nodes.status,
|
|
2394
|
+
role: nodes.role,
|
|
2298
2395
|
handlersLive: nodes.handlersLive,
|
|
2299
2396
|
maxAgents: nodes.maxAgents,
|
|
2300
2397
|
activeAgents: nodes.activeAgents,
|
|
@@ -2474,7 +2571,7 @@ export async function handleNodeControlMessage(args) {
|
|
|
2474
2571
|
}
|
|
2475
2572
|
case 'node.heartbeat': {
|
|
2476
2573
|
const prior = await readHeartbeatDrainState(args.db, args.workspaceId, args.nodeId, frameProviderName);
|
|
2477
|
-
const beat = await heartbeatNode(args.db, args.workspaceId, args.nodeId, frameProviderName, message);
|
|
2574
|
+
const beat = await heartbeatNode(args.db, args.workspaceId, args.nodeId, frameProviderName, message, prior.node ?? null);
|
|
2478
2575
|
// The node row is already persisted, so emit the durable online
|
|
2479
2576
|
// transition before draining: a drainNode rejection must not skip the
|
|
2480
2577
|
// node.status.online event. Isolated so its own failure can't either.
|