@relaycast/engine 6.1.0 → 6.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/delivery-maintenance.d.ts +1 -1
- package/dist/adapters/node/delivery-maintenance.d.ts.map +1 -1
- package/dist/adapters/node/delivery-maintenance.js +4 -4
- package/dist/adapters/node/delivery-maintenance.js.map +1 -1
- package/dist/adapters/node/index.js +2 -2
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/realtime.d.ts.map +1 -1
- package/dist/adapters/node/realtime.js +45 -3
- package/dist/adapters/node/realtime.js.map +1 -1
- package/dist/agent-disconnect.d.ts +23 -1
- package/dist/agent-disconnect.d.ts.map +1 -1
- package/dist/agent-disconnect.js +20 -2
- package/dist/agent-disconnect.js.map +1 -1
- package/dist/db/migrations/0032_invocation_handler_unreachable.sql +5 -0
- package/dist/db/schema.d.ts +17 -0
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +4 -0
- package/dist/db/schema.js.map +1 -1
- package/dist/engine/action.d.ts +34 -13
- package/dist/engine/action.d.ts.map +1 -1
- package/dist/engine/action.js +323 -57
- package/dist/engine/action.js.map +1 -1
- package/dist/engine/delivery.d.ts +21 -1
- package/dist/engine/delivery.d.ts.map +1 -1
- package/dist/engine/delivery.js +79 -9
- package/dist/engine/delivery.js.map +1 -1
- package/dist/engine/invocationCompletion.d.ts +43 -0
- package/dist/engine/invocationCompletion.d.ts.map +1 -1
- package/dist/engine/invocationCompletion.js +108 -4
- package/dist/engine/invocationCompletion.js.map +1 -1
- package/dist/engine/node.d.ts +72 -6
- package/dist/engine/node.d.ts.map +1 -1
- package/dist/engine/node.js +152 -23
- package/dist/engine/node.js.map +1 -1
- package/dist/engine/nodeDeliver.d.ts.map +1 -1
- package/dist/engine/nodeDeliver.js +5 -0
- package/dist/engine/nodeDeliver.js.map +1 -1
- package/dist/engine/observerToken.js +1 -1
- package/dist/engine/observerToken.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/lib/origin.d.ts +44 -0
- package/dist/lib/origin.d.ts.map +1 -1
- package/dist/lib/origin.js +63 -3
- package/dist/lib/origin.js.map +1 -1
- package/dist/lib/serverTelemetry.d.ts.map +1 -1
- package/dist/lib/serverTelemetry.js +10 -2
- package/dist/lib/serverTelemetry.js.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 +22 -1
- package/dist/ports/realtime.d.ts.map +1 -1
- package/dist/ports/realtime.js.map +1 -1
- package/dist/routes/action.js +11 -5
- package/dist/routes/action.js.map +1 -1
- package/dist/routes/deliveryRouting.d.ts +20 -1
- package/dist/routes/deliveryRouting.d.ts.map +1 -1
- package/dist/routes/deliveryRouting.js +116 -17
- package/dist/routes/deliveryRouting.js.map +1 -1
- package/dist/routes/node.js +3 -2
- package/dist/routes/node.js.map +1 -1
- package/dist/routes/presence.d.ts.map +1 -1
- package/dist/routes/presence.js +15 -4
- package/dist/routes/presence.js.map +1 -1
- package/package.json +3 -3
package/dist/engine/action.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import { and, asc, eq, inArray, isNull, lte, or, sql } from 'drizzle-orm';
|
|
1
|
+
import { and, asc, eq, inArray, isNotNull, isNull, lte, or, sql } from 'drizzle-orm';
|
|
2
2
|
import { actions, actionInvocations, agents, agentNodeBindings, nodes } from '../db/schema.js';
|
|
3
3
|
import { generateId } from './snowflake.js';
|
|
4
4
|
import { codedError } from '../lib/httpError.js';
|
|
5
5
|
import { toFleetWireJson } from './deliveryWire.js';
|
|
6
|
+
import { emitAgentExitedEffects, emitInvocationCompletionEffects, fleetInvocationId, } from './invocationCompletion.js';
|
|
7
|
+
import { runAtomic } from '../ports/database.js';
|
|
6
8
|
import { claimSpawnNode, chooseNodeForAction, isNodeLive, releaseNodeCapacity, reserveNodeCapacity } from './placement.js';
|
|
7
9
|
import { DEFAULT_PROVIDER_NAME, capacityProviderName, getProvider, isProviderLive } from './nodeProvider.js';
|
|
8
10
|
const OPEN_INVOCATION_STATUSES = ['pending', 'dispatched', 'invoked'];
|
|
9
11
|
export const ACTION_DISPATCH_TIMEOUT_MS = 30_000;
|
|
12
|
+
/**
|
|
13
|
+
* How long an agent handler's connection must be CONTINUOUSLY unreachable
|
|
14
|
+
* before its open invocations are failed with `handler_unavailable`. The clock
|
|
15
|
+
* starts at the sweep's first unreachable observation and resets when
|
|
16
|
+
* connectivity recovers — generous enough to ride out a handler restart at any
|
|
17
|
+
* invocation age, bounded enough that a caller blocked on the invocation gets
|
|
18
|
+
* a signal instead of an unbounded hang.
|
|
19
|
+
*/
|
|
20
|
+
export const ACTION_HANDLER_UNREACHABLE_TTL_MS = 120_000;
|
|
10
21
|
const ACTION_RETRY_BACKOFF_MS = 5_000;
|
|
11
22
|
const NODE_DRAIN_REQUEUE_RETRY_MS = 5_000;
|
|
12
23
|
function capabilityName(capability) {
|
|
@@ -120,7 +131,7 @@ async function fetchNodeAction(db, workspaceId, nodeId, actionName) {
|
|
|
120
131
|
.where(and(eq(actions.workspaceId, workspaceId), eq(actions.handlerNodeId, nodeId), eq(actions.name, actionName), eq(actions.isActive, true)));
|
|
121
132
|
return action ?? null;
|
|
122
133
|
}
|
|
123
|
-
export async function registerAction(db, workspaceId, data) {
|
|
134
|
+
export async function registerAction(db, workspaceId, data, options = {}) {
|
|
124
135
|
requireOneHandler(data);
|
|
125
136
|
let handlerAgentId = null;
|
|
126
137
|
let handlerAgentName = null;
|
|
@@ -155,32 +166,94 @@ export async function registerAction(db, workspaceId, data) {
|
|
|
155
166
|
handlerNodeName = node.name;
|
|
156
167
|
}
|
|
157
168
|
const id = `act_${generateId()}`;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
169
|
+
// Registration is an idempotent assertion (PUT semantics): re-registering an
|
|
170
|
+
// existing name refreshes the handler pointer, schemas, and visibility so a
|
|
171
|
+
// reconnecting publisher heals a stale handler instead of surfacing the
|
|
172
|
+
// unique index as a 500. Provider-owned fields (handlerProvider, isGlobal,
|
|
173
|
+
// queue) are deliberately not touched — they belong to node-control
|
|
174
|
+
// registration (materializeProviderActions).
|
|
175
|
+
const refresh = {
|
|
164
176
|
description: data.description,
|
|
165
177
|
handlerAgentId,
|
|
166
|
-
handlerNodeId,
|
|
167
178
|
inputSchema: data.input_schema ?? {},
|
|
168
179
|
outputSchema: data.output_schema ?? {},
|
|
169
180
|
availableTo: data.available_to ?? null,
|
|
170
|
-
|
|
171
|
-
|
|
181
|
+
isActive: true,
|
|
182
|
+
};
|
|
183
|
+
let action;
|
|
184
|
+
let stranded;
|
|
185
|
+
try {
|
|
186
|
+
// The read-upsert-strand sequence runs atomically so a concurrent invoke
|
|
187
|
+
// cannot land between the handler move and the stranded-invocation snapshot
|
|
188
|
+
// (it either targets the old handler and is swept, or the new handler and
|
|
189
|
+
// is untouched).
|
|
190
|
+
({ action, stranded } = await runAtomic(db, async (tx) => {
|
|
191
|
+
// Read the row the upsert may refresh so a handler move can be detected.
|
|
192
|
+
// The agent branch conflicts on the agent-hosted partial index, the node
|
|
193
|
+
// branch on the per-node index.
|
|
194
|
+
const [previous] = await tx
|
|
195
|
+
.select({ id: actions.id, handlerAgentId: actions.handlerAgentId })
|
|
196
|
+
.from(actions)
|
|
197
|
+
.where(and(eq(actions.workspaceId, workspaceId), eq(actions.name, data.name), handlerNodeId ? eq(actions.handlerNodeId, handlerNodeId) : isNull(actions.handlerNodeId)));
|
|
198
|
+
const [row] = await tx
|
|
199
|
+
.insert(actions)
|
|
200
|
+
.values({
|
|
201
|
+
id,
|
|
202
|
+
workspaceId,
|
|
203
|
+
name: data.name,
|
|
204
|
+
description: data.description,
|
|
205
|
+
handlerAgentId,
|
|
206
|
+
handlerNodeId,
|
|
207
|
+
inputSchema: data.input_schema ?? {},
|
|
208
|
+
outputSchema: data.output_schema ?? {},
|
|
209
|
+
availableTo: data.available_to ?? null,
|
|
210
|
+
})
|
|
211
|
+
.onConflictDoUpdate(handlerNodeId
|
|
212
|
+
? { target: [actions.workspaceId, actions.handlerNodeId, actions.name], set: refresh }
|
|
213
|
+
// Agent-hosted uniqueness is a partial index, so the conflict target
|
|
214
|
+
// must carry its WHERE clause to match it.
|
|
215
|
+
: { target: [actions.workspaceId, actions.name], targetWhere: isNull(actions.handlerNodeId), set: refresh })
|
|
216
|
+
.returning();
|
|
217
|
+
// A refresh that moves the handler to a DIFFERENT agent strands
|
|
218
|
+
// invocations already in flight toward the previous handler: completion
|
|
219
|
+
// authorization follows actions.handlerAgentId (the old handler's
|
|
220
|
+
// completion would 403) and the timeout sweep would redeliver them to the
|
|
221
|
+
// new handler, which never received the original dispatch. Terminally
|
|
222
|
+
// fail them; the callers are told after commit.
|
|
223
|
+
let strandedRows = [];
|
|
224
|
+
if (previous && previous.handlerAgentId && previous.handlerAgentId !== handlerAgentId) {
|
|
225
|
+
const strandedIds = await openInvocationIdsForActions(tx, workspaceId, [previous.id]);
|
|
226
|
+
strandedRows = await failOpenInvocationRows(tx, workspaceId, strandedIds, 'handler_unavailable');
|
|
227
|
+
}
|
|
228
|
+
return { action: row, stranded: strandedRows };
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
// A residual uniqueness race must surface as a conflict, never as a 500
|
|
233
|
+
// internal_error (which reads as a server outage and invites retries).
|
|
234
|
+
if (err instanceof Error && /unique constraint failed/i.test(err.message)) {
|
|
235
|
+
throw codedError(`Action "${data.name}" conflicts with an existing action`, 'action_name_conflict', 409);
|
|
236
|
+
}
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
if (options.completionDeps) {
|
|
240
|
+
await emitFailedInvocationEffects(options.completionDeps, workspaceId, stranded);
|
|
241
|
+
}
|
|
172
242
|
return {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
243
|
+
created: action.id === id,
|
|
244
|
+
action: {
|
|
245
|
+
id: action.id,
|
|
246
|
+
name: action.name,
|
|
247
|
+
description: action.description,
|
|
248
|
+
handler_agent: handlerAgentName,
|
|
249
|
+
handler_node: handlerNodeName,
|
|
250
|
+
handler_node_id: handlerNodeId,
|
|
251
|
+
input_schema: action.inputSchema,
|
|
252
|
+
output_schema: action.outputSchema,
|
|
253
|
+
available_to: action.availableTo ?? null,
|
|
254
|
+
is_active: action.isActive,
|
|
255
|
+
created_at: action.createdAt.toISOString(),
|
|
256
|
+
},
|
|
184
257
|
};
|
|
185
258
|
}
|
|
186
259
|
export async function listActions(db, workspaceId, callerName) {
|
|
@@ -229,12 +302,29 @@ export async function getAction(db, workspaceId, name, callerName) {
|
|
|
229
302
|
return null;
|
|
230
303
|
return publicAction(row);
|
|
231
304
|
}
|
|
232
|
-
export async function deleteAction(db, workspaceId, name) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
305
|
+
export async function deleteAction(db, workspaceId, name, options = {}) {
|
|
306
|
+
// Fail open invocations WITH the delete in one atomic step: actionId is set
|
|
307
|
+
// null on action delete, which would orphan them outside every
|
|
308
|
+
// handler-resolution join (the TTL sweep included) so they retry forever and
|
|
309
|
+
// the caller never hears back. Atomicity closes the window where an invoke
|
|
310
|
+
// lands between the stranded snapshot and the delete.
|
|
311
|
+
const { deleted, stranded } = await runAtomic(db, async (tx) => {
|
|
312
|
+
const rows = await tx
|
|
313
|
+
.select({ id: actions.id })
|
|
314
|
+
.from(actions)
|
|
315
|
+
.where(and(eq(actions.workspaceId, workspaceId), eq(actions.name, name)));
|
|
316
|
+
const strandedIds = await openInvocationIdsForActions(tx, workspaceId, rows.map((row) => row.id));
|
|
317
|
+
const strandedRows = await failOpenInvocationRows(tx, workspaceId, strandedIds, 'action_deleted');
|
|
318
|
+
const result = await tx
|
|
319
|
+
.delete(actions)
|
|
320
|
+
.where(and(eq(actions.workspaceId, workspaceId), eq(actions.name, name)))
|
|
321
|
+
.returning();
|
|
322
|
+
return { deleted: result.length > 0, stranded: strandedRows };
|
|
323
|
+
});
|
|
324
|
+
if (options.completionDeps) {
|
|
325
|
+
await emitFailedInvocationEffects(options.completionDeps, workspaceId, stranded);
|
|
326
|
+
}
|
|
327
|
+
return deleted;
|
|
238
328
|
}
|
|
239
329
|
async function createInvocation(db, workspaceId, action, data) {
|
|
240
330
|
const invocationId = `inv_${generateId()}`;
|
|
@@ -253,6 +343,22 @@ async function createInvocation(db, workspaceId, action, data) {
|
|
|
253
343
|
.returning();
|
|
254
344
|
return invocation;
|
|
255
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* Liveness rule for the connection that hosts an agent handler, mirroring the
|
|
348
|
+
* drain-time rule: the provider socket must be connected, and when a provider
|
|
349
|
+
* row exists and is explicitly attached its heartbeat must say handlers are
|
|
350
|
+
* live. A missing provider row (direct node, legacy single-socket broker)
|
|
351
|
+
* keeps connection state as the available liveness signal.
|
|
352
|
+
*/
|
|
353
|
+
async function isHandlerConnectionLive(db, registry, workspaceId, nodeId, providerName) {
|
|
354
|
+
if (!registry.isProviderConnected(workspaceId, nodeId, providerName))
|
|
355
|
+
return false;
|
|
356
|
+
const provider = await getProvider(db, workspaceId, nodeId, providerName);
|
|
357
|
+
if (!provider)
|
|
358
|
+
return true;
|
|
359
|
+
const explicitlyAttached = registry.isProviderAttached?.(workspaceId, nodeId, providerName) ?? true;
|
|
360
|
+
return !explicitlyAttached || isProviderLive(provider);
|
|
361
|
+
}
|
|
256
362
|
/** Is a node provider currently invokable — connection up AND heartbeat handlers_live. */
|
|
257
363
|
async function isNodeProviderLive(db, registry, workspaceId, nodeId, providerName) {
|
|
258
364
|
if (!registry.isProviderConnected(workspaceId, nodeId, providerName))
|
|
@@ -266,6 +372,78 @@ async function failInvocationForUnavailableProvider(db, workspaceId, invocationI
|
|
|
266
372
|
.set({ status: 'failed', error: 'handler_unavailable', completedAt: new Date(), spawnReservedAt: null })
|
|
267
373
|
.where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
|
|
268
374
|
}
|
|
375
|
+
/**
|
|
376
|
+
* Terminally fail the given open invocations (no completion emission — call
|
|
377
|
+
* {@link emitFailedInvocationEffects} with the returned rows after the
|
|
378
|
+
* enclosing transaction commits). A spawn invocation holding a native node
|
|
379
|
+
* reservation releases that capacity, mirroring `completeNodeInvocation`.
|
|
380
|
+
*/
|
|
381
|
+
async function failOpenInvocationRows(db, workspaceId, invocationIds, error) {
|
|
382
|
+
if (invocationIds.length === 0)
|
|
383
|
+
return [];
|
|
384
|
+
// Capture reservation state before the update clears spawnReservedAt.
|
|
385
|
+
const held = await db
|
|
386
|
+
.select({
|
|
387
|
+
id: actionInvocations.id,
|
|
388
|
+
actionName: actionInvocations.actionName,
|
|
389
|
+
dispatchedNodeId: actionInvocations.dispatchedNodeId,
|
|
390
|
+
spawnReservedAt: actionInvocations.spawnReservedAt,
|
|
391
|
+
})
|
|
392
|
+
.from(actionInvocations)
|
|
393
|
+
.where(and(eq(actionInvocations.workspaceId, workspaceId), inArray(actionInvocations.id, invocationIds), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
|
|
394
|
+
const failed = await db
|
|
395
|
+
.update(actionInvocations)
|
|
396
|
+
.set({ status: 'failed', error, completedAt: new Date(), spawnReservedAt: null })
|
|
397
|
+
.where(and(eq(actionInvocations.workspaceId, workspaceId), inArray(actionInvocations.id, invocationIds), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)))
|
|
398
|
+
.returning();
|
|
399
|
+
const failedIds = new Set(failed.map((row) => row.id));
|
|
400
|
+
for (const row of held) {
|
|
401
|
+
if (failedIds.has(row.id) && isSpawnInvocation(row.actionName) && row.dispatchedNodeId && row.spawnReservedAt) {
|
|
402
|
+
await releaseNodeCapacity(db, workspaceId, row.dispatchedNodeId);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return failed;
|
|
406
|
+
}
|
|
407
|
+
/** Emit `action.failed` to each failed invocation's caller. Best-effort per row. */
|
|
408
|
+
async function emitFailedInvocationEffects(completionDeps, workspaceId, failed) {
|
|
409
|
+
for (const invocation of failed) {
|
|
410
|
+
try {
|
|
411
|
+
await emitInvocationCompletionEffects(completionDeps, workspaceId, {
|
|
412
|
+
invocation_id: invocation.id,
|
|
413
|
+
action_name: invocation.actionName,
|
|
414
|
+
caller_id: invocation.callerId,
|
|
415
|
+
status: 'failed',
|
|
416
|
+
output: invocation.output,
|
|
417
|
+
error: invocation.error,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
// Notification is best-effort; the invocation is already terminally failed.
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Terminally fail the given open invocations, emitting `action.failed` to each
|
|
427
|
+
* caller when completion deps are provided. Used when an invocation can no
|
|
428
|
+
* longer reach a handler that will answer: the handler pointer moved to a
|
|
429
|
+
* different agent (re-register takeover), the action was deleted, or the
|
|
430
|
+
* handler connection stayed unreachable past the TTL.
|
|
431
|
+
*/
|
|
432
|
+
async function failOpenInvocations(db, workspaceId, invocationIds, error, completionDeps) {
|
|
433
|
+
const failed = await failOpenInvocationRows(db, workspaceId, invocationIds, error);
|
|
434
|
+
if (completionDeps)
|
|
435
|
+
await emitFailedInvocationEffects(completionDeps, workspaceId, failed);
|
|
436
|
+
}
|
|
437
|
+
/** Ids of open invocations pointing at any of the given action rows. */
|
|
438
|
+
async function openInvocationIdsForActions(db, workspaceId, actionIds) {
|
|
439
|
+
if (actionIds.length === 0)
|
|
440
|
+
return [];
|
|
441
|
+
const rows = await db
|
|
442
|
+
.select({ id: actionInvocations.id })
|
|
443
|
+
.from(actionInvocations)
|
|
444
|
+
.where(and(eq(actionInvocations.workspaceId, workspaceId), inArray(actionInvocations.actionId, actionIds), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
|
|
445
|
+
return rows.map((row) => row.id);
|
|
446
|
+
}
|
|
269
447
|
/**
|
|
270
448
|
* Dispatch an invoke to a specific provider on a node. When the provider is
|
|
271
449
|
* offline the invoke fails fast (the invocation is failed) unless it opted into
|
|
@@ -581,11 +759,37 @@ export async function invokeAction(db, workspaceId, actionName, data, options =
|
|
|
581
759
|
if (!handlerAgent || handlerAgent.locationType !== 'via_node' || !handlerAgent.locationNodeId) {
|
|
582
760
|
throw codedError(`Action "${actionName}" handler is not bound to a node`, 'handler_unavailable', 503);
|
|
583
761
|
}
|
|
762
|
+
// Fail fast when the handler agent's connection is not live: an invocation
|
|
763
|
+
// queued toward a dead handler never completes and the caller gets no signal
|
|
764
|
+
// — it just hangs on the tool call. An action that opted into `queue` keeps
|
|
765
|
+
// the queue-and-drain semantics (the TTL sweep still bounds it).
|
|
766
|
+
if (!action.queue) {
|
|
767
|
+
const live = await isHandlerConnectionLive(db, options.nodeConnections, workspaceId, handlerAgent.locationNodeId, handlerAgent.providerName);
|
|
768
|
+
if (!live) {
|
|
769
|
+
throw codedError(`Action "${actionName}" handler "${handlerAgent.name}" has no live connection`, 'handler_unavailable', 503);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
584
772
|
const invocation = await createInvocation(db, workspaceId, action, {
|
|
585
773
|
input: data.input,
|
|
586
774
|
caller_id: data.caller_id,
|
|
587
775
|
caller_name: data.caller_name,
|
|
588
776
|
});
|
|
777
|
+
// Re-validate the handler pointer AFTER the insert. A takeover committing
|
|
778
|
+
// between action resolution above and the insert misses this invocation in
|
|
779
|
+
// its stranded snapshot (the row was not visible yet), which would leave it
|
|
780
|
+
// dispatched toward the old handler whose completions no longer authorize.
|
|
781
|
+
// The two orderings are now both covered: a takeover before the insert is
|
|
782
|
+
// caught here; a takeover after the insert sees the row and sweeps it.
|
|
783
|
+
// (A concurrent DELETE needs no equivalent check — the actionId foreign key
|
|
784
|
+
// rejects an insert that lands after the delete commits.)
|
|
785
|
+
const [currentHandler] = await db
|
|
786
|
+
.select({ handlerAgentId: actions.handlerAgentId })
|
|
787
|
+
.from(actions)
|
|
788
|
+
.where(and(eq(actions.workspaceId, workspaceId), eq(actions.id, action.id)));
|
|
789
|
+
if (!currentHandler || currentHandler.handlerAgentId !== handlerAgent.id) {
|
|
790
|
+
await failOpenInvocationRows(db, workspaceId, [invocation.id], 'handler_unavailable');
|
|
791
|
+
throw codedError(`Action "${actionName}" handler changed during invoke`, 'handler_unavailable', 503);
|
|
792
|
+
}
|
|
589
793
|
const dispatched = await dispatchNodeInvocation({
|
|
590
794
|
db,
|
|
591
795
|
registry: options.nodeConnections,
|
|
@@ -625,7 +829,7 @@ function publicInvocation(row) {
|
|
|
625
829
|
completed_at: row.completedAt?.toISOString() ?? null,
|
|
626
830
|
};
|
|
627
831
|
}
|
|
628
|
-
async function applyReleaseCompletionEffect(db, workspaceId, nodeId, invocation, data) {
|
|
832
|
+
async function applyReleaseCompletionEffect(db, workspaceId, nodeId, invocation, data, deps) {
|
|
629
833
|
if (!isReleaseInvocation(invocation.actionName) || data.error)
|
|
630
834
|
return;
|
|
631
835
|
const input = recordInput(invocation.input);
|
|
@@ -648,6 +852,9 @@ async function applyReleaseCompletionEffect(db, workspaceId, nodeId, invocation,
|
|
|
648
852
|
.returning({ id: agentNodeBindings.id });
|
|
649
853
|
if (!deactivatedBinding)
|
|
650
854
|
return;
|
|
855
|
+
// Capture exit correlation BEFORE the mutation deletes the row or strips the
|
|
856
|
+
// spawn/cli metadata, so a durable agent.exited can still be emitted.
|
|
857
|
+
const exited = { agentId: agent.id, agentName: agent.name, invocationId: fleetInvocationId(agent.metadata) };
|
|
651
858
|
await db
|
|
652
859
|
.update(nodes)
|
|
653
860
|
.set({
|
|
@@ -656,28 +863,38 @@ async function applyReleaseCompletionEffect(db, workspaceId, nodeId, invocation,
|
|
|
656
863
|
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
|
|
657
864
|
if (input.delete_agent === true) {
|
|
658
865
|
await db.delete(agents).where(and(eq(agents.workspaceId, workspaceId), eq(agents.id, agent.id)));
|
|
659
|
-
return;
|
|
660
866
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
867
|
+
else {
|
|
868
|
+
const existingMetadata = agent.metadata ?? {};
|
|
869
|
+
const { spawn: _spawn, cli: _cli, ...restMetadata } = existingMetadata;
|
|
870
|
+
await db
|
|
871
|
+
.update(agents)
|
|
872
|
+
.set({
|
|
873
|
+
status: 'offline',
|
|
874
|
+
// Clear the node location so the agent is no longer routable to the released
|
|
875
|
+
// node and a repeat release can't re-decrement the node's active count.
|
|
876
|
+
locationType: 'self_connected',
|
|
877
|
+
locationNodeId: null,
|
|
878
|
+
lastSeen: new Date(),
|
|
879
|
+
metadata: {
|
|
880
|
+
...restMetadata,
|
|
881
|
+
release: {
|
|
882
|
+
reason: typeof input.reason === 'string' ? input.reason : null,
|
|
883
|
+
released_at: new Date().toISOString(),
|
|
884
|
+
},
|
|
677
885
|
},
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
|
|
886
|
+
})
|
|
887
|
+
.where(and(eq(agents.workspaceId, workspaceId), eq(agents.id, agent.id)));
|
|
888
|
+
}
|
|
889
|
+
if (deps) {
|
|
890
|
+
await emitAgentExitedEffects(deps, workspaceId, {
|
|
891
|
+
agentId: exited.agentId,
|
|
892
|
+
agentName: exited.agentName,
|
|
893
|
+
nodeId,
|
|
894
|
+
invocationId: exited.invocationId,
|
|
895
|
+
reason: 'released',
|
|
896
|
+
});
|
|
897
|
+
}
|
|
681
898
|
}
|
|
682
899
|
async function dispatchNodeAttempt(db, workspaceId, invocationId, nodeId, opts) {
|
|
683
900
|
const stateFields = opts.pending
|
|
@@ -840,11 +1057,7 @@ export async function drainNodeInvocations(db, registry, workspaceId, nodeId) {
|
|
|
840
1057
|
// Skip draining to a provider that is disconnected or whose heartbeat says
|
|
841
1058
|
// handlers are not live. A missing provider row is a legacy single-socket
|
|
842
1059
|
// node, for which connection state remains the available liveness signal.
|
|
843
|
-
if (!registry
|
|
844
|
-
continue;
|
|
845
|
-
const provider = await getProvider(db, workspaceId, nodeId, providerName);
|
|
846
|
-
const explicitlyAttached = registry.isProviderAttached?.(workspaceId, nodeId, providerName) ?? true;
|
|
847
|
-
if (provider && explicitlyAttached && !isProviderLive(provider))
|
|
1060
|
+
if (!(await isHandlerConnectionLive(db, registry, workspaceId, nodeId, providerName)))
|
|
848
1061
|
continue;
|
|
849
1062
|
let reservedForDrain = false;
|
|
850
1063
|
try {
|
|
@@ -1112,7 +1325,7 @@ export async function completeInvocation(db, workspaceId, actionName, invocation
|
|
|
1112
1325
|
return null;
|
|
1113
1326
|
return publicInvocation(updated);
|
|
1114
1327
|
}
|
|
1115
|
-
export async function completeNodeInvocation(db, registry, workspaceId, nodeId, providerName, invocationId, data) {
|
|
1328
|
+
export async function completeNodeInvocation(db, registry, workspaceId, nodeId, providerName, invocationId, data, deps) {
|
|
1116
1329
|
const [existing] = await db
|
|
1117
1330
|
.select({
|
|
1118
1331
|
id: actionInvocations.id,
|
|
@@ -1174,12 +1387,65 @@ export async function completeNodeInvocation(db, registry, workspaceId, nodeId,
|
|
|
1174
1387
|
await releaseNodeCapacity(db, workspaceId, updated.dispatchedNodeId);
|
|
1175
1388
|
}
|
|
1176
1389
|
if (updated) {
|
|
1177
|
-
await applyReleaseCompletionEffect(db, workspaceId, nodeId, existing, data);
|
|
1390
|
+
await applyReleaseCompletionEffect(db, workspaceId, nodeId, existing, data, deps);
|
|
1178
1391
|
}
|
|
1179
1392
|
return updated ? publicInvocation(updated) : null;
|
|
1180
1393
|
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Bound the wait on agent-handled invocations: an open invocation whose handler
|
|
1396
|
+
* agent's connection has been CONTINUOUSLY unreachable for the TTL is failed
|
|
1397
|
+
* with `handler_unavailable` — emitting `action.failed` to the caller when
|
|
1398
|
+
* completion deps are provided — instead of staying `pending`/`dispatched`
|
|
1399
|
+
* forever. The first unreachable observation stamps
|
|
1400
|
+
* `handler_unreachable_since`; a recovered connection clears it, so a brief
|
|
1401
|
+
* handler restart never kills an invocation regardless of its age.
|
|
1402
|
+
*/
|
|
1403
|
+
async function failUnreachableAgentInvocations(db, registry, ttlMs, completionDeps) {
|
|
1404
|
+
const rows = await db
|
|
1405
|
+
.select({
|
|
1406
|
+
id: actionInvocations.id,
|
|
1407
|
+
workspaceId: actionInvocations.workspaceId,
|
|
1408
|
+
handlerUnreachableSince: actionInvocations.handlerUnreachableSince,
|
|
1409
|
+
})
|
|
1410
|
+
.from(actionInvocations)
|
|
1411
|
+
.innerJoin(actions, eq(actionInvocations.actionId, actions.id))
|
|
1412
|
+
.where(and(inArray(actionInvocations.status, ['pending', 'dispatched']), isNull(actions.handlerNodeId), isNotNull(actions.handlerAgentId)));
|
|
1413
|
+
for (const row of rows) {
|
|
1414
|
+
try {
|
|
1415
|
+
// targetAgentForInvocation returns null when the handler agent is gone or
|
|
1416
|
+
// no longer node-bound — both count as unreachable.
|
|
1417
|
+
const target = await targetAgentForInvocation(db, row);
|
|
1418
|
+
const reachable = !!target
|
|
1419
|
+
&& await isHandlerConnectionLive(db, registry, row.workspaceId, target.nodeId, target.providerName);
|
|
1420
|
+
if (reachable) {
|
|
1421
|
+
if (row.handlerUnreachableSince) {
|
|
1422
|
+
await db
|
|
1423
|
+
.update(actionInvocations)
|
|
1424
|
+
.set({ handlerUnreachableSince: null })
|
|
1425
|
+
.where(and(eq(actionInvocations.workspaceId, row.workspaceId), eq(actionInvocations.id, row.id)));
|
|
1426
|
+
}
|
|
1427
|
+
continue;
|
|
1428
|
+
}
|
|
1429
|
+
const since = row.handlerUnreachableSince ?? new Date();
|
|
1430
|
+
if (!row.handlerUnreachableSince) {
|
|
1431
|
+
await db
|
|
1432
|
+
.update(actionInvocations)
|
|
1433
|
+
.set({ handlerUnreachableSince: since })
|
|
1434
|
+
.where(and(eq(actionInvocations.workspaceId, row.workspaceId), eq(actionInvocations.id, row.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
|
|
1435
|
+
}
|
|
1436
|
+
if (Date.now() - since.getTime() >= ttlMs) {
|
|
1437
|
+
await failOpenInvocations(db, row.workspaceId, [row.id], 'handler_unavailable', completionDeps);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
// Leave the invocation for the next sweep.
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1181
1445
|
export async function sweepTimedOutInvocations(db, registry, opts = {}) {
|
|
1182
|
-
const
|
|
1446
|
+
const sweepOpts = typeof opts === 'number' || opts === null ? {} : opts;
|
|
1447
|
+
const timeoutMs = typeof opts === 'number' ? opts : sweepOpts.timeoutMs ?? ACTION_DISPATCH_TIMEOUT_MS;
|
|
1448
|
+
await failUnreachableAgentInvocations(db, registry, sweepOpts.handlerUnreachableTtlMs ?? ACTION_HANDLER_UNREACHABLE_TTL_MS, sweepOpts.completionDeps);
|
|
1183
1449
|
const now = new Date();
|
|
1184
1450
|
const cutoff = new Date(Date.now() - timeoutMs);
|
|
1185
1451
|
const rows = await db
|