@masons/runtime-broker 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/broker/broker-daemon.d.ts +1 -0
  2. package/dist/broker/broker-daemon.d.ts.map +1 -1
  3. package/dist/broker/broker-daemon.js +569 -114
  4. package/dist/broker/control-event-dispatcher.d.ts.map +1 -1
  5. package/dist/broker/control-event-dispatcher.js +34 -16
  6. package/dist/broker/control-event-types.d.ts +12 -2
  7. package/dist/broker/control-event-types.d.ts.map +1 -1
  8. package/dist/broker/endpoint-registry.d.ts +7 -0
  9. package/dist/broker/endpoint-registry.d.ts.map +1 -1
  10. package/dist/broker/endpoint-registry.js +3 -0
  11. package/dist/broker/entry.d.ts.map +1 -1
  12. package/dist/broker/entry.js +4 -1
  13. package/dist/broker/ipc-server.d.ts +31 -0
  14. package/dist/broker/ipc-server.d.ts.map +1 -1
  15. package/dist/broker/ipc-server.js +6 -0
  16. package/dist/broker/reconnecting-buffer.d.ts +2 -0
  17. package/dist/broker/reconnecting-buffer.d.ts.map +1 -1
  18. package/dist/broker/reconnecting-buffer.js +3 -0
  19. package/dist/broker/runtime-endpoint-port.d.ts +2 -0
  20. package/dist/broker/runtime-endpoint-port.d.ts.map +1 -1
  21. package/dist/broker/runtime-processing-state-event-types.d.ts +26 -0
  22. package/dist/broker/runtime-processing-state-event-types.d.ts.map +1 -0
  23. package/dist/broker/runtime-processing-state-event-types.js +1 -0
  24. package/dist/broker-client/broker-client.d.ts +30 -0
  25. package/dist/broker-client/broker-client.d.ts.map +1 -1
  26. package/dist/broker-client/broker-client.js +27 -0
  27. package/dist/runtime-endpoint-client.d.ts +11 -1
  28. package/dist/runtime-endpoint-client.d.ts.map +1 -1
  29. package/dist/runtime-endpoint-client.js +33 -0
  30. package/dist/version.d.ts +1 -1
  31. package/dist/version.js +1 -1
  32. package/package.json +12 -13
@@ -22,6 +22,15 @@ import { CONTENT_PREVIEW_MAX_CODEPOINTS, truncateContentPreview, } from "./undis
22
22
  import { createUndispatchedChangedEmitter, postUndispatchedChangedViaPort, } from "./undispatched-emitter.js";
23
23
  import { createUndispatchedInbox, } from "./undispatched-inbox.js";
24
24
  const REMOTE_SPAWN_CAPABILITY = "remote_spawn_v1";
25
+ const DEFAULT_RUNTIME_ASSIGNMENT_ACCEPTANCE_TIMEOUT_MS = 30_000;
26
+ const RUNTIME_PROCESSING_RETRY_INITIAL_MS = 1_000;
27
+ const RUNTIME_PROCESSING_RETRY_MAX_MS = 30_000;
28
+ function isBackgroundExchangeMode(value) {
29
+ return value === "resident_endpoint" || value === "adapter_managed_turn";
30
+ }
31
+ function normalizeBackgroundExchangeMode(value) {
32
+ return isBackgroundExchangeMode(value) ? value : "resident_endpoint";
33
+ }
25
34
  function derivePublicDisplayLabel(body) {
26
35
  const parts = [
27
36
  body.kind,
@@ -39,6 +48,8 @@ export async function startBrokerDaemon(opts) {
39
48
  const { paths, asNodeId: _asNodeIdReservedForA3, connector, apiPort, logger, closedEndpointLookup, livenessProbe = defaultIsPluginAlive, spawnDriverRegistry, spawnFn = spawnChild, servicesEventClientFactory, } = opts;
40
49
  const graceMs = opts.graceMs ?? DEFAULT_GRACE_MS;
41
50
  const presenceGraceMs = opts.presenceGraceMs;
51
+ const runtimeAssignmentAcceptanceTimeoutMs = opts.runtimeAssignmentAcceptanceTimeoutMs ??
52
+ DEFAULT_RUNTIME_ASSIGNMENT_ACCEPTANCE_TIMEOUT_MS;
42
53
  const postControlAck = opts.postControlAck ??
43
54
  (async () => {
44
55
  });
@@ -63,6 +74,8 @@ export async function startBrokerDaemon(opts) {
63
74
  const reconnectingBuffers = createReconnectingBufferManager(paths.buffersDir);
64
75
  const graceTimers = createGraceTimerManager();
65
76
  const controlEventDispatcher = createControlEventDispatcher();
77
+ const runtimeAssignments = new Map();
78
+ const runtimeAssignmentByEndpointCorrelation = new Map();
66
79
  const spawnCorrelation = createSpawnCorrelationManager({
67
80
  timeoutMs: opts.spawnCorrelationTimeoutMs,
68
81
  });
@@ -121,6 +134,71 @@ export async function startBrokerDaemon(opts) {
121
134
  backoffMaxMs: opts.runtimeInboundRoutedBackoffMaxMs,
122
135
  maxRetries: opts.runtimeInboundRoutedMaxRetries,
123
136
  });
137
+ const runtimeAssignmentKey = (sourceMessageId, assignmentId) => `${sourceMessageId}\u0000${assignmentId}`;
138
+ const endpointCorrelationKey = (endpointId, correlationId) => `${endpointId}\u0000${correlationId}`;
139
+ const isBackgroundExchangeTarget = (entry) => Boolean(entry.runtime_capabilities?.includes("background_exchange_v1") &&
140
+ isBackgroundExchangeMode(entry.background_exchange_mode));
141
+ const rememberRuntimeAssignmentCorrelation = (endpointId, correlationId, assignmentKey) => {
142
+ if (typeof correlationId !== "string" || correlationId.length === 0) {
143
+ return;
144
+ }
145
+ runtimeAssignmentByEndpointCorrelation.set(endpointCorrelationKey(endpointId, correlationId), assignmentKey);
146
+ };
147
+ const forgetRuntimeAssignment = (key) => {
148
+ const assignment = runtimeAssignments.get(key);
149
+ if (assignment?.acceptanceTimer) {
150
+ clearTimeout(assignment.acceptanceTimer);
151
+ }
152
+ if (assignment?.replyEmittedRetryTimer) {
153
+ clearTimeout(assignment.replyEmittedRetryTimer);
154
+ }
155
+ runtimeAssignments.delete(key);
156
+ for (const [correlationKey, assignmentKey] of Array.from(runtimeAssignmentByEndpointCorrelation.entries())) {
157
+ if (assignmentKey === key) {
158
+ runtimeAssignmentByEndpointCorrelation.delete(correlationKey);
159
+ }
160
+ }
161
+ };
162
+ const scheduleAcceptanceTimeout = (key) => {
163
+ if (runtimeAssignmentAcceptanceTimeoutMs <= 0)
164
+ return;
165
+ const assignment = runtimeAssignments.get(key);
166
+ if (!assignment || assignment.accepted || assignment.acceptanceTimer) {
167
+ return;
168
+ }
169
+ const timer = setTimeout(() => {
170
+ const current = runtimeAssignments.get(key);
171
+ if (!current || current.accepted)
172
+ return;
173
+ logger.warn("runtime_assignment_acceptance_timeout", {
174
+ undispatched_id: current.context.undispatchedId,
175
+ source_message_id: current.context.sourceMessageId,
176
+ target_endpoint_id: current.context.targetRef.projection_endpoint_id,
177
+ });
178
+ void restorePendingAssignmentsForEndpoint(current.context.targetRef.projection_endpoint_id, "adapter_acceptance_timeout");
179
+ }, runtimeAssignmentAcceptanceTimeoutMs);
180
+ timer.unref?.();
181
+ assignment.acceptanceTimer = timer;
182
+ };
183
+ const emitRuntimeProcessingState = async (event) => {
184
+ if (!apiPort.emitRuntimeProcessingState) {
185
+ return {
186
+ ok: false,
187
+ terminal: true,
188
+ detail: "runtime_processing_state_port_not_configured",
189
+ };
190
+ }
191
+ try {
192
+ return await apiPort.emitRuntimeProcessingState(event);
193
+ }
194
+ catch (err) {
195
+ return {
196
+ ok: false,
197
+ terminal: false,
198
+ detail: err instanceof Error ? err.message : String(err),
199
+ };
200
+ }
201
+ };
124
202
  const directPostUndispatched = async (event, opts = {}) => {
125
203
  let outcome;
126
204
  try {
@@ -195,6 +273,127 @@ export async function startBrokerDaemon(opts) {
195
273
  return;
196
274
  replyCorrelationCache.record(endpoint_id, c);
197
275
  };
276
+ const restorePendingAssignmentsForEndpoint = (endpoint_id, reason) => {
277
+ const tasks = [];
278
+ for (const [key, assignment] of Array.from(runtimeAssignments.entries())) {
279
+ if (assignment.context.targetRef.projection_endpoint_id !== endpoint_id ||
280
+ assignment.accepted) {
281
+ continue;
282
+ }
283
+ if (assignment.acceptanceTimer) {
284
+ clearTimeout(assignment.acceptanceTimer);
285
+ assignment.acceptanceTimer = undefined;
286
+ }
287
+ const readded = undispatchedInbox.tryAdd(assignment.message);
288
+ if (!readded) {
289
+ logger.error("runtime_assignment_reinsert_failed_at_capacity", {
290
+ undispatched_id: assignment.context.undispatchedId,
291
+ source_message_id: assignment.context.sourceMessageId,
292
+ target_endpoint_id: endpoint_id,
293
+ inbox_size: undispatchedInbox.size(),
294
+ inbox_capacity: undispatchedInbox.capacity(),
295
+ });
296
+ scheduleAcceptanceTimeout(key);
297
+ continue;
298
+ }
299
+ const task = emitRuntimeProcessingState({
300
+ type: "runtime_processing_state",
301
+ version: 1,
302
+ source_message_id: assignment.context.sourceMessageId,
303
+ assignment_id: assignment.context.assignmentId,
304
+ state_event_id: `${assignment.context.assignmentId}:target_lost`,
305
+ state_sequence: 2,
306
+ target_ref: assignment.context.targetRef,
307
+ state: "target_lost_before_acceptance",
308
+ occurred_at: assignment.assignedAt + 1,
309
+ undispatched_id: assignment.context.undispatchedId,
310
+ reason_code: reason,
311
+ }).then((outcome) => {
312
+ if (outcome.ok) {
313
+ forgetRuntimeAssignment(key);
314
+ return;
315
+ }
316
+ logger.warn("runtime_assignment_target_lost_emit_failed", {
317
+ undispatched_id: assignment.context.undispatchedId,
318
+ source_message_id: assignment.context.sourceMessageId,
319
+ target_endpoint_id: endpoint_id,
320
+ status: outcome.status,
321
+ terminal: outcome.terminal,
322
+ detail: outcome.detail,
323
+ });
324
+ scheduleAcceptanceTimeout(key);
325
+ });
326
+ tasks.push(task);
327
+ }
328
+ return Promise.allSettled(tasks).then(() => undefined);
329
+ };
330
+ const emitReplyEmittedWithRetry = (assignmentKey, event, attempt = 0) => {
331
+ const assignment = runtimeAssignments.get(assignmentKey);
332
+ if (!assignment)
333
+ return;
334
+ assignment.replyEmittedEvent = event;
335
+ if (assignment.replyEmittedRetryTimer) {
336
+ clearTimeout(assignment.replyEmittedRetryTimer);
337
+ assignment.replyEmittedRetryTimer = undefined;
338
+ }
339
+ void emitRuntimeProcessingState(event).then((outcome) => {
340
+ const current = runtimeAssignments.get(assignmentKey);
341
+ if (!current)
342
+ return;
343
+ if (outcome.ok) {
344
+ forgetRuntimeAssignment(assignmentKey);
345
+ return;
346
+ }
347
+ logger.warn("runtime_reply_emitted_projection_failed", {
348
+ source_message_id: current.context.sourceMessageId,
349
+ assignment_id: current.context.assignmentId,
350
+ status: outcome.status,
351
+ terminal: outcome.terminal,
352
+ detail: outcome.detail,
353
+ });
354
+ if (outcome.terminal)
355
+ return;
356
+ const delay = Math.min(RUNTIME_PROCESSING_RETRY_INITIAL_MS * 2 ** attempt, RUNTIME_PROCESSING_RETRY_MAX_MS);
357
+ const timer = setTimeout(() => {
358
+ emitReplyEmittedWithRetry(assignmentKey, event, attempt + 1);
359
+ }, delay);
360
+ timer.unref?.();
361
+ current.replyEmittedRetryTimer = timer;
362
+ });
363
+ };
364
+ const flushRuntimeAssignmentsOnShutdown = async () => {
365
+ const endpointIds = new Set();
366
+ for (const assignment of runtimeAssignments.values()) {
367
+ endpointIds.add(assignment.context.targetRef.projection_endpoint_id);
368
+ }
369
+ for (const [key, assignment] of Array.from(runtimeAssignments.entries())) {
370
+ if (assignment.replyEmittedEvent) {
371
+ const outcome = await emitRuntimeProcessingState(assignment.replyEmittedEvent);
372
+ if (outcome.ok) {
373
+ forgetRuntimeAssignment(key);
374
+ continue;
375
+ }
376
+ logger.warn("runtime_reply_emitted_shutdown_flush_failed", {
377
+ source_message_id: assignment.context.sourceMessageId,
378
+ assignment_id: assignment.context.assignmentId,
379
+ status: outcome.status,
380
+ terminal: outcome.terminal,
381
+ detail: outcome.detail,
382
+ });
383
+ }
384
+ }
385
+ for (const endpointId of endpointIds) {
386
+ await restorePendingAssignmentsForEndpoint(endpointId, "broker_shutdown");
387
+ }
388
+ if (runtimeAssignments.size > 0) {
389
+ logger.warn("runtime_assignments_dropped_on_shutdown", {
390
+ size: runtimeAssignments.size,
391
+ });
392
+ }
393
+ for (const key of Array.from(runtimeAssignments.keys())) {
394
+ forgetRuntimeAssignment(key);
395
+ }
396
+ };
198
397
  const channels = new Map();
199
398
  let networkPresence = "offline";
200
399
  let presenceGraceTimer;
@@ -357,11 +556,12 @@ export async function startBrokerDaemon(opts) {
357
556
  break;
358
557
  case "drain_buffer": {
359
558
  const buf = reconnectingBuffers.forEndpoint(endpoint_id);
360
- const drained = buf.drain();
559
+ const drained = buf.read();
361
560
  const e = registry.get(endpoint_id);
561
+ let allPushed = true;
362
562
  if (e) {
363
563
  for (const msg of drained) {
364
- pushToPlugin(e.ipc_ws, {
564
+ const pushed = pushToPlugin(e.ipc_ws, {
365
565
  event: "message_received",
366
566
  from: msg.envelope.from,
367
567
  content: msg.envelope.content,
@@ -370,15 +570,32 @@ export async function startBrokerDaemon(opts) {
370
570
  ...(msg.envelope.sourceMessageId !== undefined && {
371
571
  sourceMessageId: msg.envelope.sourceMessageId,
372
572
  }),
573
+ ...(msg.envelope.runtimeAssignment !== undefined && {
574
+ runtimeAssignment: msg.envelope.runtimeAssignment,
575
+ }),
373
576
  });
577
+ if (!pushed) {
578
+ allPushed = false;
579
+ break;
580
+ }
374
581
  }
375
582
  }
583
+ if (allPushed) {
584
+ buf.delete();
585
+ }
586
+ else {
587
+ logger.warn("reconnecting_buffer_drain_failed", {
588
+ endpoint_id,
589
+ buffered_count: drained.length,
590
+ });
591
+ }
376
592
  break;
377
593
  }
378
594
  case "delete_buffer":
379
595
  reconnectingBuffers.cleanup(endpoint_id);
380
596
  break;
381
597
  case "remove_from_registry":
598
+ void restorePendingAssignmentsForEndpoint(endpoint_id, "endpoint_lost");
382
599
  clearHeartbeat(endpoint_id);
383
600
  registry.unregister(endpoint_id);
384
601
  replyCorrelationCache.forgetEndpoint(endpoint_id);
@@ -389,20 +606,49 @@ export async function startBrokerDaemon(opts) {
389
606
  }
390
607
  };
391
608
  controlEventDispatcher.on("dispatch_undispatched", async (event) => {
392
- const taken = undispatchedInbox.take(event.undispatched_id);
609
+ const targetEndpointId = event.target_ref?.projection_endpoint_id ?? event.target_endpoint_id;
610
+ if (!targetEndpointId) {
611
+ return {
612
+ ok: false,
613
+ detail: "dispatch target_ref missing",
614
+ };
615
+ }
616
+ const assignmentKey = runtimeAssignmentKey(event.source_message_id, event.idempotency_key);
617
+ const existingAssignment = runtimeAssignments.get(assignmentKey);
618
+ const taken = undispatchedInbox.take(event.undispatched_id) ??
619
+ (!existingAssignment?.accepted &&
620
+ existingAssignment?.context.undispatchedId === event.undispatched_id &&
621
+ existingAssignment.context.targetRef.projection_endpoint_id ===
622
+ targetEndpointId
623
+ ? existingAssignment.message
624
+ : undefined);
393
625
  if (!taken) {
394
626
  return {
395
627
  ok: false,
396
628
  detail: `unknown undispatched_id: ${event.undispatched_id}`,
397
629
  };
398
630
  }
399
- const target = registry.get(event.target_endpoint_id);
631
+ if (taken.source_message_id === undefined) {
632
+ undispatchedInbox.tryAdd(taken);
633
+ return {
634
+ ok: false,
635
+ detail: "dispatch source_message_id missing from inbox row",
636
+ };
637
+ }
638
+ if (event.source_message_id !== taken.source_message_id) {
639
+ undispatchedInbox.tryAdd(taken);
640
+ return {
641
+ ok: false,
642
+ detail: `source_message_id mismatch: ${event.source_message_id}`,
643
+ };
644
+ }
645
+ const target = registry.get(targetEndpointId);
400
646
  if (!target) {
401
647
  const readded = undispatchedInbox.tryAdd(taken);
402
648
  if (!readded) {
403
649
  logger.error("dispatch_reinsert_failed_at_capacity", {
404
650
  undispatched_id: event.undispatched_id,
405
- target_endpoint_id: event.target_endpoint_id,
651
+ target_endpoint_id: targetEndpointId,
406
652
  inbox_size: undispatchedInbox.size(),
407
653
  inbox_capacity: undispatchedInbox.capacity(),
408
654
  });
@@ -419,9 +665,74 @@ export async function startBrokerDaemon(opts) {
419
665
  }
420
666
  return {
421
667
  ok: false,
422
- detail: `target endpoint not found: ${event.target_endpoint_id}`,
668
+ detail: `target endpoint not found: ${targetEndpointId}`,
423
669
  };
424
670
  }
671
+ const targetRef = event.target_ref ?? {
672
+ kind: "projection_endpoint",
673
+ projection_endpoint_id: targetEndpointId,
674
+ background_exchange_mode: normalizeBackgroundExchangeMode(target.background_exchange_mode),
675
+ };
676
+ if (!target.runtime_capabilities?.includes("background_exchange_v1") ||
677
+ target.background_exchange_mode !== targetRef.background_exchange_mode) {
678
+ undispatchedInbox.tryAdd(taken);
679
+ return {
680
+ ok: false,
681
+ detail: `target endpoint is not background-exchange capable: ${targetEndpointId}`,
682
+ };
683
+ }
684
+ const assignedAt = event.emitted_at;
685
+ const processingEvent = {
686
+ type: "runtime_processing_state",
687
+ version: 1,
688
+ source_message_id: event.source_message_id,
689
+ assignment_id: event.idempotency_key,
690
+ state_event_id: `${event.idempotency_key}:assigned`,
691
+ state_sequence: 1,
692
+ target_ref: targetRef,
693
+ state: "assigned_to_runtime_target",
694
+ occurred_at: assignedAt,
695
+ undispatched_id: event.undispatched_id,
696
+ };
697
+ const processingResult = await emitRuntimeProcessingState(processingEvent);
698
+ if (!processingResult.ok) {
699
+ undispatchedInbox.tryAdd(taken);
700
+ logger.warn("runtime_processing_state_emit_failed", {
701
+ undispatched_id: event.undispatched_id,
702
+ source_message_id: event.source_message_id,
703
+ target_endpoint_id: targetEndpointId,
704
+ status: processingResult.status,
705
+ terminal: processingResult.terminal,
706
+ detail: processingResult.detail,
707
+ });
708
+ return {
709
+ ok: false,
710
+ detail: processingResult.detail ??
711
+ "runtime_processing_state_assignment_failed",
712
+ };
713
+ }
714
+ const runtimeAssignment = {
715
+ undispatchedId: event.undispatched_id,
716
+ sourceMessageId: event.source_message_id,
717
+ assignmentId: event.idempotency_key,
718
+ targetRef,
719
+ deliveryIntent: "external_handoff",
720
+ ...(typeof taken.metadata.correlation_id === "string" && {
721
+ replyCorrelationId: taken.metadata.correlation_id,
722
+ }),
723
+ };
724
+ const storedAssignment = existingAssignment ?? {
725
+ context: runtimeAssignment,
726
+ message: taken,
727
+ accepted: false,
728
+ assignedAt,
729
+ };
730
+ storedAssignment.context = runtimeAssignment;
731
+ storedAssignment.message = taken;
732
+ storedAssignment.assignedAt = assignedAt;
733
+ runtimeAssignments.set(assignmentKey, storedAssignment);
734
+ scheduleAcceptanceTimeout(assignmentKey);
735
+ rememberRuntimeAssignmentCorrelation(target.endpoint_id, taken.metadata.correlation_id, assignmentKey);
425
736
  const stamped = {
426
737
  event: "message_received",
427
738
  from: taken.sender_address,
@@ -430,47 +741,50 @@ export async function startBrokerDaemon(opts) {
430
741
  metadata: {
431
742
  ...taken.metadata,
432
743
  dispatched_from: event.undispatched_id,
433
- dispatched_by: "passport",
744
+ delivery_intent: "external_handoff",
434
745
  },
435
- ...(taken.source_message_id !== undefined && {
436
- sourceMessageId: taken.source_message_id,
437
- }),
746
+ sourceMessageId: taken.source_message_id,
747
+ runtimeAssignment,
438
748
  };
439
749
  recordInboundCorrelation(target.endpoint_id, stamped.metadata);
440
750
  if (target.state === "active") {
441
- pushToPlugin(target.ipc_ws, stamped);
751
+ if (!pushToPlugin(target.ipc_ws, stamped)) {
752
+ await restorePendingAssignmentsForEndpoint(target.endpoint_id, "ipc_push_failed");
753
+ return {
754
+ ok: false,
755
+ detail: `target endpoint not writable: ${targetEndpointId}`,
756
+ };
757
+ }
442
758
  }
443
759
  else {
444
- const buf = reconnectingBuffers.forEndpoint(target.endpoint_id);
445
- buf.append({
446
- id: randomUUID(),
447
- arrived_at: new Date().toISOString(),
448
- envelope: {
449
- from: taken.sender_address,
450
- content: taken.content,
451
- contentType: taken.content_type,
452
- metadata: stamped.metadata,
453
- ...(stamped.sourceMessageId !== undefined && {
760
+ try {
761
+ const buf = reconnectingBuffers.forEndpoint(target.endpoint_id);
762
+ buf.append({
763
+ id: randomUUID(),
764
+ arrived_at: new Date().toISOString(),
765
+ envelope: {
766
+ from: taken.sender_address,
767
+ content: taken.content,
768
+ contentType: taken.content_type,
769
+ metadata: stamped.metadata,
454
770
  sourceMessageId: stamped.sourceMessageId,
455
- }),
456
- },
457
- });
771
+ runtimeAssignment,
772
+ },
773
+ });
774
+ }
775
+ catch (err) {
776
+ await restorePendingAssignmentsForEndpoint(target.endpoint_id, "reconnecting_buffer_failed");
777
+ return {
778
+ ok: false,
779
+ detail: err instanceof Error ? err.message : String(err),
780
+ };
781
+ }
458
782
  }
459
783
  logger.info("undispatched_dispatched", {
460
784
  undispatched_id: event.undispatched_id,
461
- target_endpoint_id: event.target_endpoint_id,
785
+ target_endpoint_id: targetEndpointId,
462
786
  target_state: target.state,
463
787
  });
464
- emitUndispatched({
465
- type: "undispatched_changed",
466
- version: 1,
467
- action: "dispatch",
468
- undispatched_id: event.undispatched_id,
469
- ...(taken.source_message_id !== undefined && {
470
- source_message_id: taken.source_message_id,
471
- }),
472
- dispatched_to_endpoint_id: event.target_endpoint_id,
473
- });
474
788
  return { ok: true };
475
789
  });
476
790
  controlEventDispatcher.on("spawn_request", async (event) => {
@@ -562,8 +876,12 @@ export async function startBrokerDaemon(opts) {
562
876
  const spawnAvailability = spawnDriver
563
877
  ? await spawnDriver.isAvailable().catch(() => ({ available: false }))
564
878
  : null;
565
- const runtimeCapabilities = spawnAvailability?.available
566
- ? [REMOTE_SPAWN_CAPABILITY]
879
+ const runtimeCapabilitiesSet = new Set(body.runtime_capabilities?.filter((capability) => capability !== REMOTE_SPAWN_CAPABILITY) ?? []);
880
+ if (spawnAvailability?.available) {
881
+ runtimeCapabilitiesSet.add(REMOTE_SPAWN_CAPABILITY);
882
+ }
883
+ const runtimeCapabilities = runtimeCapabilitiesSet.size > 0
884
+ ? Array.from(runtimeCapabilitiesSet)
567
885
  : undefined;
568
886
  const apiResp = await apiPort.register({
569
887
  runtime_kind: body.kind,
@@ -576,12 +894,17 @@ export async function startBrokerDaemon(opts) {
576
894
  session_name: body.session_name,
577
895
  task_hint: body.task_hint,
578
896
  runtime_capabilities: runtimeCapabilities,
897
+ execution_surface: body.execution_surface,
898
+ background_exchange_mode: body.background_exchange_mode,
579
899
  });
580
900
  registry.register({
581
901
  endpoint_id: apiResp.endpoint_id,
582
902
  agent_id: body.agent_id,
583
903
  plugin_pid: body.plugin_pid,
584
904
  ipc_ws: boundWs,
905
+ runtime_capabilities: runtimeCapabilities,
906
+ execution_surface: body.execution_surface,
907
+ background_exchange_mode: body.background_exchange_mode,
585
908
  display_metadata: {
586
909
  session_name: body.session_name,
587
910
  kind: body.kind,
@@ -698,8 +1021,86 @@ export async function startBrokerDaemon(opts) {
698
1021
  }
699
1022
  throw err;
700
1023
  }
1024
+ if (inReplyTo) {
1025
+ const assignmentKey = runtimeAssignmentByEndpointCorrelation.get(endpointCorrelationKey(body.endpoint_id, inReplyTo));
1026
+ const assignment = assignmentKey !== undefined
1027
+ ? runtimeAssignments.get(assignmentKey)
1028
+ : undefined;
1029
+ if (assignmentKey !== undefined && assignment?.accepted) {
1030
+ emitReplyEmittedWithRetry(assignmentKey, {
1031
+ type: "runtime_processing_state",
1032
+ version: 1,
1033
+ source_message_id: assignment.context.sourceMessageId,
1034
+ assignment_id: assignment.context.assignmentId,
1035
+ state_event_id: `${assignment.context.assignmentId}:reply:${ack.messageId}`,
1036
+ state_sequence: Number.MAX_SAFE_INTEGER,
1037
+ target_ref: assignment.context.targetRef,
1038
+ state: "reply_emitted",
1039
+ occurred_at: Date.now(),
1040
+ undispatched_id: assignment.context.undispatchedId,
1041
+ outbound_message_id: ack.messageId,
1042
+ });
1043
+ }
1044
+ }
701
1045
  return { messageId: ack.messageId, status: ack.status };
702
1046
  },
1047
+ async reportProcessingState(body) {
1048
+ const entry = registry.get(body.endpoint_id);
1049
+ if (!entry) {
1050
+ throw new BrokerHttpError(404, "unknown_endpoint", `unknown endpoint_id: ${body.endpoint_id}`);
1051
+ }
1052
+ if (entry.plugin_pid !== body.plugin_pid) {
1053
+ throw new BrokerHttpError(403, "endpoint_pid_mismatch", "plugin_pid does not own endpoint_id");
1054
+ }
1055
+ const key = runtimeAssignmentKey(body.source_message_id, body.assignment_id);
1056
+ const assignment = runtimeAssignments.get(key);
1057
+ if (!assignment) {
1058
+ throw new BrokerHttpError(409, "runtime_assignment_unknown", "source assignment is not known to this broker");
1059
+ }
1060
+ if (assignment.context.targetRef.projection_endpoint_id !== body.endpoint_id) {
1061
+ throw new BrokerHttpError(403, "runtime_assignment_target_mismatch", "endpoint_id does not match the assigned target");
1062
+ }
1063
+ if (body.state === "reply_emitted") {
1064
+ throw new BrokerHttpError(403, "reply_emitted_broker_owned", "reply_emitted is emitted by the broker after outbound wire send");
1065
+ }
1066
+ const event = {
1067
+ type: "runtime_processing_state",
1068
+ version: 1,
1069
+ source_message_id: body.source_message_id,
1070
+ assignment_id: body.assignment_id,
1071
+ state_event_id: body.state_event_id,
1072
+ state_sequence: body.state_sequence,
1073
+ target_ref: assignment.context.targetRef,
1074
+ state: body.state,
1075
+ occurred_at: body.occurred_at ?? Date.now(),
1076
+ undispatched_id: body.undispatched_id ?? assignment.context.undispatchedId,
1077
+ ...(body.retryable !== undefined && { retryable: body.retryable }),
1078
+ ...(body.reason_code !== undefined && {
1079
+ reason_code: body.reason_code,
1080
+ }),
1081
+ ...(body.detail !== undefined && { detail: body.detail }),
1082
+ ...(body.reply_request_id !== undefined && {
1083
+ reply_request_id: body.reply_request_id,
1084
+ }),
1085
+ ...(body.outbound_message_id !== undefined && {
1086
+ outbound_message_id: body.outbound_message_id,
1087
+ }),
1088
+ };
1089
+ const outcome = await emitRuntimeProcessingState(event);
1090
+ if (!outcome.ok) {
1091
+ throw new BrokerHttpError(outcome.terminal ? 409 : 503, "runtime_processing_state_rejected", outcome.detail ?? "runtime processing state was not accepted");
1092
+ }
1093
+ if (body.state === "adapter_accepted") {
1094
+ assignment.accepted = true;
1095
+ if (assignment.acceptanceTimer) {
1096
+ clearTimeout(assignment.acceptanceTimer);
1097
+ assignment.acceptanceTimer = undefined;
1098
+ }
1099
+ }
1100
+ if (body.state === "processing_failed" || body.state === "expired") {
1101
+ forgetRuntimeAssignment(key);
1102
+ }
1103
+ },
703
1104
  async reattachEndpoint(endpoint_id, plugin_pid, _ipcWsHint) {
704
1105
  const ipcWs = channels.get(plugin_pid);
705
1106
  if (!ipcWs) {
@@ -722,11 +1123,12 @@ export async function startBrokerDaemon(opts) {
722
1123
  }
723
1124
  bound.add(endpoint_id);
724
1125
  const buf = reconnectingBuffers.forEndpoint(endpoint_id);
725
- const drained = buf.drain();
1126
+ const drained = buf.read();
726
1127
  graceTimers.cancel(endpoint_id);
727
1128
  registry.markActive(endpoint_id, ipcWs);
1129
+ let allPushed = true;
728
1130
  for (const msg of drained) {
729
- pushToPlugin(ipcWs, {
1131
+ const pushed = pushToPlugin(ipcWs, {
730
1132
  event: "message_received",
731
1133
  from: msg.envelope.from,
732
1134
  content: msg.envelope.content,
@@ -735,8 +1137,23 @@ export async function startBrokerDaemon(opts) {
735
1137
  ...(msg.envelope.sourceMessageId !== undefined && {
736
1138
  sourceMessageId: msg.envelope.sourceMessageId,
737
1139
  }),
1140
+ ...(msg.envelope.runtimeAssignment !== undefined && {
1141
+ runtimeAssignment: msg.envelope.runtimeAssignment,
1142
+ }),
1143
+ });
1144
+ if (!pushed) {
1145
+ allPushed = false;
1146
+ break;
1147
+ }
1148
+ }
1149
+ if (!allPushed) {
1150
+ handleTransition(endpoint_id, {
1151
+ type: "ipc_close",
1152
+ plugin_alive: livenessProbe(plugin_pid),
738
1153
  });
1154
+ throw new BrokerHttpError(503, "reconnecting_buffer_drain_failed", "failed to push buffered messages to reattached endpoint", { retryable: true });
739
1155
  }
1156
+ buf.delete();
740
1157
  emitTransition(endpoint_id, "active", "reattach");
741
1158
  logger.info("endpoint_reattached", {
742
1159
  endpoint_id,
@@ -749,13 +1166,21 @@ export async function startBrokerDaemon(opts) {
749
1166
  return undispatchedInbox.list();
750
1167
  },
751
1168
  async dispatch(undispatched_id, target_endpoint_id) {
1169
+ const pending = undispatchedInbox.get(undispatched_id);
1170
+ const target = registry.get(target_endpoint_id);
752
1171
  const result = await controlEventDispatcher.dispatch({
753
1172
  type: "dispatch_undispatched",
754
1173
  version: 1,
755
1174
  idempotency_key: `local-${randomUUID()}`,
756
1175
  emitted_at: Date.now(),
757
1176
  undispatched_id,
758
- target_endpoint_id,
1177
+ source_message_id: pending?.source_message_id ?? undispatched_id,
1178
+ target_ref: {
1179
+ kind: "projection_endpoint",
1180
+ projection_endpoint_id: target_endpoint_id,
1181
+ background_exchange_mode: normalizeBackgroundExchangeMode(target?.background_exchange_mode),
1182
+ },
1183
+ delivery_intent: "external_handoff",
759
1184
  });
760
1185
  if (!result.ok) {
761
1186
  throw new BrokerHttpError(400, "dispatch_failed", result.detail ?? "unknown");
@@ -841,21 +1266,71 @@ export async function startBrokerDaemon(opts) {
841
1266
  ? meta.target_endpoint_id
842
1267
  : undefined;
843
1268
  const correlation_id = typeof meta.correlation_id === "string" ? meta.correlation_id : undefined;
1269
+ const addUndispatchedFromPayload = async (opts) => {
1270
+ const undispatched = {
1271
+ id: payload.messageId ?? randomUUID(),
1272
+ ...(payload.messageId !== undefined && {
1273
+ source_message_id: payload.messageId,
1274
+ }),
1275
+ arrived_at: new Date().toISOString(),
1276
+ sender_address: payload.from,
1277
+ content: payload.content,
1278
+ content_type: payload.contentType,
1279
+ metadata: meta,
1280
+ reason: opts.reason,
1281
+ original_target_endpoint_id: opts.originalTargetEndpointId,
1282
+ original_correlation_id: opts.originalCorrelationId,
1283
+ };
1284
+ const added = undispatchedInbox.tryAdd(undispatched);
1285
+ if (!added) {
1286
+ logger.warn("undispatched_inbox_at_capacity", {
1287
+ from: payload.from,
1288
+ size: undispatchedInbox.size(),
1289
+ capacity: undispatchedInbox.capacity(),
1290
+ });
1291
+ return false;
1292
+ }
1293
+ logger.info("undispatched_added", {
1294
+ id: undispatched.id,
1295
+ reason: opts.reason,
1296
+ });
1297
+ const wireReason = undispatched.reason === "unaddressed"
1298
+ ? "no_target_endpoint"
1299
+ : undispatched.reason;
1300
+ const posted = await directPostUndispatched({
1301
+ type: "undispatched_changed",
1302
+ version: 1,
1303
+ action: "add",
1304
+ undispatched_id: undispatched.id,
1305
+ ...(undispatched.source_message_id !== undefined && {
1306
+ source_message_id: undispatched.source_message_id,
1307
+ }),
1308
+ sender_address: undispatched.sender_address,
1309
+ content_preview: truncateContentPreview(undispatched.content, CONTENT_PREVIEW_MAX_CODEPOINTS),
1310
+ reason: wireReason,
1311
+ ...(opts.originalTargetEndpointId !== undefined && {
1312
+ target_endpoint_id_hint: opts.originalTargetEndpointId,
1313
+ }),
1314
+ ...(typeof correlation_id === "string" && {
1315
+ in_reply_to: correlation_id,
1316
+ }),
1317
+ arrived_at: Date.parse(undispatched.arrived_at),
1318
+ }, { queueOnTransientFailure: typeof payload.seq !== "number" });
1319
+ if (!posted)
1320
+ return false;
1321
+ return markDeliverySeqAccepted(payload.seq);
1322
+ };
844
1323
  const decision = router.route({ target_endpoint_id, correlation_id });
845
1324
  if (decision.kind === "endpoint") {
846
- if (payload.messageId !== undefined) {
847
- const posted = await directPostRuntimeInboundRouted({
848
- type: "runtime_inbound_routed",
849
- version: 1,
850
- source_message_id: payload.messageId,
851
- routed_to_endpoint_id: decision.entry.endpoint_id,
852
- route_kind: decision.entry.state === "active"
853
- ? "active_endpoint"
854
- : "reconnecting_endpoint",
855
- routed_at: Date.now(),
856
- }, { queueOnTransientFailure: typeof payload.seq !== "number" });
857
- if (!posted && typeof payload.seq === "number")
858
- return false;
1325
+ const isCorrelationContinuation = typeof correlation_id === "string" &&
1326
+ decision.entry.correlation_ring.has(correlation_id);
1327
+ if (isBackgroundExchangeTarget(decision.entry) &&
1328
+ !isCorrelationContinuation) {
1329
+ return addUndispatchedFromPayload({
1330
+ reason: "unaddressed",
1331
+ originalTargetEndpointId: decision.entry.endpoint_id,
1332
+ originalCorrelationId: correlation_id,
1333
+ });
859
1334
  }
860
1335
  recordInboundCorrelation(decision.entry.endpoint_id, meta);
861
1336
  if (decision.entry.state === "active") {
@@ -874,24 +1349,47 @@ export async function startBrokerDaemon(opts) {
874
1349
  }
875
1350
  else {
876
1351
  const buf = reconnectingBuffers.forEndpoint(decision.entry.endpoint_id);
877
- buf.append({
878
- id: payload.messageId ?? randomUUID(),
879
- arrived_at: new Date().toISOString(),
880
- envelope: {
881
- from: payload.from,
882
- content: payload.content,
883
- contentType: payload.contentType,
884
- metadata: meta,
885
- ...(payload.messageId !== undefined && {
886
- sourceMessageId: payload.messageId,
887
- }),
888
- },
889
- });
1352
+ try {
1353
+ buf.append({
1354
+ id: payload.messageId ?? randomUUID(),
1355
+ arrived_at: new Date().toISOString(),
1356
+ envelope: {
1357
+ from: payload.from,
1358
+ content: payload.content,
1359
+ contentType: payload.contentType,
1360
+ metadata: meta,
1361
+ ...(payload.messageId !== undefined && {
1362
+ sourceMessageId: payload.messageId,
1363
+ }),
1364
+ },
1365
+ });
1366
+ }
1367
+ catch (err) {
1368
+ logger.warn("buffer_for_reconnecting_endpoint_failed", {
1369
+ endpoint_id: decision.entry.endpoint_id,
1370
+ err: err instanceof Error ? err.message : String(err),
1371
+ });
1372
+ return false;
1373
+ }
890
1374
  logger.info("buffered_for_reconnecting_endpoint", {
891
1375
  endpoint_id: decision.entry.endpoint_id,
892
1376
  buffer_size: buf.size(),
893
1377
  });
894
1378
  }
1379
+ if (payload.messageId !== undefined) {
1380
+ const posted = await directPostRuntimeInboundRouted({
1381
+ type: "runtime_inbound_routed",
1382
+ version: 1,
1383
+ source_message_id: payload.messageId,
1384
+ routed_to_endpoint_id: decision.entry.endpoint_id,
1385
+ route_kind: decision.entry.state === "active"
1386
+ ? "active_endpoint"
1387
+ : "reconnecting_endpoint",
1388
+ routed_at: Date.now(),
1389
+ }, { queueOnTransientFailure: typeof payload.seq !== "number" });
1390
+ if (!posted && typeof payload.seq === "number")
1391
+ return false;
1392
+ }
895
1393
  return markDeliverySeqAccepted(payload.seq);
896
1394
  }
897
1395
  let reason = "unaddressed";
@@ -911,55 +1409,11 @@ export async function startBrokerDaemon(opts) {
911
1409
  });
912
1410
  }
913
1411
  }
914
- const undispatched = {
915
- id: payload.messageId ?? randomUUID(),
916
- ...(payload.messageId !== undefined && {
917
- source_message_id: payload.messageId,
918
- }),
919
- arrived_at: new Date().toISOString(),
920
- sender_address: payload.from,
921
- content: payload.content,
922
- content_type: payload.contentType,
923
- metadata: meta,
1412
+ return addUndispatchedFromPayload({
924
1413
  reason,
925
- original_target_endpoint_id: originalTarget,
926
- original_correlation_id: correlation_id,
927
- };
928
- const added = undispatchedInbox.tryAdd(undispatched);
929
- if (!added) {
930
- logger.warn("undispatched_inbox_at_capacity", {
931
- from: payload.from,
932
- size: undispatchedInbox.size(),
933
- capacity: undispatchedInbox.capacity(),
934
- });
935
- return false;
936
- }
937
- logger.info("undispatched_added", { id: undispatched.id, reason });
938
- const wireReason = undispatched.reason === "unaddressed"
939
- ? "no_target_endpoint"
940
- : undispatched.reason;
941
- const posted = await directPostUndispatched({
942
- type: "undispatched_changed",
943
- version: 1,
944
- action: "add",
945
- undispatched_id: undispatched.id,
946
- ...(undispatched.source_message_id !== undefined && {
947
- source_message_id: undispatched.source_message_id,
948
- }),
949
- sender_address: undispatched.sender_address,
950
- content_preview: truncateContentPreview(undispatched.content, CONTENT_PREVIEW_MAX_CODEPOINTS),
951
- reason: wireReason,
952
- ...(undispatched.original_target_endpoint_id !== undefined && {
953
- target_endpoint_id_hint: undispatched.original_target_endpoint_id,
954
- }),
955
- ...(typeof correlation_id === "string" && {
956
- in_reply_to: correlation_id,
957
- }),
958
- arrived_at: Date.parse(undispatched.arrived_at),
959
- }, { queueOnTransientFailure: typeof payload.seq !== "number" });
960
- if (!posted)
961
- return false;
962
- return markDeliverySeqAccepted(payload.seq);
1414
+ originalTargetEndpointId: originalTarget,
1415
+ originalCorrelationId: correlation_id,
1416
+ });
963
1417
  };
964
1418
  connector.on("message_received", ({ payload }) => {
965
1419
  let task;
@@ -1081,6 +1535,7 @@ export async function startBrokerDaemon(opts) {
1081
1535
  });
1082
1536
  }
1083
1537
  await runtimeInboundRoutedEmitter.shutdown();
1538
+ await flushRuntimeAssignmentsOnShutdown();
1084
1539
  const endpoints = registry.list();
1085
1540
  await Promise.all(endpoints.map(async (entry) => {
1086
1541
  try {