@bastani/atomic 0.9.45 → 0.9.46

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 (214) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/dist/builtin/intercom/CHANGELOG.md +22 -0
  3. package/dist/builtin/intercom/broker/broker.ts +124 -10
  4. package/dist/builtin/intercom/broker/client.ts +177 -25
  5. package/dist/builtin/intercom/broker/multinode-router.ts +228 -27
  6. package/dist/builtin/intercom/broker/multinode-state-store.ts +10 -0
  7. package/dist/builtin/intercom/broker/recipient-receipts.ts +38 -4
  8. package/dist/builtin/intercom/broker/send-handler.ts +9 -2
  9. package/dist/builtin/intercom/broker/sql-outbox-store.ts +29 -4
  10. package/dist/builtin/intercom/group.ts +25 -0
  11. package/dist/builtin/intercom/index.bundle.mjs +177 -35
  12. package/dist/builtin/intercom/package.json +1 -1
  13. package/dist/builtin/intercom/retry-policy.ts +7 -0
  14. package/dist/builtin/intercom/types.ts +21 -0
  15. package/dist/builtin/mcp/package.json +1 -1
  16. package/dist/builtin/subagents/CHANGELOG.md +21 -0
  17. package/dist/builtin/subagents/package.json +1 -1
  18. package/dist/builtin/subagents/src/extension/index.bundle.mjs +152 -56
  19. package/dist/builtin/web-access/package.json +1 -1
  20. package/dist/builtin/workflows/CHANGELOG.md +79 -0
  21. package/dist/builtin/workflows/builtin/{chunk-6901nsd6.js → chunk-6jpwc09k.js} +1 -1
  22. package/dist/builtin/workflows/builtin/{chunk-vwx023ge.js → chunk-qhm35ntx.js} +1 -1
  23. package/dist/builtin/workflows/builtin/{chunk-jvna28mq.js → chunk-zhyqnf5g.js} +331 -68
  24. package/dist/builtin/workflows/builtin/goal.js +2 -2
  25. package/dist/builtin/workflows/builtin/index.js +3 -3
  26. package/dist/builtin/workflows/builtin/ralph.js +2 -2
  27. package/dist/builtin/workflows/package.json +1 -1
  28. package/dist/builtin/workflows/skills/prompt-engineer/SKILL.md +1 -0
  29. package/dist/builtin/workflows/skills/prompt-engineer/references/gpt_6.md +2 -2
  30. package/dist/builtin/workflows/skills/prompt-engineer/references/gpt_6_1_sol.md +2 -2
  31. package/dist/builtin/workflows/skills/prompt-engineer/references/gpt_6_1_terra.md +77 -0
  32. package/dist/builtin/workflows/src/authoring.d.ts +1 -1
  33. package/dist/builtin/workflows/src/durable/journal/internal.d.ts +2 -0
  34. package/dist/builtin/workflows/src/extension/index.bundle.mjs +7104 -3387
  35. package/dist/builtin/workflows/src/extension/local-worker-entry.mjs +11105 -7264
  36. package/dist/builtin/workflows/src/index.js +6796 -3606
  37. package/dist/builtin/workflows/src/local-worker-entry.mjs +11105 -7264
  38. package/dist/builtin/workflows/src/shared/authoring-contract-ui.d.ts +19 -0
  39. package/dist/core/agent-session-boundaries.d.ts.map +1 -1
  40. package/dist/core/agent-session-boundaries.js +1 -0
  41. package/dist/core/agent-session-boundaries.js.map +1 -1
  42. package/dist/core/agent-session-events.d.ts.map +1 -1
  43. package/dist/core/agent-session-events.js +2 -1
  44. package/dist/core/agent-session-events.js.map +1 -1
  45. package/dist/core/agent-session-extension-bindings.d.ts.map +1 -1
  46. package/dist/core/agent-session-extension-bindings.js +15 -2
  47. package/dist/core/agent-session-extension-bindings.js.map +1 -1
  48. package/dist/core/agent-session-methods.d.ts +3 -2
  49. package/dist/core/agent-session-methods.d.ts.map +1 -1
  50. package/dist/core/agent-session-methods.js.map +1 -1
  51. package/dist/core/agent-session-prompt.d.ts +4 -2
  52. package/dist/core/agent-session-prompt.d.ts.map +1 -1
  53. package/dist/core/agent-session-prompt.js +13 -8
  54. package/dist/core/agent-session-prompt.js.map +1 -1
  55. package/dist/core/agent-session-recovery.d.ts.map +1 -1
  56. package/dist/core/agent-session-recovery.js +92 -1
  57. package/dist/core/agent-session-recovery.js.map +1 -1
  58. package/dist/core/agent-session-tasks.d.ts.map +1 -1
  59. package/dist/core/agent-session-tasks.js +8 -1
  60. package/dist/core/agent-session-tasks.js.map +1 -1
  61. package/dist/core/agent-session-transfer.d.ts.map +1 -1
  62. package/dist/core/agent-session-transfer.js +5 -0
  63. package/dist/core/agent-session-transfer.js.map +1 -1
  64. package/dist/core/agent-session.d.ts +2 -0
  65. package/dist/core/agent-session.d.ts.map +1 -1
  66. package/dist/core/agent-session.js +2 -0
  67. package/dist/core/agent-session.js.map +1 -1
  68. package/dist/core/copilot-errors.d.ts +6 -0
  69. package/dist/core/copilot-errors.d.ts.map +1 -0
  70. package/dist/core/copilot-errors.js +17 -0
  71. package/dist/core/copilot-errors.js.map +1 -0
  72. package/dist/core/execution-model-router.d.ts.map +1 -1
  73. package/dist/core/execution-model-router.js +12 -2
  74. package/dist/core/execution-model-router.js.map +1 -1
  75. package/dist/core/extensions/context-types.d.ts +7 -0
  76. package/dist/core/extensions/context-types.d.ts.map +1 -1
  77. package/dist/core/extensions/context-types.js.map +1 -1
  78. package/dist/core/extensions/runner-context.d.ts.map +1 -1
  79. package/dist/core/extensions/runner-context.js +6 -0
  80. package/dist/core/extensions/runner-context.js.map +1 -1
  81. package/dist/core/extensions/ui-types.d.ts +9 -1
  82. package/dist/core/extensions/ui-types.d.ts.map +1 -1
  83. package/dist/core/extensions/ui-types.js.map +1 -1
  84. package/dist/core/extensions/workflow-activity-hub.d.ts +4 -0
  85. package/dist/core/extensions/workflow-activity-hub.d.ts.map +1 -1
  86. package/dist/core/extensions/workflow-activity-hub.js +26 -5
  87. package/dist/core/extensions/workflow-activity-hub.js.map +1 -1
  88. package/dist/core/extensions/workflow-events.d.ts +5 -0
  89. package/dist/core/extensions/workflow-events.d.ts.map +1 -1
  90. package/dist/core/extensions/workflow-events.js.map +1 -1
  91. package/dist/core/messages.d.ts +5 -0
  92. package/dist/core/messages.d.ts.map +1 -1
  93. package/dist/core/messages.js +1 -1
  94. package/dist/core/messages.js.map +1 -1
  95. package/dist/core/model-fallback-failures.d.ts +1 -1
  96. package/dist/core/model-fallback-failures.d.ts.map +1 -1
  97. package/dist/core/model-fallback-failures.js +37 -3
  98. package/dist/core/model-fallback-failures.js.map +1 -1
  99. package/dist/core/model-resolver-defaults.js +2 -2
  100. package/dist/core/model-resolver-defaults.js.map +1 -1
  101. package/dist/core/model-runtime-streaming.js +2 -2
  102. package/dist/core/model-runtime-streaming.js.map +1 -1
  103. package/dist/core/package-manager-npm.d.ts.map +1 -1
  104. package/dist/core/package-manager-npm.js +7 -3
  105. package/dist/core/package-manager-npm.js.map +1 -1
  106. package/dist/core/provider-composer.d.ts.map +1 -1
  107. package/dist/core/provider-composer.js +19 -11
  108. package/dist/core/provider-composer.js.map +1 -1
  109. package/dist/core/provider-rate-limits.d.ts +55 -6
  110. package/dist/core/provider-rate-limits.d.ts.map +1 -1
  111. package/dist/core/provider-rate-limits.js +182 -29
  112. package/dist/core/provider-rate-limits.js.map +1 -1
  113. package/dist/core/provider-request-admission.d.ts.map +1 -1
  114. package/dist/core/provider-request-admission.js +42 -18
  115. package/dist/core/provider-request-admission.js.map +1 -1
  116. package/dist/core/storage/node-sqlite.d.ts.map +1 -1
  117. package/dist/core/storage/node-sqlite.js +176 -19
  118. package/dist/core/storage/node-sqlite.js.map +1 -1
  119. package/dist/core/storage/provider.d.ts +4 -2
  120. package/dist/core/storage/provider.d.ts.map +1 -1
  121. package/dist/core/storage/provider.js +94 -5
  122. package/dist/core/storage/provider.js.map +1 -1
  123. package/dist/core/structured-output/index.d.ts.map +1 -1
  124. package/dist/core/structured-output/index.js +15 -8
  125. package/dist/core/structured-output/index.js.map +1 -1
  126. package/dist/core/structured-output/provider-failure.d.ts +17 -0
  127. package/dist/core/structured-output/provider-failure.d.ts.map +1 -0
  128. package/dist/core/structured-output/provider-failure.js +28 -0
  129. package/dist/core/structured-output/provider-failure.js.map +1 -0
  130. package/dist/core/tasks/completion.d.ts +6 -1
  131. package/dist/core/tasks/completion.d.ts.map +1 -1
  132. package/dist/core/tasks/completion.js +33 -18
  133. package/dist/core/tasks/completion.js.map +1 -1
  134. package/dist/core/tasks/pause.d.ts +2 -1
  135. package/dist/core/tasks/pause.d.ts.map +1 -1
  136. package/dist/core/tasks/pause.js +3 -2
  137. package/dist/core/tasks/pause.js.map +1 -1
  138. package/dist/core/tools/bash.d.ts.map +1 -1
  139. package/dist/core/tools/bash.js +13 -7
  140. package/dist/core/tools/bash.js.map +1 -1
  141. package/dist/core/tools/grep.d.ts.map +1 -1
  142. package/dist/core/tools/grep.js +6 -4
  143. package/dist/core/tools/grep.js.map +1 -1
  144. package/dist/core/tools/shell-task-render.d.ts +1 -0
  145. package/dist/core/tools/shell-task-render.d.ts.map +1 -1
  146. package/dist/core/tools/shell-task-render.js +7 -2
  147. package/dist/core/tools/shell-task-render.js.map +1 -1
  148. package/dist/core/tools/shell-task-status.d.ts.map +1 -1
  149. package/dist/core/tools/shell-task-status.js +2 -1
  150. package/dist/core/tools/shell-task-status.js.map +1 -1
  151. package/dist/extensions/tool-search/tool.d.ts +5 -0
  152. package/dist/extensions/tool-search/tool.d.ts.map +1 -1
  153. package/dist/extensions/tool-search/tool.js +38 -5
  154. package/dist/extensions/tool-search/tool.js.map +1 -1
  155. package/dist/index.d.ts +1 -1
  156. package/dist/index.d.ts.map +1 -1
  157. package/dist/index.js +1 -1
  158. package/dist/index.js.map +1 -1
  159. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  160. package/dist/modes/interactive/components/assistant-message.js +20 -12
  161. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  162. package/dist/modes/interactive/components/chat-session-host-actions.js +3 -2
  163. package/dist/modes/interactive/components/chat-session-host-actions.js.map +1 -1
  164. package/dist/modes/interactive/components/login-dialog.d.ts.map +1 -1
  165. package/dist/modes/interactive/components/login-dialog.js +42 -14
  166. package/dist/modes/interactive/components/login-dialog.js.map +1 -1
  167. package/dist/modes/interactive/interactive-extension-context.js +2 -1
  168. package/dist/modes/interactive/interactive-extension-context.js.map +1 -1
  169. package/dist/modes/interactive/interactive-extension-custom-ui.js +1 -1
  170. package/dist/modes/interactive/interactive-extension-custom-ui.js.map +1 -1
  171. package/dist/modes/interactive/interactive-extension-dialogs.js +10 -4
  172. package/dist/modes/interactive/interactive-extension-dialogs.js.map +1 -1
  173. package/dist/modes/interactive/interactive-mode-surface.d.ts +1 -0
  174. package/dist/modes/interactive/interactive-mode-surface.d.ts.map +1 -1
  175. package/dist/modes/interactive/interactive-mode-surface.js.map +1 -1
  176. package/dist/modes/interactive/interactive-startup.d.ts.map +1 -1
  177. package/dist/modes/interactive/interactive-startup.js +5 -6
  178. package/dist/modes/interactive/interactive-startup.js.map +1 -1
  179. package/dist/modes/interactive-engine/engine-custom-ui.d.ts +3 -0
  180. package/dist/modes/interactive-engine/engine-custom-ui.d.ts.map +1 -1
  181. package/dist/modes/interactive-engine/engine-custom-ui.js +1 -0
  182. package/dist/modes/interactive-engine/engine-custom-ui.js.map +1 -1
  183. package/dist/modes/interactive-engine/protocol.d.ts +6 -0
  184. package/dist/modes/interactive-engine/protocol.d.ts.map +1 -1
  185. package/dist/modes/interactive-engine/protocol.js +9 -0
  186. package/dist/modes/interactive-engine/protocol.js.map +1 -1
  187. package/dist/modes/interactive-engine/remote-component.d.ts.map +1 -1
  188. package/dist/modes/interactive-engine/remote-component.js +3 -2
  189. package/dist/modes/interactive-engine/remote-component.js.map +1 -1
  190. package/dist/modes/rpc/rpc-client-process.d.ts.map +1 -1
  191. package/dist/modes/rpc/rpc-client-process.js +8 -1
  192. package/dist/modes/rpc/rpc-client-process.js.map +1 -1
  193. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  194. package/dist/modes/rpc/rpc-client.js +7 -1
  195. package/dist/modes/rpc/rpc-client.js.map +1 -1
  196. package/dist/utils/ansi.d.ts +2 -0
  197. package/dist/utils/ansi.d.ts.map +1 -1
  198. package/dist/utils/ansi.js +5 -0
  199. package/dist/utils/ansi.js.map +1 -1
  200. package/docs/codemode.md +1 -1
  201. package/docs/intercom/reference.md +3 -3
  202. package/docs/llama-cpp.md +25 -14
  203. package/docs/models/evals.md +31 -12
  204. package/docs/models/reference.md +23 -51
  205. package/docs/models.md +3 -4
  206. package/docs/providers.md +5 -15
  207. package/docs/settings.md +1 -1
  208. package/docs/storage.md +2 -2
  209. package/docs/tools.md +1 -1
  210. package/docs/tui.md +2 -0
  211. package/docs/workflows/api-reference.md +20 -1
  212. package/docs/workflows/operations.md +17 -4
  213. package/npm-shrinkwrap.json +64 -44
  214. package/package.json +4 -4
@@ -2,7 +2,11 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { hostname } from "node:os";
3
3
  import { normalizeGroup } from "../group.js";
4
4
  import { isAgentRecipient, NON_AGENT_RECIPIENT_REFUSAL, TERMINAL_CHILD_ASK_REFUSAL } from "../recipient-purpose.js";
5
- import { DELIVERED_MESSAGE_TTL_MS, RECIPIENT_RECEIPT_TIMEOUT_MS } from "../retry-policy.js";
5
+ import {
6
+ DELIVERED_MESSAGE_TTL_MS,
7
+ RECIPIENT_RECEIPT_TIMEOUT_MS,
8
+ RECIPIENT_SAVE_MAX_WAIT_MS,
9
+ } from "../retry-policy.js";
6
10
  import {
7
11
  resolveSessionTarget,
8
12
  resolveSessionTargetByHostSessionId,
@@ -66,6 +70,12 @@ export const OFFLINE_SESSION_TARGET = "offline session";
66
70
  export const SHARED_STORAGE_UNAVAILABLE_REASON =
67
71
  "Session not found on this node, and Intercom could not reach the shared storage database to look for it on other nodes. Try again once the database is available.";
68
72
  const MAX_HELD_SENDS = 256;
73
+ /**
74
+ * #3983: how long the decision to keep a sender waiting may take before the sender is told to keep waiting while it
75
+ * finishes. Well inside the sender's own wait past the receipt wait (`SEND_RESPONSE_TIMEOUT_MS` minus
76
+ * `RECIPIENT_RECEIPT_TIMEOUT_MS`).
77
+ */
78
+ const HOLD_CHECK_GRACE_MS = 500;
69
79
  /** A session that moved here may find its presence lease still held by the broker it just left. */
70
80
  const PRESENCE_HANDOVER_ATTEMPTS = 4;
71
81
  const PRESENCE_HANDOVER_RETRY_MS = 50;
@@ -180,6 +190,12 @@ interface Waiter {
180
190
  /** Told `queued` already: `delivered` follows only while the sender is connected, and it never expires. */
181
191
  readonly queued: boolean;
182
192
  timer: ReturnType<typeof setTimeout> | undefined;
193
+ /** When the sender started waiting; a recipient's save reports never keep it waiting past the save cap (#3983). */
194
+ readonly waitingSince: number;
195
+ /** Ends the wait as uncertain at the save cap, whatever the store does (#3983). */
196
+ capTimer?: ReturnType<typeof setTimeout>;
197
+ /** The hold check whose outbox read is in flight; a wait that ends meanwhile reuses it (#3983). */
198
+ holdCheck?: Promise<void>;
183
199
  }
184
200
 
185
201
  interface TrackedHold {
@@ -218,6 +234,16 @@ interface RemoteTarget {
218
234
  readonly hostBrokerId?: string;
219
235
  }
220
236
 
237
+ function queuedFrame(messageId: string, attemptId: string | undefined, position: number): BrokerOutboundFrame {
238
+ return {
239
+ type: "queued",
240
+ messageId,
241
+ ...(attemptId === undefined ? {} : { attemptId }),
242
+ target: OFFLINE_SESSION_TARGET,
243
+ position,
244
+ };
245
+ }
246
+
221
247
  function returnAddressOf(identity: string): string | undefined {
222
248
  try {
223
249
  const parsed = JSON.parse(identity) as unknown;
@@ -404,7 +430,10 @@ export class MultiNodeRouter {
404
430
  const sessionId = session.info.id;
405
431
  this.serial(identity, async () => {
406
432
  for (const inflight of claims) await this.state.outbox.releaseClaim(inflight.claim);
407
- if (entry.lease !== undefined) await this.state.leases.release(entry.lease);
433
+ if (entry.lease !== undefined) {
434
+ await this.state.leases.release(entry.lease);
435
+ await this.wakeWaitingOrigins(identity);
436
+ }
408
437
  await this.recordKnown(session, entry);
409
438
  await this.state.pendingAsks.pruneSender(sessionId);
410
439
  await this.state.pendingAsks.takeForTarget(sessionId);
@@ -426,6 +455,18 @@ export class MultiNodeRouter {
426
455
  return true;
427
456
  }
428
457
 
458
+ /**
459
+ * #3983 `saving` from a local recipient: it is still saving a row this broker claimed for it. Recorded on the
460
+ * row, so the sender's broker keeps its sender waiting. True when it matches such a row.
461
+ */
462
+ saving(recipient: BrokerConnectedSession, messageId: string): boolean {
463
+ const inflight = this.inflight.get(messageId);
464
+ if (inflight === undefined || inflight.identity !== deliveryTargetIdentity(recipient)) return false;
465
+ const report = this.state.outbox.reportSaving?.(inflight.claim);
466
+ if (report !== undefined) void report.catch((error: Error) => this.report(error));
467
+ return true;
468
+ }
469
+
429
470
  /**
430
471
  * Sessions other brokers host now (live presence leases), keyed by session id, for `list`: a session connected
431
472
  * through another node is a live peer exactly like a local one. Group visibility is the caller's, as for local
@@ -933,40 +974,83 @@ export class MultiNodeRouter {
933
974
  targetIdentity: target.identity,
934
975
  queued: offline,
935
976
  timer: undefined,
977
+ waitingSince: Date.now(),
936
978
  };
937
- waiter.timer = setTimeout(
938
- () => {
939
- if (this.waiters.get(message.id) !== waiter) return;
940
- this.dropWaiter(waiter);
941
- if (!offline) {
942
- this.host.write(request.socket, {
943
- type: "delivery_failed",
944
- messageId: message.id,
945
- attemptId: request.attemptId,
946
- reason: UNCERTAIN_RECEIPT_REASON,
947
- });
948
- }
949
- },
950
- offline ? DELIVERED_MESSAGE_TTL_MS : RECIPIENT_RECEIPT_TIMEOUT_MS,
951
- );
952
- waiter.timer.unref?.();
979
+ this.armWaiter(waiter, offline ? DELIVERED_MESSAGE_TTL_MS : RECIPIENT_RECEIPT_TIMEOUT_MS);
953
980
  this.waiters.set(message.id, waiter);
954
981
  if (offline) {
955
- const listed = await this.state.outbox.listUndelivered(target.identity);
956
- const index = listed.status === "listed" ? listed.rows.findIndex((entry) => entry.messageId === message.id) : -1;
957
- this.host.write(request.socket, {
958
- type: "queued",
959
- messageId: message.id,
960
- ...(request.attemptId === undefined ? {} : { attemptId: request.attemptId }),
961
- target: OFFLINE_SESSION_TARGET,
962
- position: index + 1 || 1,
963
- });
982
+ await this.writeQueued(request.socket, message.id, request.attemptId, target.identity);
964
983
  return;
965
984
  }
966
985
  if (asksTarget) await this.writeQuestionTarget(request.socket, message.id, request.attemptId, target);
967
986
  await this.bus.publish({ brokerId: target.hostBrokerId!, reason: "outbox", version: row.sequence });
987
+ // A clean quit recorded before this send's row was written is seen here; one recorded after it wakes this
988
+ // broker (`wakeWaitingOrigins`), so a send racing its recipient's quit is queued either way. The waiter's own
989
+ // receipt wait keeps running meanwhile, so slow reads here never hold the sender past it.
990
+ void this.queueIfRecipientLeft(waiter).catch((error: Error) => this.report(error));
968
991
  }
969
992
 
993
+ private async writeQueued(
994
+ socket: BrokerConnectedSession["socket"],
995
+ messageId: string,
996
+ attemptId: string | undefined,
997
+ identity: string,
998
+ ): Promise<void> {
999
+ this.host.write(socket, queuedFrame(messageId, attemptId, await this.queuePosition(identity, messageId)));
1000
+ }
1001
+
1002
+ private async queuePosition(identity: string, messageId: string): Promise<number> {
1003
+ const listed = await this.state.outbox.listUndelivered(identity);
1004
+ const index = listed.status === "listed" ? listed.rows.findIndex((entry) => entry.messageId === messageId) : -1;
1005
+ return index + 1 || 1;
1006
+ }
1007
+
1008
+ /**
1009
+ * A send routed to a live recipient that has since quit, before any broker claimed its row: the row was never
1010
+ * written to the recipient and no broker hosts it now, so the sender is to be told `queued`, as for a send routed
1011
+ * after the quit, instead of waiting out a receipt that cannot come and hearing the outcome is uncertain. Presence
1012
+ * is read before the row, so a row still unclaimed was never written to any recipient. Only reads: the row's queue
1013
+ * position when that holds, otherwise undefined.
1014
+ */
1015
+ private async positionIfRecipientLeft(waiter: Waiter): Promise<number | undefined> {
1016
+ const presence = await this.state.leases.read({ kind: "presence", targetIdentity: waiter.targetIdentity });
1017
+ if (presence.status === "invalid") return undefined;
1018
+ if (presence.status === "found" && presence.lease.ageMs < BROKER_LEASE_STALE_AFTER_MS) return undefined;
1019
+ const read = await this.state.outbox.read(waiter.messageId);
1020
+ if (read.status !== "found" || read.row.state !== "queued" || read.row.claimAttempts !== 0) return undefined;
1021
+ return this.queuePosition(waiter.targetIdentity, waiter.messageId);
1022
+ }
1023
+
1024
+ /** Tell a still-waiting sender `queued`, at once: every read was made before. True when it was told. */
1025
+ private settleQueued(waiter: Waiter, position: number): boolean {
1026
+ if (waiter.queued || this.waiters.get(waiter.messageId) !== waiter) return false;
1027
+ this.dropWaiter(waiter);
1028
+ const queued: Waiter = { ...waiter, queued: true, timer: undefined, capTimer: undefined, holdCheck: undefined };
1029
+ this.armWaiter(queued, DELIVERED_MESSAGE_TTL_MS);
1030
+ this.waiters.set(queued.messageId, queued);
1031
+ this.host.write(queued.socket, queuedFrame(queued.messageId, queued.attemptId, position));
1032
+ return true;
1033
+ }
1034
+
1035
+ /**
1036
+ * Off the receipt wait (a wake-up, the outcome poll, right after routing): the waiter's own timers keep running
1037
+ * while this reads, so `expireWaiter` still answers the sender in time, and an answer here after that is dropped.
1038
+ */
1039
+ private async queueIfRecipientLeft(waiter: Waiter): Promise<void> {
1040
+ if (waiter.queued || this.waiters.get(waiter.messageId) !== waiter) return;
1041
+ const position = await this.positionIfRecipientLeft(waiter);
1042
+ if (position !== undefined) this.settleQueued(waiter, position);
1043
+ }
1044
+
1045
+ /** A recipient left: the brokers waiting on its undelivered rows re-read them, and queue the ones never claimed. */
1046
+ private async wakeWaitingOrigins(identity: string): Promise<void> {
1047
+ const listed = await this.state.outbox.listUndelivered(identity);
1048
+ if (listed.status !== "listed") return;
1049
+ for (const origin of new Set(listed.rows.map((row) => row.originBrokerId))) {
1050
+ if (origin === this.brokerId) void this.checkOutcomes();
1051
+ else await this.bus.publish({ brokerId: origin, reason: "outcome", version: 0 });
1052
+ }
1053
+ }
970
1054
  private async writeQuestionTarget(
971
1055
  socket: BrokerConnectedSession["socket"],
972
1056
  messageId: string,
@@ -1266,6 +1350,9 @@ export class MultiNodeRouter {
1266
1350
  attemptId: waiter.attemptId,
1267
1351
  reason: row.failureReason ?? "Session not found",
1268
1352
  });
1353
+ } else if (row.state === "queued" && row.claimAttempts === 0 && !waiter.queued) {
1354
+ // Not awaited: its reads must not hold back the other senders' outcomes.
1355
+ void this.queueIfRecipientLeft(waiter).catch((error: Error) => this.report(error));
1269
1356
  }
1270
1357
  }
1271
1358
  } while (state.again && !this.closed);
@@ -1295,9 +1382,123 @@ export class MultiNodeRouter {
1295
1382
  this.host.write(waiter.socket, { type: "delivered", messageId: waiter.messageId, attemptId: waiter.attemptId });
1296
1383
  }
1297
1384
 
1385
+ private armWaiter(waiter: Waiter, timeoutMs: number): void {
1386
+ if (waiter.timer !== undefined) clearTimeout(waiter.timer);
1387
+ waiter.timer = setTimeout(() => {
1388
+ if (this.waiters.get(waiter.messageId) !== waiter) return;
1389
+ if (waiter.queued) {
1390
+ this.dropWaiter(waiter);
1391
+ return;
1392
+ }
1393
+ // One hold check per waiter: a wait that ends while the previous check's read is still pending reuses it.
1394
+ if (waiter.holdCheck !== undefined) return;
1395
+ waiter.holdCheck = this.expireWaiter(waiter)
1396
+ .catch((error: Error) => {
1397
+ this.report(error);
1398
+ this.expireUncertain(waiter);
1399
+ })
1400
+ .finally(() => {
1401
+ waiter.holdCheck = undefined;
1402
+ });
1403
+ }, timeoutMs);
1404
+ waiter.timer.unref?.();
1405
+ // #3983: the save cap holds from the sender's own start, whatever the store does.
1406
+ if (!waiter.queued && waiter.capTimer === undefined) {
1407
+ waiter.capTimer = setTimeout(
1408
+ () => this.expireUncertain(waiter),
1409
+ Math.max(0, waiter.waitingSince + RECIPIENT_SAVE_MAX_WAIT_MS - Date.now()),
1410
+ );
1411
+ waiter.capTimer.unref?.();
1412
+ }
1413
+ }
1414
+
1415
+ /** The sender may wait this much longer under the save cap; 0 once the cap is reached. */
1416
+ private capRemaining(waiter: Waiter): number {
1417
+ return Math.max(0, waiter.waitingSince + RECIPIENT_SAVE_MAX_WAIT_MS - Date.now());
1418
+ }
1419
+
1420
+ /**
1421
+ * #3983: the sender's receipt wait ended. A recipient on another broker that reported, within the last receipt
1422
+ * wait, that it is still saving the message keeps its sender waiting another wait (told `send_held`), within the
1423
+ * save cap; otherwise the sender hears that the outcome is uncertain, as before. The decision reads the outbox
1424
+ * row, which can wait behind other writes on the shared connection: a read slower than `HOLD_CHECK_GRACE_MS`
1425
+ * first tells the sender to keep waiting while it finishes (within the cap), and a read that has not answered
1426
+ * within one receipt wait counts as no evidence, so the sender hears the outcome is uncertain. A row no broker
1427
+ * claimed whose recipient has quit is queued for it instead; the reads deciding that run under the same grace and
1428
+ * deadline. Only one decision per waiter is ever in flight, and the cap ends the wait however the store behaves.
1429
+ */
1430
+ private async expireWaiter(waiter: Waiter): Promise<void> {
1431
+ const provisional = setTimeout(() => {
1432
+ const timeoutMs = Math.min(RECIPIENT_RECEIPT_TIMEOUT_MS, this.capRemaining(waiter));
1433
+ if (this.waiters.get(waiter.messageId) !== waiter || timeoutMs === 0) return;
1434
+ this.armWaiter(waiter, timeoutMs);
1435
+ this.holdSender(waiter, timeoutMs);
1436
+ }, HOLD_CHECK_GRACE_MS);
1437
+ provisional.unref?.();
1438
+ let deadline: ReturnType<typeof setTimeout> | undefined;
1439
+ const timedOut = new Promise<"timed-out">((resolveTimeout) => {
1440
+ deadline = setTimeout(() => resolveTimeout("timed-out"), RECIPIENT_RECEIPT_TIMEOUT_MS);
1441
+ deadline.unref?.();
1442
+ });
1443
+ const decide = async () => {
1444
+ const read = await this.state.outbox.read(waiter.messageId);
1445
+ const unclaimed = read.status === "found" && read.row.state === "queued" && read.row.claimAttempts === 0;
1446
+ return { read, queuedAt: unclaimed ? await this.positionIfRecipientLeft(waiter) : undefined };
1447
+ };
1448
+ let decision: Awaited<ReturnType<typeof decide>> | "timed-out";
1449
+ try {
1450
+ decision = await Promise.race([decide(), timedOut]);
1451
+ } finally {
1452
+ clearTimeout(provisional);
1453
+ clearTimeout(deadline);
1454
+ }
1455
+ if (this.waiters.get(waiter.messageId) !== waiter) return;
1456
+ if (decision === "timed-out") {
1457
+ this.expireUncertain(waiter);
1458
+ return;
1459
+ }
1460
+ const { read, queuedAt } = decision;
1461
+ if (read.status === "found" && read.row.state === "delivered") {
1462
+ await this.finishDelivered(waiter);
1463
+ return;
1464
+ }
1465
+ const remaining = this.capRemaining(waiter);
1466
+ const savingAge = read.status === "found" && read.row.state === "claimed" ? read.row.savingAgeMs : undefined;
1467
+ if (savingAge !== undefined && savingAge < RECIPIENT_RECEIPT_TIMEOUT_MS && remaining > 0) {
1468
+ const timeoutMs = Math.min(RECIPIENT_RECEIPT_TIMEOUT_MS, remaining);
1469
+ this.armWaiter(waiter, timeoutMs);
1470
+ this.holdSender(waiter, timeoutMs);
1471
+ return;
1472
+ }
1473
+ if (queuedAt !== undefined && this.settleQueued(waiter, queuedAt)) return;
1474
+ this.expireUncertain(waiter);
1475
+ }
1476
+
1477
+ private holdSender(waiter: Waiter, timeoutMs: number): void {
1478
+ if (this.waiters.get(waiter.messageId) !== waiter) return;
1479
+ this.host.write(waiter.socket, {
1480
+ type: "send_held",
1481
+ messageId: waiter.messageId,
1482
+ ...(waiter.attemptId === undefined ? {} : { attemptId: waiter.attemptId }),
1483
+ timeoutMs,
1484
+ });
1485
+ }
1486
+
1487
+ private expireUncertain(waiter: Waiter): void {
1488
+ if (this.waiters.get(waiter.messageId) !== waiter) return;
1489
+ this.dropWaiter(waiter);
1490
+ this.host.write(waiter.socket, {
1491
+ type: "delivery_failed",
1492
+ messageId: waiter.messageId,
1493
+ attemptId: waiter.attemptId,
1494
+ reason: UNCERTAIN_RECEIPT_REASON,
1495
+ });
1496
+ }
1497
+
1298
1498
  private dropWaiter(waiter: Waiter): void {
1299
1499
  if (this.waiters.get(waiter.messageId) === waiter) this.waiters.delete(waiter.messageId);
1300
1500
  if (waiter.timer !== undefined) clearTimeout(waiter.timer);
1501
+ if (waiter.capTimer !== undefined) clearTimeout(waiter.capTimer);
1301
1502
  }
1302
1503
 
1303
1504
  // ---------------------------------------------------------------------------------------------------------
@@ -151,6 +151,11 @@ export interface OutboxRow {
151
151
  readonly claimAttempts: number;
152
152
  /** Present only when `state` is `failed`; verbatim. */
153
153
  readonly failureReason?: string;
154
+ /**
155
+ * #3983: store-clock milliseconds since the claimed row's recipient last reported it is still saving it; absent
156
+ * when it never reported, and on rows from a store that does not record it.
157
+ */
158
+ readonly savingAgeMs?: number;
154
159
  }
155
160
 
156
161
  export interface OutboxClaim {
@@ -217,6 +222,11 @@ export interface OutboxStore {
217
222
  */
218
223
  claim(request: OutboxClaimRequest): Promise<OutboxClaimResult>;
219
224
  renewClaim(claim: OutboxClaim): Promise<OutboxRenewClaimResult>;
225
+ /**
226
+ * #3983: the claim's recipient reports it is still saving the row, so the sender's broker keeps its sender
227
+ * waiting. Optional: a store without it never extends a sender's wait.
228
+ */
229
+ reportSaving?(claim: OutboxClaim): Promise<OutboxRenewClaimResult>;
220
230
  /** Return the row to `queued`, for example when its recipient disconnected before acknowledging. */
221
231
  releaseClaim(claim: OutboxClaim): Promise<OutboxReleaseClaimResult>;
222
232
  /**
@@ -1,5 +1,9 @@
1
1
  import type net from "node:net";
2
- import { DELIVERED_MESSAGE_TTL_MS, RECIPIENT_RECEIPT_TIMEOUT_MS } from "../retry-policy.js";
2
+ import {
3
+ DELIVERED_MESSAGE_TTL_MS,
4
+ RECIPIENT_RECEIPT_TIMEOUT_MS,
5
+ RECIPIENT_SAVE_MAX_WAIT_MS,
6
+ } from "../retry-policy.js";
3
7
  import type { BrokerMessage } from "../types.js";
4
8
 
5
9
  /** #3761: the sender is told this when the recipient did not confirm a save within the sender's wait. */
@@ -21,12 +25,19 @@ export interface AwaitedReceipt<Recipient> {
21
25
  readonly complete: (recipient: Recipient, sender: ReceiptSender | undefined) => void;
22
26
  /** Runs when the sender's wait ends before the recipient confirms. */
23
27
  readonly expire: (sender: ReceiptSender) => void;
28
+ /**
29
+ * Runs when a recipient still saving the message extends the sender's wait: tells the sender to keep waiting
30
+ * for up to `timeoutMs` more (a `send_held` frame).
31
+ */
32
+ readonly hold?: (sender: ReceiptSender, timeoutMs: number) => void;
24
33
  }
25
34
 
26
35
  interface PendingReceipt<Recipient> extends AwaitedReceipt<Recipient> {
27
36
  sender: ReceiptSender | undefined;
28
37
  senderTimer: ReturnType<typeof setTimeout> | undefined;
29
38
  retentionTimer: ReturnType<typeof setTimeout>;
39
+ /** When the sender started waiting; a save that keeps reporting progress still ends its wait after the cap. */
40
+ readonly waitingSince: number;
30
41
  }
31
42
 
32
43
  /**
@@ -51,15 +62,38 @@ export class RecipientReceipts<Recipient> {
51
62
  retentionTimer: setTimeout(() => {
52
63
  if (this.pending.get(receipt.messageId) === entry) this.drop(receipt.messageId);
53
64
  }, this.retentionMs),
65
+ waitingSince: Date.now(),
54
66
  };
55
67
  entry.retentionTimer.unref?.();
68
+ this.armSenderWait(entry, this.senderTimeoutMs);
69
+ this.pending.set(receipt.messageId, entry);
70
+ }
71
+
72
+ private armSenderWait(entry: PendingReceipt<Recipient>, timeoutMs: number): void {
73
+ if (entry.senderTimer !== undefined) clearTimeout(entry.senderTimer);
56
74
  entry.senderTimer = setTimeout(() => {
57
75
  entry.senderTimer = undefined;
58
76
  const waiting = entry.sender;
59
77
  entry.sender = undefined;
60
- if (waiting !== undefined && this.pending.get(receipt.messageId) === entry) entry.expire(waiting);
61
- }, this.senderTimeoutMs);
62
- this.pending.set(receipt.messageId, entry);
78
+ if (waiting !== undefined && this.pending.get(entry.messageId) === entry) entry.expire(waiting);
79
+ }, timeoutMs);
80
+ }
81
+
82
+ /**
83
+ * The recipient this message was written to reports it is still saving it (for example a pooled stage whose
84
+ * journal commit waits behind other writers): the sender keeps waiting another full receipt wait instead of
85
+ * hearing the outcome is uncertain. A recipient that dies stops reporting, so its wait still ends; and no save
86
+ * holds a sender past `RECIPIENT_SAVE_MAX_WAIT_MS` (#3983).
87
+ */
88
+ extend(messageId: string, targetIdentity: string): boolean {
89
+ const entry = this.pending.get(messageId);
90
+ if (entry === undefined || entry.targetIdentity !== targetIdentity || entry.sender === undefined) return false;
91
+ const remaining = entry.waitingSince + RECIPIENT_SAVE_MAX_WAIT_MS - Date.now();
92
+ if (remaining <= 0) return false;
93
+ const timeoutMs = Math.min(this.senderTimeoutMs, remaining);
94
+ this.armSenderWait(entry, timeoutMs);
95
+ entry.hold?.(entry.sender, timeoutMs);
96
+ return true;
63
97
  }
64
98
 
65
99
  /** Settle a held message when the confirming recipient is the one it was written to. */
@@ -6,7 +6,7 @@ import {
6
6
  parseLegacyWorkflowStageTarget,
7
7
  parseWorkflowStageTarget,
8
8
  } from "../workflow-stage-target.js";
9
- import { normalizeGroup } from "../group.js";
9
+ import { normalizeGroup, workflowInvocationRefusalFor } from "../group.js";
10
10
  import { isMessage } from "./client-message-validation.js";
11
11
  import {
12
12
  resolveSessionTarget,
@@ -541,7 +541,11 @@ export function handleBrokerSend(
541
541
  type: "delivery_failed",
542
542
  messageId: message.id,
543
543
  attemptId,
544
- reason: "Target session is in a different intercom group",
544
+ // #4006: a live workflow stage is reached through its invocation; name the group to join,
545
+ // unless the sender is a workflow stage that joining could never authorize.
546
+ reason: liveWorkflowTarget !== undefined && workflowTarget !== undefined
547
+ ? workflowInvocationRefusalFor(fromSession.registrationGroup, `workflow:${workflowTarget.rootRunId}`)
548
+ : "Target session is in a different intercom group",
545
549
  });
546
550
  return;
547
551
  }
@@ -744,6 +748,9 @@ export function handleBrokerSend(
744
748
  expire: (waiting) => {
745
749
  write(waiting.socket, { type: "delivery_failed", messageId: message.id, attemptId: waiting.attemptId, reason: UNCERTAIN_RECEIPT_REASON });
746
750
  },
751
+ hold: (waiting, timeoutMs) => {
752
+ write(waiting.socket, { type: "send_held", messageId: message.id, attemptId: waiting.attemptId, timeoutMs });
753
+ },
747
754
  },
748
755
  senderWaiting,
749
756
  );
@@ -34,6 +34,8 @@ interface OutboxRecord extends SqliteRow {
34
34
  readonly claim_token: string | null;
35
35
  /** Store-clock milliseconds since the claim was taken or renewed; null when unclaimed. */
36
36
  readonly claim_age: SqliteValue;
37
+ /** Store-clock milliseconds since the claim's recipient last reported it is still saving the row; null when never. */
38
+ readonly saving_age: SqliteValue;
37
39
  readonly failure_reason: string | null;
38
40
  }
39
41
 
@@ -51,6 +53,9 @@ function toRow(record: OutboxRecord): OutboxRow {
51
53
  state: record.state,
52
54
  claimAttempts: Number(record.claim_attempts),
53
55
  ...(failureReason === null ? {} : { failureReason: JSON.parse(failureReason) as string }),
56
+ ...(record.saving_age === null || record.saving_age === undefined
57
+ ? {}
58
+ : { savingAgeMs: Number(record.saving_age) }),
54
59
  };
55
60
  }
56
61
 
@@ -81,10 +86,22 @@ export async function openSqlOutboxStore(options: IntercomBrokerStateOptions): P
81
86
  );
82
87
  CREATE INDEX IF NOT EXISTS ${table}_target ON ${table}(target_identity, state, sequence);
83
88
  `);
89
+ // #3983: added later; a table from an older broker gets the column, and an older broker ignores it.
90
+ const known = await database.all<{ name: string }>(`PRAGMA table_info(${table})`);
91
+ if (!known.some((column) => column.name === "saving_at")) {
92
+ try {
93
+ await database.exec(`ALTER TABLE ${table} ADD COLUMN saving_at INTEGER`);
94
+ } catch (error) {
95
+ // Another broker added it between the check and the change.
96
+ if (!/duplicate column/i.test(error instanceof Error ? `${error.message} ${String(error.cause ?? "")}` : ""))
97
+ throw error;
98
+ }
99
+ }
84
100
 
85
101
  const columns = `sequence, message_id, target_identity, origin_broker_id, digest, frame_json, state,
86
102
  claim_attempts, claimant_id, claim_token, failure_reason,
87
- CASE WHEN claim_renewed_at IS NULL THEN NULL ELSE MAX(0, ${STORE_NOW} - claim_renewed_at) END AS claim_age`;
103
+ CASE WHEN claim_renewed_at IS NULL THEN NULL ELSE MAX(0, ${STORE_NOW} - claim_renewed_at) END AS claim_age,
104
+ CASE WHEN saving_at IS NULL THEN NULL ELSE MAX(0, ${STORE_NOW} - saving_at) END AS saving_age`;
88
105
  const select = (executor: SqliteExecutor, messageId: string) =>
89
106
  executor.get<OutboxRecord>(`SELECT ${columns} FROM ${table} WHERE message_id = ?`, messageId);
90
107
  const undelivered = (executor: SqliteExecutor, targetIdentity: string) =>
@@ -121,7 +138,7 @@ export async function openSqlOutboxStore(options: IntercomBrokerStateOptions): P
121
138
  ): Promise<OutboxRow> => {
122
139
  await executor.run(
123
140
  `UPDATE ${table} SET state = ?, failure_reason = ?, settled_at = ${STORE_NOW},
124
- claimant_id = NULL, claim_token = NULL, claim_renewed_at = NULL
141
+ claimant_id = NULL, claim_token = NULL, claim_renewed_at = NULL, saving_at = NULL
125
142
  WHERE message_id = ?`,
126
143
  state,
127
144
  reason === null ? null : JSON.stringify(reason),
@@ -175,7 +192,7 @@ export async function openSqlOutboxStore(options: IntercomBrokerStateOptions): P
175
192
  const token = randomUUID();
176
193
  await executor.run(
177
194
  `UPDATE ${table} SET state = 'claimed', claim_attempts = claim_attempts + 1, claimant_id = ?,
178
- claim_token = ?, claim_renewed_at = ${STORE_NOW}
195
+ claim_token = ?, claim_renewed_at = ${STORE_NOW}, saving_at = NULL
179
196
  WHERE message_id = ?`,
180
197
  request.claimantId,
181
198
  token,
@@ -201,10 +218,18 @@ export async function openSqlOutboxStore(options: IntercomBrokerStateOptions): P
201
218
  });
202
219
  },
203
220
 
221
+ reportSaving(claim) {
222
+ return withClaim(claim, async (executor) => {
223
+ await executor.run(`UPDATE ${table} SET saving_at = ${STORE_NOW} WHERE message_id = ?`, claim.messageId);
224
+ return { status: "renewed" } as const;
225
+ });
226
+ },
227
+
204
228
  releaseClaim(claim) {
205
229
  return withClaim(claim, async (executor) => {
206
230
  await executor.run(
207
- `UPDATE ${table} SET state = 'queued', claimant_id = NULL, claim_token = NULL, claim_renewed_at = NULL
231
+ `UPDATE ${table} SET state = 'queued', claimant_id = NULL, claim_token = NULL, claim_renewed_at = NULL,
232
+ saving_at = NULL
208
233
  WHERE message_id = ?`,
209
234
  claim.messageId,
210
235
  );
@@ -123,3 +123,28 @@ export function resolveHomeGroup(
123
123
  }
124
124
  return DEFAULT_GROUP;
125
125
  }
126
+
127
+ /**
128
+ * #4006: refusal for a sender outside a workflow invocation. It names the documented
129
+ * `workflow:<rootRunId>` group to join, never an internal identifier.
130
+ */
131
+ export function workflowInvocationGroupRefusal(invocationGroup: string): string {
132
+ return `Target workflow run is in a different intercom group; join ${normalizeGroup(invocationGroup)} to send to its stages`;
133
+ }
134
+
135
+ /**
136
+ * Refusal for a workflow stage registered outside a run's invocation group: another run's stage,
137
+ * or an isolated subgroup stage of the same run. Joining never grants it control, so the reason
138
+ * must not tell it to join.
139
+ */
140
+ export function workflowStageInvocationRefusal(senderRegistrationGroup: string, invocationGroup: string): string {
141
+ return `A workflow stage in ${normalizeGroup(senderRegistrationGroup)} cannot send to stages of ${normalizeGroup(invocationGroup)}; joining that group does not grant it control`;
142
+ }
143
+
144
+ /** The precise refusal for a sender registered in `senderRegistrationGroup` reaching stages of `invocationGroup`. */
145
+ export function workflowInvocationRefusalFor(senderRegistrationGroup: string | undefined, invocationGroup: string): string {
146
+ const registration = normalizeGroup(senderRegistrationGroup);
147
+ return registration.startsWith("workflow:") && registration !== normalizeGroup(invocationGroup)
148
+ ? workflowStageInvocationRefusal(registration, invocationGroup)
149
+ : workflowInvocationGroupRefusal(invocationGroup);
150
+ }