@relaycast/engine 6.1.0 → 6.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.
Files changed (62) hide show
  1. package/dist/adapters/node/delivery-maintenance.d.ts +1 -1
  2. package/dist/adapters/node/delivery-maintenance.d.ts.map +1 -1
  3. package/dist/adapters/node/delivery-maintenance.js +4 -4
  4. package/dist/adapters/node/delivery-maintenance.js.map +1 -1
  5. package/dist/adapters/node/index.js +2 -2
  6. package/dist/adapters/node/index.js.map +1 -1
  7. package/dist/adapters/node/realtime.d.ts.map +1 -1
  8. package/dist/adapters/node/realtime.js +45 -3
  9. package/dist/adapters/node/realtime.js.map +1 -1
  10. package/dist/agent-disconnect.d.ts +23 -1
  11. package/dist/agent-disconnect.d.ts.map +1 -1
  12. package/dist/agent-disconnect.js +20 -2
  13. package/dist/agent-disconnect.js.map +1 -1
  14. package/dist/db/migrations/0032_invocation_handler_unreachable.sql +5 -0
  15. package/dist/db/schema.d.ts +17 -0
  16. package/dist/db/schema.d.ts.map +1 -1
  17. package/dist/db/schema.js +4 -0
  18. package/dist/db/schema.js.map +1 -1
  19. package/dist/engine/action.d.ts +34 -13
  20. package/dist/engine/action.d.ts.map +1 -1
  21. package/dist/engine/action.js +323 -57
  22. package/dist/engine/action.js.map +1 -1
  23. package/dist/engine/delivery.d.ts +21 -1
  24. package/dist/engine/delivery.d.ts.map +1 -1
  25. package/dist/engine/delivery.js +79 -9
  26. package/dist/engine/delivery.js.map +1 -1
  27. package/dist/engine/invocationCompletion.d.ts +43 -0
  28. package/dist/engine/invocationCompletion.d.ts.map +1 -1
  29. package/dist/engine/invocationCompletion.js +108 -4
  30. package/dist/engine/invocationCompletion.js.map +1 -1
  31. package/dist/engine/node.d.ts +72 -6
  32. package/dist/engine/node.d.ts.map +1 -1
  33. package/dist/engine/node.js +152 -23
  34. package/dist/engine/node.js.map +1 -1
  35. package/dist/engine/nodeDeliver.d.ts.map +1 -1
  36. package/dist/engine/nodeDeliver.js +5 -0
  37. package/dist/engine/nodeDeliver.js.map +1 -1
  38. package/dist/engine/observerToken.js +1 -1
  39. package/dist/engine/observerToken.js.map +1 -1
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +7 -5
  43. package/dist/index.js.map +1 -1
  44. package/dist/node-invocations.d.ts +1 -1
  45. package/dist/node-invocations.d.ts.map +1 -1
  46. package/dist/node-invocations.js +1 -1
  47. package/dist/node-invocations.js.map +1 -1
  48. package/dist/ports/realtime.d.ts +22 -1
  49. package/dist/ports/realtime.d.ts.map +1 -1
  50. package/dist/ports/realtime.js.map +1 -1
  51. package/dist/routes/action.js +11 -5
  52. package/dist/routes/action.js.map +1 -1
  53. package/dist/routes/deliveryRouting.d.ts +20 -1
  54. package/dist/routes/deliveryRouting.d.ts.map +1 -1
  55. package/dist/routes/deliveryRouting.js +114 -17
  56. package/dist/routes/deliveryRouting.js.map +1 -1
  57. package/dist/routes/node.js +3 -2
  58. package/dist/routes/node.js.map +1 -1
  59. package/dist/routes/presence.d.ts.map +1 -1
  60. package/dist/routes/presence.js +15 -4
  61. package/dist/routes/presence.js.map +1 -1
  62. package/package.json +3 -3
@@ -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
- const [action] = await db
159
- .insert(actions)
160
- .values({
161
- id,
162
- workspaceId,
163
- name: data.name,
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
- .returning();
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
- id: action.id,
174
- name: action.name,
175
- description: action.description,
176
- handler_agent: handlerAgentName,
177
- handler_node: handlerNodeName,
178
- handler_node_id: handlerNodeId,
179
- input_schema: action.inputSchema,
180
- output_schema: action.outputSchema,
181
- available_to: action.availableTo ?? null,
182
- is_active: action.isActive,
183
- created_at: action.createdAt.toISOString(),
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
- const result = await db
234
- .delete(actions)
235
- .where(and(eq(actions.workspaceId, workspaceId), eq(actions.name, name)))
236
- .returning();
237
- return result.length > 0;
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
- const existingMetadata = agent.metadata ?? {};
662
- const { spawn: _spawn, cli: _cli, ...restMetadata } = existingMetadata;
663
- await db
664
- .update(agents)
665
- .set({
666
- status: 'offline',
667
- // Clear the node location so the agent is no longer routable to the released
668
- // node and a repeat release can't re-decrement the node's active count.
669
- locationType: 'self_connected',
670
- locationNodeId: null,
671
- lastSeen: new Date(),
672
- metadata: {
673
- ...restMetadata,
674
- release: {
675
- reason: typeof input.reason === 'string' ? input.reason : null,
676
- released_at: new Date().toISOString(),
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
- .where(and(eq(agents.workspaceId, workspaceId), eq(agents.id, agent.id)));
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.isProviderConnected(workspaceId, nodeId, providerName))
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 timeoutMs = typeof opts === 'number' ? opts : opts?.timeoutMs ?? ACTION_DISPATCH_TIMEOUT_MS;
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