@gajae-code/coding-agent 0.16.3 → 0.16.6

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 (277) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli/args.d.ts +2 -2
  4. package/dist/types/cli/customize-doctor.d.ts +22 -0
  5. package/dist/types/cli/root-flags.d.ts +4 -2
  6. package/dist/types/cli.d.ts +1 -1
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  9. package/dist/types/config/file-lock.d.ts +31 -3
  10. package/dist/types/config/model-registry.d.ts +4 -0
  11. package/dist/types/config/model-resolver.d.ts +9 -0
  12. package/dist/types/config/settings-schema.d.ts +29 -1
  13. package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
  14. package/dist/types/coordinator-mcp/server.d.ts +7 -0
  15. package/dist/types/discovery/claude-plugins.d.ts +3 -1
  16. package/dist/types/discovery/helpers.d.ts +35 -0
  17. package/dist/types/exec/bash-executor.d.ts +17 -1
  18. package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
  19. package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
  20. package/dist/types/exec/bash-shell-signals.d.ts +2 -0
  21. package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
  22. package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
  23. package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
  24. package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
  25. package/dist/types/exec/bash-shell-worker.d.ts +1 -0
  26. package/dist/types/exec/isolated-shell.d.ts +19 -0
  27. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
  28. package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
  29. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  30. package/dist/types/extensibility/extensions/runner.d.ts +46 -6
  31. package/dist/types/extensibility/extensions/types.d.ts +16 -0
  32. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
  33. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
  34. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
  35. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
  36. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  37. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  38. package/dist/types/extensibility/runtime-skill-discovery.d.ts +5 -2
  39. package/dist/types/extensibility/shared-events.d.ts +1 -1
  40. package/dist/types/extensibility/skill-management.d.ts +9 -4
  41. package/dist/types/extensibility/skills.d.ts +2 -0
  42. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
  43. package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
  44. package/dist/types/gjc-runtime/session-state-lock.d.ts +14 -0
  45. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
  46. package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
  47. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  48. package/dist/types/modes/components/bash-execution.d.ts +1 -0
  49. package/dist/types/modes/components/execution-shared.d.ts +1 -0
  50. package/dist/types/modes/components/settings-selector.d.ts +6 -1
  51. package/dist/types/modes/components/status-line/command.d.ts +35 -0
  52. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  53. package/dist/types/modes/components/status-line/types.d.ts +5 -0
  54. package/dist/types/modes/components/tool-status-header.d.ts +14 -0
  55. package/dist/types/modes/controllers/event-controller.d.ts +6 -0
  56. package/dist/types/modes/interactive-mode.d.ts +3 -0
  57. package/dist/types/modes/types.d.ts +7 -0
  58. package/dist/types/runtime-mcp/client.d.ts +9 -1
  59. package/dist/types/runtime-mcp/manager.d.ts +7 -1
  60. package/dist/types/runtime-mcp/pool.d.ts +22 -3
  61. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  62. package/dist/types/runtime-mcp/types.d.ts +22 -0
  63. package/dist/types/sdk/broker/ensure.d.ts +32 -0
  64. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
  65. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  66. package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
  67. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
  68. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
  69. package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
  70. package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
  71. package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
  72. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  73. package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
  74. package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
  75. package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
  76. package/dist/types/sdk/client/client.d.ts +6 -0
  77. package/dist/types/sdk/host/host.d.ts +3 -1
  78. package/dist/types/sdk/host/session-runtime.d.ts +26 -1
  79. package/dist/types/sdk/lifecycle/service.d.ts +1 -0
  80. package/dist/types/sdk/router/session-router.d.ts +10 -2
  81. package/dist/types/sdk/session-api-key.d.ts +26 -0
  82. package/dist/types/sdk/session.d.ts +5 -1
  83. package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
  84. package/dist/types/session/agent-session.d.ts +11 -0
  85. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  86. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  87. package/dist/types/session/messages.d.ts +1 -0
  88. package/dist/types/session/session-manager.d.ts +3 -1
  89. package/dist/types/setup/paseo/announce.d.ts +112 -0
  90. package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
  91. package/dist/types/skill-state/canonical-skills.d.ts +4 -0
  92. package/dist/types/system-prompt.d.ts +10 -0
  93. package/dist/types/task/executor.d.ts +5 -1
  94. package/dist/types/tools/bash.d.ts +1 -0
  95. package/dist/types/tools/fetch.d.ts +7 -0
  96. package/dist/types/tools/github-cache.d.ts +2 -1
  97. package/dist/types/tools/index.d.ts +10 -0
  98. package/dist/types/tools/path-utils.d.ts +2 -0
  99. package/dist/types/tools/search.d.ts +6 -0
  100. package/package.json +9 -7
  101. package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
  102. package/scripts/generate-sdk-operation-inventory.ts +11 -0
  103. package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
  104. package/src/capability/index.ts +14 -2
  105. package/src/capability/types.ts +4 -0
  106. package/src/cli/args.ts +20 -9
  107. package/src/cli/customize-doctor.ts +155 -12
  108. package/src/cli/root-flags.ts +5 -3
  109. package/src/cli.ts +22 -0
  110. package/src/commands/sdk.ts +74 -31
  111. package/src/config/autorouting-tier-map.ts +1 -0
  112. package/src/config/file-lock-gc.ts +40 -2
  113. package/src/config/file-lock.ts +379 -31
  114. package/src/config/model-preset-registry.ts +120 -19
  115. package/src/config/model-profiles.ts +40 -0
  116. package/src/config/model-registry.ts +73 -23
  117. package/src/config/model-resolver.ts +48 -0
  118. package/src/config/settings-schema.ts +33 -1
  119. package/src/config/settings.ts +1 -0
  120. package/src/coordinator-mcp/question-state.ts +33 -2
  121. package/src/coordinator-mcp/server.ts +477 -101
  122. package/src/crash/sanitize.ts +1 -1
  123. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
  124. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
  125. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
  126. package/src/discovery/builtin.ts +52 -55
  127. package/src/discovery/claude-plugins.ts +12 -4
  128. package/src/discovery/helpers.ts +307 -34
  129. package/src/exec/bash-executor.ts +330 -50
  130. package/src/exec/bash-shell-guardian.ts +388 -0
  131. package/src/exec/bash-shell-runtime-entry.ts +5 -0
  132. package/src/exec/bash-shell-signals.ts +29 -0
  133. package/src/exec/bash-shell-supervisor-entry.ts +3 -0
  134. package/src/exec/bash-shell-supervisor.ts +359 -0
  135. package/src/exec/bash-shell-worker-entry.ts +5 -0
  136. package/src/exec/bash-shell-worker-protocol.ts +36 -0
  137. package/src/exec/bash-shell-worker.ts +116 -0
  138. package/src/exec/isolated-shell.ts +393 -0
  139. package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
  140. package/src/extensibility/extensions/function-hooks.ts +959 -0
  141. package/src/extensibility/extensions/index.ts +22 -0
  142. package/src/extensibility/extensions/loader.ts +53 -2
  143. package/src/extensibility/extensions/runner.ts +895 -264
  144. package/src/extensibility/extensions/types.ts +42 -0
  145. package/src/extensibility/extensions/wrapper.ts +56 -15
  146. package/src/extensibility/gjc-plugins/compiler.ts +228 -60
  147. package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
  148. package/src/extensibility/gjc-plugins/installer.ts +13 -12
  149. package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
  150. package/src/extensibility/gjc-plugins/paths.ts +13 -0
  151. package/src/extensibility/gjc-plugins/registry.ts +113 -1
  152. package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
  153. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
  154. package/src/extensibility/gjc-plugins/schema.ts +121 -4
  155. package/src/extensibility/gjc-plugins/types.ts +25 -0
  156. package/src/extensibility/hooks/loader.ts +133 -16
  157. package/src/extensibility/hooks/types.ts +28 -0
  158. package/src/extensibility/runtime-skill-discovery.ts +149 -190
  159. package/src/extensibility/shared-events.ts +1 -1
  160. package/src/extensibility/skill-management.ts +200 -52
  161. package/src/extensibility/skills.ts +73 -25
  162. package/src/gjc-runtime/empty-delete-gc.ts +94 -4
  163. package/src/gjc-runtime/gc-runtime.ts +29 -0
  164. package/src/gjc-runtime/linux-proc.ts +14 -0
  165. package/src/gjc-runtime/repository-binding.ts +1 -1
  166. package/src/gjc-runtime/session-state-lock.ts +143 -10
  167. package/src/gjc-runtime/session-state-sidecar.ts +27 -11
  168. package/src/gjc-runtime/state-writer.ts +30 -29
  169. package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
  170. package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
  171. package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
  172. package/src/gjc-runtime/workflow-manifest.ts +16 -0
  173. package/src/harness-control-plane/session-lease.ts +2 -2
  174. package/src/internal-urls/docs-index.generated.ts +12 -12
  175. package/src/lsp/config.ts +10 -3
  176. package/src/lsp/index.ts +1 -1
  177. package/src/lsp/path-trust.ts +69 -14
  178. package/src/main.ts +78 -35
  179. package/src/modes/DESIGN.md +54 -0
  180. package/src/modes/acp/acp-agent.ts +16 -3
  181. package/src/modes/components/assistant-message.ts +22 -9
  182. package/src/modes/components/bash-execution.ts +4 -1
  183. package/src/modes/components/execution-shared.ts +4 -1
  184. package/src/modes/components/model-selector.ts +166 -33
  185. package/src/modes/components/session-observer-overlay.ts +1 -1
  186. package/src/modes/components/settings-selector.ts +1075 -235
  187. package/src/modes/components/status-line/command.ts +190 -0
  188. package/src/modes/components/status-line/index.ts +1 -0
  189. package/src/modes/components/status-line/segments.ts +32 -5
  190. package/src/modes/components/status-line/types.ts +5 -0
  191. package/src/modes/components/tool-status-header.ts +396 -22
  192. package/src/modes/controllers/command-controller.ts +3 -0
  193. package/src/modes/controllers/event-controller.ts +151 -7
  194. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  195. package/src/modes/controllers/input-controller.ts +6 -3
  196. package/src/modes/controllers/selector-controller.ts +21 -0
  197. package/src/modes/interactive-mode.ts +53 -6
  198. package/src/modes/print-mode.ts +5 -1
  199. package/src/modes/prompt-action-autocomplete.ts +54 -27
  200. package/src/modes/runtime-init.ts +2 -0
  201. package/src/modes/types.ts +17 -3
  202. package/src/modes/utils/ui-helpers.ts +2 -0
  203. package/src/prompts/system/system-prompt.md +8 -0
  204. package/src/prompts/tools/search.md +2 -0
  205. package/src/prompts/tools/subagent.md +1 -0
  206. package/src/runtime-mcp/client.ts +60 -17
  207. package/src/runtime-mcp/manager.ts +189 -14
  208. package/src/runtime-mcp/pool.test.ts +218 -0
  209. package/src/runtime-mcp/pool.ts +224 -23
  210. package/src/runtime-mcp/transports/stdio.ts +158 -24
  211. package/src/runtime-mcp/types.ts +24 -0
  212. package/src/sdk/broker/broker.ts +124 -32
  213. package/src/sdk/broker/ensure.ts +248 -41
  214. package/src/sdk/broker/lifecycle-ledger.ts +7 -17
  215. package/src/sdk/broker/lifecycle.ts +56 -6
  216. package/src/sdk/broker/startup-failure.ts +15 -6
  217. package/src/sdk/bus/chat-daemon-control.ts +3 -1
  218. package/src/sdk/bus/chat-daemon-runtime.ts +72 -6
  219. package/src/sdk/bus/chat-effect-journal.ts +132 -7
  220. package/src/sdk/bus/conversation-store.ts +29 -0
  221. package/src/sdk/bus/lifecycle-commands.ts +17 -11
  222. package/src/sdk/bus/slack-conversation.ts +1 -0
  223. package/src/sdk/bus/slack-daemon.ts +478 -49
  224. package/src/sdk/bus/slack-live-provider.ts +12 -0
  225. package/src/sdk/bus/slack-provider.ts +5 -0
  226. package/src/sdk/bus/telegram-daemon.ts +16 -2
  227. package/src/sdk/bus/topic-registry.ts +19 -0
  228. package/src/sdk/cli/master-cli.ts +1 -1
  229. package/src/sdk/cli/session-cli.ts +11 -8
  230. package/src/sdk/client/client.ts +66 -17
  231. package/src/sdk/host/host.ts +29 -9
  232. package/src/sdk/host/query/revision-store.ts +19 -13
  233. package/src/sdk/host/reverse-leases.ts +7 -4
  234. package/src/sdk/host/session-runtime.test.ts +52 -10
  235. package/src/sdk/host/session-runtime.ts +383 -44
  236. package/src/sdk/lifecycle/service.ts +4 -0
  237. package/src/sdk/protocol/operation-inventory.generated.json +77 -0
  238. package/src/sdk/router/session-router.ts +89 -38
  239. package/src/sdk/session-api-key.ts +94 -0
  240. package/src/sdk/session.ts +228 -55
  241. package/src/sdk/transport/relay.ts +35 -13
  242. package/src/sdk/transport/serve-cli.ts +3 -3
  243. package/src/session/agent-session.ts +240 -90
  244. package/src/session/contribution-prep.ts +214 -21
  245. package/src/session/fallback-chain-controller.ts +38 -0
  246. package/src/session/internal/managed-session-storage.ts +19 -5
  247. package/src/session/messages.ts +5 -0
  248. package/src/session/session-manager.ts +29 -5
  249. package/src/session-import/redact.ts +5 -1
  250. package/src/setup/paseo/announce.ts +456 -0
  251. package/src/setup/paseo/setup-deps.ts +15 -3
  252. package/src/skill-state/canonical-skills.ts +12 -0
  253. package/src/skills/skills.test.ts +41 -21
  254. package/src/slash-commands/builtin-registry.ts +1 -0
  255. package/src/system-prompt.ts +29 -3
  256. package/src/task/executor.ts +7 -1
  257. package/src/task/index.ts +21 -4
  258. package/src/tools/bash.ts +63 -19
  259. package/src/tools/cron.ts +1 -1
  260. package/src/tools/fetch.ts +29 -1
  261. package/src/tools/gh.ts +26 -3
  262. package/src/tools/github-cache.ts +68 -16
  263. package/src/tools/index.ts +10 -0
  264. package/src/tools/job.ts +21 -4
  265. package/src/tools/monitor.ts +8 -2
  266. package/src/tools/path-utils.ts +7 -0
  267. package/src/tools/read.ts +14 -2
  268. package/src/tools/render-utils.ts +1 -3
  269. package/src/tools/resource-gc.ts +28 -8
  270. package/src/tools/search.ts +116 -58
  271. package/src/tools/skill-discovery.ts +5 -0
  272. package/src/tools/skill.ts +37 -2
  273. package/src/tools/subagent.ts +6 -3
  274. package/src/tools/tool-catalog.generated.ts +10 -3
  275. package/src/tools/ultragoal-ask-guard.ts +2 -2
  276. package/src/web/insane/bridge.ts +113 -23
  277. package/src/web/search/providers/perplexity.ts +102 -41
@@ -1,10 +1,10 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { logger } from "@gajae-code/utils";
3
- import { SdkClientError } from "../client/client";
3
+ import { SdkPreparedDispatchError } from "../client/client";
4
4
  import { type SessionAttachment, SessionRouterError } from "../router";
5
5
 
6
6
  import type { ChatDeliveryError } from "./chat-daemon-runtime";
7
- import { ConversationStore } from "./conversation-store";
7
+ import { ConversationLockTimeoutError, ConversationStore } from "./conversation-store";
8
8
  import {
9
9
  acceptsSlackInbound,
10
10
  normalizeSlackConversation,
@@ -58,7 +58,9 @@ function slackPublicationClientMsgId(publicationId: string): string {
58
58
 
59
59
  // Durable filesystem publication leases must outlast one event-loop and persistence turn.
60
60
  const MIN_PUBLICATION_LEASE_MS = 100;
61
+ const INBOUND_REACTION_DEADLINE_MS = 5_000;
61
62
  const RETIREMENT_DRAIN_MS = 5_000;
63
+ const INBOUND_REACTION = "eyes";
62
64
 
63
65
  type SlackProviderWorkScope = {
64
66
  sessionId: string;
@@ -70,6 +72,8 @@ type SlackProviderWork = {
70
72
  scopeKey: string;
71
73
  invalidate(): Promise<void>;
72
74
  abort(): void;
75
+ /** Best-effort work is aborted before the graceful shutdown drain. */
76
+ abortOnStop?: boolean;
73
77
  terminalize(): Promise<void>;
74
78
  settled: Promise<void>;
75
79
  };
@@ -113,6 +117,8 @@ export interface SlackNotificationDaemonOptions {
113
117
  content: string,
114
118
  attachment: SessionAttachment,
115
119
  idempotencyKey: string,
120
+ beforeDispatch: () => void,
121
+ dispatchFence: <T>(dispatch: () => Promise<T>) => Promise<T>,
116
122
  ) => Promise<boolean>;
117
123
  }
118
124
 
@@ -221,6 +227,10 @@ export class SlackNotificationDaemon {
221
227
  readonly #activeWork = new Set<Promise<unknown>>();
222
228
  readonly #workInvalidators = new Set<SlackProviderWork>();
223
229
  readonly #retiredProviderWorkScopes = new Map<string, SlackRetiredProviderWorkScope>();
230
+ readonly #retiringAttachmentGenerations = new Set<string>();
231
+ readonly #closingSessions = new Map<string, number>();
232
+ readonly #closingInboundRoots = new Map<string, number>();
233
+ readonly #retiredInboundRoots = new Set<string>();
224
234
  readonly #rollovers = new Map<string, Promise<SlackRootPublication>>();
225
235
  #workGeneration = 0;
226
236
  #lifecycleTail: Promise<void> = Promise.resolve();
@@ -362,6 +372,7 @@ export class SlackNotificationDaemon {
362
372
  this.#startedGeneration = undefined;
363
373
  if (this.#providerRunningGeneration === lifecycleGeneration) this.#providerRunningGeneration = undefined;
364
374
  this.#clearLeaseRecoveryTimer();
375
+ for (const work of this.#workInvalidators) if (work.abortOnStop) work.abort();
365
376
  if (stopProvider) this.#providerStopRequestedGenerations.add(lifecycleGeneration);
366
377
  const providerStop = stopProvider ? Promise.resolve().then(() => this.options.provider.stop()) : undefined;
367
378
  const providerStopResult = providerStop?.then(
@@ -449,6 +460,7 @@ export class SlackNotificationDaemon {
449
460
 
450
461
  /** Accepted inbound effects are durably claimed before their Socket Mode ACK. */
451
462
  async handleEnvelope(envelope: SlackSocketEnvelope): Promise<boolean> {
463
+ const lifecycleGeneration = this.#lifecycleGeneration;
452
464
  if (!text(envelope.envelope_id)) return false;
453
465
  const inbound = messageFromEnvelope(envelope.payload);
454
466
  if (!inbound || inbound.teamId !== this.options.teamId || inbound.channelId !== this.options.channelId) {
@@ -479,6 +491,15 @@ export class SlackNotificationDaemon {
479
491
  this.#inflightInbound.add(inflightKey);
480
492
  try {
481
493
  await this.options.provider.ack(envelope.envelope_id);
494
+ const messageTs = text(inbound.event.ts);
495
+ if (messageTs)
496
+ this.#scheduleInboundReaction(
497
+ claim.sessionId,
498
+ claim.endpoint,
499
+ inbound.channelId,
500
+ messageTs,
501
+ lifecycleGeneration,
502
+ );
482
503
  return await this.#dispatchInbound(claim);
483
504
  } finally {
484
505
  this.#inflightInbound.delete(inflightKey);
@@ -491,6 +512,76 @@ export class SlackNotificationDaemon {
491
512
  return work.finally(() => this.#activeWork.delete(work));
492
513
  }
493
514
 
515
+ /** Track the best-effort acknowledgement in the owning attachment's provider-work scope. */
516
+ #scheduleInboundReaction(
517
+ sessionId: string,
518
+ endpoint: SessionAttachment,
519
+ channel: string,
520
+ timestamp: string,
521
+ lifecycleGeneration: number,
522
+ ): void {
523
+ const scope: SlackProviderWorkScope = {
524
+ sessionId,
525
+ endpointGeneration: endpoint.generation,
526
+ ...(endpoint.authorityId === undefined ? {} : { attachmentAuthorityId: endpoint.authorityId }),
527
+ };
528
+ const scopeKey = slackProviderWorkScopeKey(scope);
529
+ const workGeneration = this.#workGeneration;
530
+ const controller = new AbortController();
531
+ const settled = Promise.withResolvers<void>();
532
+ let abortKind: "deadline" | "lifecycle" | undefined;
533
+ const abort = (kind: "deadline" | "lifecycle"): void => {
534
+ abortKind ??= kind;
535
+ controller.abort();
536
+ };
537
+ const work: SlackProviderWork = {
538
+ scopeKey,
539
+ invalidate: async () => abort("lifecycle"),
540
+ abort: () => abort("lifecycle"),
541
+ abortOnStop: true,
542
+ terminalize: async () => abort("lifecycle"),
543
+ settled: settled.promise,
544
+ };
545
+ this.#workInvalidators.add(work);
546
+ const reaction = (async () => {
547
+ const deadline = setTimeout(() => abort("deadline"), INBOUND_REACTION_DEADLINE_MS);
548
+ try {
549
+ if (
550
+ workGeneration !== this.#workGeneration ||
551
+ lifecycleGeneration !== this.#lifecycleGeneration ||
552
+ this.#retiredProviderWorkScopes.has(scopeKey) ||
553
+ !endpoint.isCurrent()
554
+ ) {
555
+ abort("lifecycle");
556
+ return;
557
+ }
558
+ await this.options.provider.addReaction({
559
+ channel,
560
+ timestamp,
561
+ name: INBOUND_REACTION,
562
+ signal: controller.signal,
563
+ });
564
+ if (abortKind === "deadline")
565
+ logger.warn(
566
+ `Slack inbound acknowledgement reaction exceeded the ${INBOUND_REACTION_DEADLINE_MS}ms deadline and was aborted.`,
567
+ );
568
+ } catch {
569
+ if (abortKind !== "lifecycle")
570
+ logger.warn(
571
+ abortKind === "deadline"
572
+ ? `Slack inbound acknowledgement reaction exceeded the ${INBOUND_REACTION_DEADLINE_MS}ms deadline and was aborted.`
573
+ : "Slack inbound acknowledgement reaction failed.",
574
+ );
575
+ } finally {
576
+ clearTimeout(deadline);
577
+ this.#workInvalidators.delete(work);
578
+ settled.resolve();
579
+ this.#tryReleaseRetiredProviderWorkScope(scopeKey);
580
+ }
581
+ })();
582
+ void this.#track(reaction);
583
+ }
584
+
494
585
  async #invalidateActiveWork(): Promise<void> {
495
586
  this.#workGeneration += 1;
496
587
  await Promise.allSettled([...this.#workInvalidators].map(work => work.invalidate()));
@@ -597,7 +688,9 @@ export class SlackNotificationDaemon {
597
688
  }
598
689
 
599
690
  async postRoot(sessionId: string, body: string, endpointGeneration?: number): Promise<SlackConversation> {
600
- return (await this.#postRoot(sessionId, body, endpointGeneration)).conversation;
691
+ const conversation = (await this.#postRoot(sessionId, body, endpointGeneration)).conversation;
692
+ this.#admitActivatedRoot(conversation);
693
+ return conversation;
601
694
  }
602
695
 
603
696
  /**
@@ -631,7 +724,7 @@ export class SlackNotificationDaemon {
631
724
  authority.attachmentAuthorityId ??
632
725
  (attachment?.generation === authority.endpointGeneration ? attachment.authorityId : undefined);
633
726
  await this.#verifyExistingRoot(rootTs);
634
- return await claimSlackThreadBinding({
727
+ const conversation = await claimSlackThreadBinding({
635
728
  store: this.store,
636
729
  key: this.#intentKey(sessionId),
637
730
  teamId: this.options.teamId,
@@ -652,6 +745,8 @@ export class SlackNotificationDaemon {
652
745
  },
653
746
  now: this.#now,
654
747
  });
748
+ this.#admitActivatedRoot(conversation);
749
+ return conversation;
655
750
  }
656
751
 
657
752
  /**
@@ -883,6 +978,7 @@ export class SlackNotificationDaemon {
883
978
  ? await this.#resumeWithRootPublication(sessionId, body, generation, rootPublication?.clientMsgId)
884
979
  : await this.#postRoot(sessionId, body, generation, rootPublication?.clientMsgId);
885
980
  conversation = publication.conversation;
981
+ this.#admitActivatedRoot(conversation);
886
982
  bodyWasUsedAsRoot ||= publication.created;
887
983
  }
888
984
  if (!conversation.rootTs) return conversation;
@@ -1045,13 +1141,68 @@ export class SlackNotificationDaemon {
1045
1141
  }
1046
1142
 
1047
1143
  async close(sessionId: string, marker = "Session closed.", endpointGeneration?: number): Promise<boolean> {
1048
- return await this.#track(this.#close(sessionId, marker, true, endpointGeneration));
1144
+ return await this.#track(this.#closeWithFence(sessionId, marker, true, endpointGeneration));
1145
+ }
1146
+
1147
+ async #closeWithFence(
1148
+ sessionId: string,
1149
+ marker: string,
1150
+ allowRemovedAttachment: boolean,
1151
+ endpointGeneration?: number,
1152
+ ): Promise<boolean> {
1153
+ this.#incrementFence(this.#closingSessions, sessionId);
1154
+ let closingRootKey: string | undefined;
1155
+ try {
1156
+ return await this.#close(sessionId, marker, allowRemovedAttachment, endpointGeneration, record => {
1157
+ closingRootKey = `${sessionId}\u0000${this.#requireEndpointGeneration(record)}\u0000${record.rootTs}`;
1158
+ this.#incrementFence(this.#closingInboundRoots, closingRootKey);
1159
+ this.#decrementFence(this.#closingSessions, sessionId);
1160
+ });
1161
+ } finally {
1162
+ if (closingRootKey) this.#decrementFence(this.#closingInboundRoots, closingRootKey);
1163
+ else this.#decrementFence(this.#closingSessions, sessionId);
1164
+ }
1049
1165
  }
1050
1166
 
1051
1167
  async recoverCleanup(sessionId: string, endpointGeneration: number, authorityId?: string): Promise<boolean> {
1052
1168
  const found = await this.findSession(sessionId, true);
1053
1169
  if (!found) return true;
1054
- if (found.record.endpointGeneration !== endpointGeneration) return true;
1170
+ if (found.record.endpointGeneration !== endpointGeneration) {
1171
+ const effectId = found.record.cleanupEffectId;
1172
+ if (effectId?.startsWith("close-marker:")) {
1173
+ let inboundDispatches: SlackInboundDispatchReceipt[] = [];
1174
+ const closed = await this.store.transact(found.key, current => {
1175
+ if (
1176
+ current?.state !== "active" ||
1177
+ current.sessionId !== sessionId ||
1178
+ current.cleanupEffectId !== effectId
1179
+ )
1180
+ return current;
1181
+ inboundDispatches = [...(current.inboundDispatches ?? [])];
1182
+ return nextRecord(current, {
1183
+ state: "closed_marker",
1184
+ cleanupEffectId: undefined,
1185
+ pendingActionId: undefined,
1186
+ inboundDispatches: [],
1187
+ updatedAt: this.#now(),
1188
+ });
1189
+ });
1190
+ if (closed?.state === "closed_marker") {
1191
+ if (found.record.rootTs)
1192
+ this.#retireInboundRoot(
1193
+ `${sessionId}\u0000${this.#requireEndpointGeneration(found.record)}\u0000${found.record.rootTs}`,
1194
+ );
1195
+ await this.#journal.terminalizeDecided(effectId, { status: "stale_binding" });
1196
+ if (inboundDispatches.length > 0)
1197
+ await this.#retirementTerminalization(
1198
+ inboundDispatches.map(receipt =>
1199
+ this.#terminalizeInboundEffect(receipt.effectId, "stale_binding"),
1200
+ ),
1201
+ );
1202
+ }
1203
+ }
1204
+ return true;
1205
+ }
1055
1206
  if (found.record.state === "closed_marker" || found.record.state === "error") return true;
1056
1207
  if (
1057
1208
  (found.record.state === "posting_root" || found.record.state === "resumed_root") &&
@@ -1061,10 +1212,14 @@ export class SlackNotificationDaemon {
1061
1212
  if (found.record.state !== "active") return false;
1062
1213
  if (!found.record.rootTs) return false;
1063
1214
  if (found.record.attachmentAuthorityId === authorityId && found.record.cleanupEffectId === undefined) return true;
1064
- const effectId = `close-marker-cleanup:${sessionId}:${found.record.clientMsgId ?? found.record.rootTs}`;
1065
- if (found.record.cleanupEffectId !== effectId) return false;
1215
+ const effectId = found.record.cleanupEffectId;
1216
+ if (!effectId) return false;
1066
1217
  const effect = await this.#journal.read(effectId);
1067
- if (!effect || (effect.state === "terminal" && !effect.receipt?.messageId && !effect.receipt?.timestamp)) {
1218
+ if (!effect) {
1219
+ await this.close(sessionId, "Session closed.", endpointGeneration);
1220
+ return true;
1221
+ }
1222
+ if (effect.state === "terminal" && !effect.receipt?.messageId && !effect.receipt?.timestamp) {
1068
1223
  await this.store.transact(found.key, current =>
1069
1224
  current?.cleanupEffectId === effectId
1070
1225
  ? nextRecord(current, { cleanupEffectId: undefined, updatedAt: this.#now() })
@@ -1077,8 +1232,19 @@ export class SlackNotificationDaemon {
1077
1232
  }
1078
1233
 
1079
1234
  async retireAttachment(sessionId: string, endpointGeneration: number): Promise<void> {
1080
- const found = await this.findSession(sessionId, true);
1081
- if (found?.record.endpointGeneration !== endpointGeneration) return;
1235
+ const generationKey = `${sessionId}\u0000${endpointGeneration}`;
1236
+ this.#retiringAttachmentGenerations.add(generationKey);
1237
+ let found: { key: string; record: SlackConversation } | undefined;
1238
+ try {
1239
+ found = await this.findSession(sessionId, true);
1240
+ } catch (error) {
1241
+ this.#retiringAttachmentGenerations.delete(generationKey);
1242
+ throw error;
1243
+ }
1244
+ if (found?.record.endpointGeneration !== endpointGeneration) {
1245
+ this.#retiringAttachmentGenerations.delete(generationKey);
1246
+ return;
1247
+ }
1082
1248
  const attachmentAuthorityId = found.record.attachmentAuthorityId;
1083
1249
  const scope: SlackProviderWorkScope = {
1084
1250
  sessionId,
@@ -1089,6 +1255,7 @@ export class SlackNotificationDaemon {
1089
1255
  let releaseScope = false;
1090
1256
  try {
1091
1257
  const draining = this.#invalidateAndDrainProviderWork(scope, deadline);
1258
+ releaseScope = true;
1092
1259
  let inboundDispatches: SlackInboundDispatchReceipt[] = [];
1093
1260
  // Persist the mapping fence before waiting on terminal effects. A detached
1094
1261
  // terminalization can then never be replayed through this attachment.
@@ -1108,11 +1275,10 @@ export class SlackNotificationDaemon {
1108
1275
  updatedAt: this.#now(),
1109
1276
  });
1110
1277
  });
1111
- releaseScope = true;
1112
1278
  await draining;
1113
1279
  if (inboundDispatches.length === 0) return;
1114
1280
  const terminalization = this.#retirementTerminalization(
1115
- inboundDispatches.map(receipt => this.#journal.terminalize(receipt.effectId, { status: "stale_binding" })),
1281
+ inboundDispatches.map(receipt => this.#terminalizeInboundEffect(receipt.effectId, "stale_binding")),
1116
1282
  );
1117
1283
  this.#trackRetirementTerminalization(slackProviderWorkScopeKey(scope), terminalization);
1118
1284
  const remaining = deadline - this.#now();
@@ -1128,6 +1294,7 @@ export class SlackNotificationDaemon {
1128
1294
  logger.warn("Slack attachment retirement exceeded the 5000ms deadline; fenced pending inbound effects.");
1129
1295
  } finally {
1130
1296
  if (releaseScope) this.#releaseRetiredProviderWorkScope(scope);
1297
+ this.#retiringAttachmentGenerations.delete(generationKey);
1131
1298
  }
1132
1299
  }
1133
1300
 
@@ -1136,13 +1303,17 @@ export class SlackNotificationDaemon {
1136
1303
  marker: string,
1137
1304
  allowRemovedAttachment: boolean,
1138
1305
  expectedEndpointGeneration?: number,
1306
+ onResolved?: (record: SlackConversation) => void,
1139
1307
  ): Promise<boolean> {
1140
1308
  const found = await this.findSession(sessionId, true);
1141
1309
  if (!found?.record.rootTs || found.record.state !== "active") return false;
1142
1310
  if (expectedEndpointGeneration !== undefined && found.record.endpointGeneration !== expectedEndpointGeneration)
1143
1311
  return false;
1144
- const effectId = `${allowRemovedAttachment ? "close-marker-cleanup" : "close-marker"}:${sessionId}:${found.record.clientMsgId ?? found.record.rootTs}`;
1145
- if (allowRemovedAttachment) {
1312
+ onResolved?.(found.record);
1313
+ const effectId =
1314
+ found.record.cleanupEffectId ??
1315
+ `${allowRemovedAttachment ? "close-marker-cleanup" : "close-marker"}:${sessionId}:${found.record.clientMsgId ?? found.record.rootTs}`;
1316
+ {
1146
1317
  let admitted = false;
1147
1318
  await this.store.transact(found.key, current => {
1148
1319
  if (
@@ -1183,6 +1354,7 @@ export class SlackNotificationDaemon {
1183
1354
  );
1184
1355
  await this.#postDurable(effectId, sessionId, this.#requireEndpointGeneration(found.record), durable.payload);
1185
1356
  let closed = false;
1357
+ let inboundDispatches: SlackInboundDispatchReceipt[] = [];
1186
1358
  await this.store.transact(found.key, current => {
1187
1359
  if (
1188
1360
  current?.state !== "active" ||
@@ -1194,13 +1366,24 @@ export class SlackNotificationDaemon {
1194
1366
  )
1195
1367
  return current;
1196
1368
  closed = true;
1369
+ inboundDispatches = [...(current.inboundDispatches ?? [])];
1197
1370
  return nextRecord(current, {
1198
1371
  state: "closed_marker",
1199
1372
  cleanupEffectId: undefined,
1200
1373
  pendingActionId: undefined,
1374
+ inboundDispatches: [],
1201
1375
  updatedAt: this.#now(),
1202
1376
  });
1203
1377
  });
1378
+ if (closed) {
1379
+ this.#retireInboundRoot(
1380
+ `${sessionId}\u0000${this.#requireEndpointGeneration(found.record)}\u0000${found.record.rootTs}`,
1381
+ );
1382
+ }
1383
+ if (closed && inboundDispatches.length > 0)
1384
+ await this.#retirementTerminalization(
1385
+ inboundDispatches.map(receipt => this.#terminalizeInboundEffect(receipt.effectId, "stale_binding")),
1386
+ );
1204
1387
 
1205
1388
  return closed;
1206
1389
  }
@@ -1211,7 +1394,9 @@ export class SlackNotificationDaemon {
1211
1394
  endpointGeneration?: number,
1212
1395
  publicationId?: string,
1213
1396
  ): Promise<SlackConversation> {
1214
- return await this.#track(this.#resume(sessionId, body, endpointGeneration, publicationId));
1397
+ const conversation = await this.#track(this.#resume(sessionId, body, endpointGeneration, publicationId));
1398
+ this.#admitActivatedRoot(conversation);
1399
+ return conversation;
1215
1400
  }
1216
1401
 
1217
1402
  async #resume(
@@ -1330,7 +1515,7 @@ export class SlackNotificationDaemon {
1330
1515
  }
1331
1516
  let closed = false;
1332
1517
  try {
1333
- closed = await this.#track(this.#close(sessionId, "Session closed.", false, previousGeneration));
1518
+ closed = await this.#track(this.#closeWithFence(sessionId, "Session closed.", false, previousGeneration));
1334
1519
  } catch (error) {
1335
1520
  if (!(error instanceof SlackStaleEffectError)) throw error;
1336
1521
  // The old-generation close marker was deliberately suppressed after
@@ -1496,7 +1681,11 @@ export class SlackNotificationDaemon {
1496
1681
  updatedAt: this.#now(),
1497
1682
  });
1498
1683
  });
1499
- return sessionId && receipt ? { key, endpoint, sessionId, receipt } : undefined;
1684
+ if (!sessionId || !receipt) {
1685
+ await this.#terminalizeInboundEffect(effectId, "rejected");
1686
+ return undefined;
1687
+ }
1688
+ return { key, endpoint, sessionId, receipt };
1500
1689
  }
1501
1690
 
1502
1691
  async #dispatchInbound(claim: {
@@ -1512,6 +1701,7 @@ export class SlackNotificationDaemon {
1512
1701
  await this.#finalizeTerminalInboundDispatch(claim.key, claim.receipt);
1513
1702
  return false;
1514
1703
  }
1704
+ if (effect?.state === "uncertain") return false;
1515
1705
  if (
1516
1706
  !current ||
1517
1707
  !this.#mappedInboundDispatchable(current, claim.receipt, effect) ||
@@ -1548,18 +1738,40 @@ export class SlackNotificationDaemon {
1548
1738
  ? {}
1549
1739
  : { attachmentAuthorityId: claim.receipt.attachmentAuthorityId }),
1550
1740
  };
1741
+ let dispatchEntered = false;
1551
1742
  try {
1552
1743
  if (effect.payload.type === "command") {
1553
1744
  const payload = effect.payload;
1554
- const accepted = await this.#withEffectLease(effect.id, lease, workScope, async () => {
1555
- if (!(await this.#inboundEffectCurrent(claim, effect.id))) throw new SlackStaleEffectError();
1556
- return await (this.options.onCommand?.(
1557
- claim.sessionId,
1558
- payload.content,
1559
- claim.endpoint,
1560
- payload.idempotencyKey,
1561
- ) ?? Promise.resolve(false));
1562
- });
1745
+ const accepted = await this.#withEffectLease(
1746
+ effect.id,
1747
+ lease,
1748
+ workScope,
1749
+ async () => {
1750
+ if (!(await this.#inboundEffectCurrent(claim, effect.id))) throw new SlackStaleEffectError();
1751
+ if (!this.#inboundEffectAdmitted(claim, payload.routing.rootTs)) throw new SlackStaleEffectError();
1752
+ if (!(await this.#journal.markDispatching(effect.id, lease)))
1753
+ throw new Error("Slack inbound command dispatch boundary could not be persisted");
1754
+ if (
1755
+ !(await this.#inboundEffectCurrent(claim, effect.id)) ||
1756
+ !this.#inboundEffectAdmitted(claim, payload.routing.rootTs) ||
1757
+ !(await this.#journal.ownsDispatching(effect.id, lease)) ||
1758
+ !this.#inboundEffectAdmitted(claim, payload.routing.rootTs)
1759
+ )
1760
+ throw new SlackStaleEffectError();
1761
+ const boundary = this.#durableDispatchBoundary(claim, payload.routing.rootTs, effect.id, lease);
1762
+ dispatchEntered = true;
1763
+ return await (this.options.onCommand?.(
1764
+ claim.sessionId,
1765
+ payload.content,
1766
+ claim.endpoint,
1767
+ payload.idempotencyKey,
1768
+ boundary.beforeDispatch,
1769
+ boundary.dispatchFence,
1770
+ ) ?? Promise.resolve(false));
1771
+ },
1772
+ this.#workGeneration,
1773
+ true,
1774
+ );
1563
1775
  if (!(await this.#inboundEffectCurrent(claim, effect.id))) {
1564
1776
  await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_binding");
1565
1777
  return false;
@@ -1570,10 +1782,30 @@ export class SlackNotificationDaemon {
1570
1782
  if (recorded) await this.#finishDispatch(claim, "terminal");
1571
1783
  return accepted && !!recorded;
1572
1784
  }
1573
- await this.#withEffectLease(effect.id, lease, workScope, async () => {
1574
- if (!(await this.#inboundEffectCurrent(claim, effect.id))) throw new SlackStaleEffectError();
1575
- await claim.endpoint.send(effect.payload);
1576
- });
1785
+ await this.#withEffectLease(
1786
+ effect.id,
1787
+ lease,
1788
+ workScope,
1789
+ async () => {
1790
+ if (!(await this.#inboundEffectCurrent(claim, effect.id))) throw new SlackStaleEffectError();
1791
+ if (!this.#inboundEffectAdmitted(claim, effect.payload.routing.rootTs))
1792
+ throw new SlackStaleEffectError();
1793
+ if (!(await this.#journal.markDispatching(effect.id, lease)))
1794
+ throw new Error("Slack inbound SDK dispatch boundary could not be persisted");
1795
+ if (
1796
+ !(await this.#inboundEffectCurrent(claim, effect.id)) ||
1797
+ !this.#inboundEffectAdmitted(claim, effect.payload.routing.rootTs) ||
1798
+ !(await this.#journal.ownsDispatching(effect.id, lease)) ||
1799
+ !this.#inboundEffectAdmitted(claim, effect.payload.routing.rootTs)
1800
+ )
1801
+ throw new SlackStaleEffectError();
1802
+ const boundary = this.#durableDispatchBoundary(claim, effect.payload.routing.rootTs, effect.id, lease);
1803
+ dispatchEntered = true;
1804
+ await claim.endpoint.send(effect.payload, boundary);
1805
+ },
1806
+ this.#workGeneration,
1807
+ true,
1808
+ );
1577
1809
  if (!(await this.#inboundEffectCurrent(claim, effect.id))) {
1578
1810
  await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_binding");
1579
1811
  return false;
@@ -1586,7 +1818,7 @@ export class SlackNotificationDaemon {
1586
1818
  await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_binding");
1587
1819
  return false;
1588
1820
  }
1589
- const state = this.#isDefiniteSdkPreSendFailure(error) ? "accepted" : "uncertain";
1821
+ const state = this.#isDefiniteSdkPreSendFailure(error) || !dispatchEntered ? "accepted" : "uncertain";
1590
1822
 
1591
1823
  const recorded = await this.#rescheduleAfterEffectTransition(
1592
1824
  this.#journal.record(effect.id, lease, state, { status: state }),
@@ -1602,6 +1834,18 @@ export class SlackNotificationDaemon {
1602
1834
 
1603
1835
  effectId: string,
1604
1836
  ): Promise<boolean> {
1837
+ if (
1838
+ this.#retiredProviderWorkScopes.has(
1839
+ slackProviderWorkScopeKey({
1840
+ sessionId: claim.sessionId,
1841
+ endpointGeneration: claim.receipt.endpointGeneration,
1842
+ ...(claim.receipt.attachmentAuthorityId === undefined
1843
+ ? {}
1844
+ : { attachmentAuthorityId: claim.receipt.attachmentAuthorityId }),
1845
+ }),
1846
+ )
1847
+ )
1848
+ return false;
1605
1849
  const endpoint = await this.#resolveAttachment(claim.sessionId);
1606
1850
  if (!endpoint?.isCurrent()) return false;
1607
1851
  if (
@@ -1628,12 +1872,120 @@ export class SlackNotificationDaemon {
1628
1872
  );
1629
1873
  }
1630
1874
 
1875
+ #inboundEffectAdmitted(
1876
+ claim: {
1877
+ endpoint: SessionAttachment;
1878
+ sessionId: string;
1879
+ receipt: SlackInboundDispatchReceipt;
1880
+ },
1881
+ rootTs: string,
1882
+ ): boolean {
1883
+ return (
1884
+ claim.endpoint.isCurrent() &&
1885
+ !this.#closingSessions.has(claim.sessionId) &&
1886
+ !this.#closingInboundRoots.has(`${claim.sessionId}\u0000${claim.receipt.endpointGeneration}\u0000${rootTs}`) &&
1887
+ !this.#retiredInboundRoots.has(`${claim.sessionId}\u0000${claim.receipt.endpointGeneration}\u0000${rootTs}`) &&
1888
+ !this.#retiringAttachmentGenerations.has(`${claim.sessionId}\u0000${claim.receipt.endpointGeneration}`) &&
1889
+ !this.#retiredProviderWorkScopes.has(
1890
+ slackProviderWorkScopeKey({
1891
+ sessionId: claim.sessionId,
1892
+ endpointGeneration: claim.receipt.endpointGeneration,
1893
+ ...(claim.receipt.attachmentAuthorityId === undefined
1894
+ ? {}
1895
+ : { attachmentAuthorityId: claim.receipt.attachmentAuthorityId }),
1896
+ }),
1897
+ )
1898
+ );
1899
+ }
1900
+
1901
+ #durableDispatchBoundary(
1902
+ claim: {
1903
+ key: string;
1904
+ endpoint: SessionAttachment;
1905
+ sessionId: string;
1906
+ receipt: SlackInboundDispatchReceipt;
1907
+ },
1908
+ rootTs: string,
1909
+ effectId: string,
1910
+ lease: ChatEffectLease,
1911
+ ): {
1912
+ beforeDispatch: () => void;
1913
+ dispatchFence: <T>(dispatch: () => Promise<T>) => Promise<T>;
1914
+ } {
1915
+ const wire = Promise.withResolvers<void>();
1916
+ let wireReached = false;
1917
+ return {
1918
+ beforeDispatch: () => {
1919
+ if (!this.#inboundEffectAdmitted(claim, rootTs)) throw new SlackStaleEffectError();
1920
+ wireReached = true;
1921
+ wire.resolve();
1922
+ },
1923
+ dispatchFence: async dispatch => {
1924
+ const result = await (async () => {
1925
+ try {
1926
+ return await this.store.dispatchWithSnapshotFence(claim.key, current => {
1927
+ if (
1928
+ !current ||
1929
+ current.sessionId !== claim.sessionId ||
1930
+ !acceptsSlackInbound(current, rootTs, claim.receipt.endpointGeneration) ||
1931
+ current.attachmentAuthorityId !== claim.receipt.attachmentAuthorityId ||
1932
+ !(current.inboundDispatches ?? []).some(receipt =>
1933
+ this.#sameInboundReceipt(receipt, claim.receipt),
1934
+ )
1935
+ )
1936
+ return undefined;
1937
+ const result = this.#journal
1938
+ .dispatchWithLeaseFence(effectId, lease, dispatch, wire.promise)
1939
+ .then(value => {
1940
+ if (!value) throw new SlackStaleEffectError();
1941
+ return value.value;
1942
+ });
1943
+ return { result, dispatched: wire.promise };
1944
+ });
1945
+ } catch (error) {
1946
+ if (error instanceof ConversationLockTimeoutError && !wireReached)
1947
+ throw new SessionRouterError("pre_send", "Slack durable dispatch fence timed out before send.");
1948
+ throw error;
1949
+ }
1950
+ })();
1951
+ if (result === undefined) throw new SlackStaleEffectError();
1952
+ return result.value;
1953
+ },
1954
+ };
1955
+ }
1956
+
1957
+ #admitActivatedRoot(conversation: SlackConversation): void {
1958
+ if (!conversation.sessionId || !conversation.endpointGeneration || !conversation.rootTs) return;
1959
+ const rootKey = `${conversation.sessionId}\u0000${conversation.endpointGeneration}\u0000${conversation.rootTs}`;
1960
+ if (this.#retiredInboundRoots.has(rootKey)) return;
1961
+ }
1962
+
1963
+ #retireInboundRoot(rootKey: string): void {
1964
+ this.#retiredInboundRoots.add(rootKey);
1965
+ while (this.#retiredInboundRoots.size > MAX_TERMINAL_CHAT_EFFECTS * 2) {
1966
+ const oldest = this.#retiredInboundRoots.values().next().value;
1967
+ if (oldest === undefined) break;
1968
+ this.#retiredInboundRoots.delete(oldest);
1969
+ }
1970
+ }
1971
+
1972
+ #incrementFence(fences: Map<string, number>, key: string): void {
1973
+ fences.set(key, (fences.get(key) ?? 0) + 1);
1974
+ }
1975
+
1976
+ #decrementFence(fences: Map<string, number>, key: string): void {
1977
+ const remaining = (fences.get(key) ?? 1) - 1;
1978
+ if (remaining === 0) fences.delete(key);
1979
+ else fences.set(key, remaining);
1980
+ }
1981
+
1631
1982
  async #withEffectLease<T>(
1632
1983
  id: string,
1633
1984
  lease: ChatEffectLease,
1634
1985
  scope: SlackProviderWorkScope,
1635
1986
  operation: (signal: AbortSignal) => Promise<T>,
1636
1987
  workGeneration = this.#workGeneration,
1988
+ preserveAmbiguityOnRetire = false,
1637
1989
  ): Promise<T> {
1638
1990
  const scopeKey = slackProviderWorkScopeKey(scope);
1639
1991
  const controller = new AbortController();
@@ -1652,13 +2004,16 @@ export class SlackNotificationDaemon {
1652
2004
  await invalidation;
1653
2005
  };
1654
2006
  const terminalize = async (): Promise<void> => {
2007
+ if (preserveAmbiguityOnRetire && (await this.#journal.read(id))?.state === "dispatching")
2008
+ return await invalidate();
1655
2009
  abort();
1656
2010
  terminalization ??= (async (): Promise<void> => {
1657
2011
  const receipt = { status: "stale_binding" };
1658
2012
  const terminalized = await this.#rescheduleAfterEffectTransition(
1659
- this.#journal.terminalize(id, receipt, lease),
2013
+ this.#journal.terminalizeDecided(id, receipt, lease),
1660
2014
  );
1661
- if (!terminalized) await this.#rescheduleAfterEffectTransition(this.#journal.terminalize(id, receipt));
2015
+ if (!terminalized)
2016
+ await this.#rescheduleAfterEffectTransition(this.#journal.terminalizeDecided(id, receipt));
1662
2017
  })();
1663
2018
  await terminalization;
1664
2019
  };
@@ -1729,11 +2084,23 @@ export class SlackNotificationDaemon {
1729
2084
  const dispatchableEffectIds = new Set<string>();
1730
2085
  for (const [key, record] of Object.entries(document.conversations)) {
1731
2086
  for (const receipt of record.inboundDispatches ?? []) {
1732
- const effect = await this.#journal.read<SlackInboundEffectPayload>(receipt.effectId);
2087
+ let effect = await this.#journal.read<SlackInboundEffectPayload>(receipt.effectId);
2088
+ if (effect?.state === "dispatching") {
2089
+ const inflightKey = `${key}\u0000${receipt.key}`;
2090
+ if (this.#inflightInbound.has(inflightKey) || (effect.leaseExpiresAt ?? 0) > this.#now()) continue;
2091
+ effect = await this.#journal.recoverDispatchingAsUncertain<SlackInboundEffectPayload>(effect.id, {
2092
+ onlyIfExpired: true,
2093
+ });
2094
+ }
1733
2095
  if (effect?.state === "terminal") {
1734
2096
  await this.#finalizeTerminalInboundDispatch(key, receipt);
1735
2097
  continue;
1736
2098
  }
2099
+ // A sent command whose response was lost is permanently fail-closed.
2100
+ // Attachment or mapping drift cannot turn that unknown remote outcome
2101
+ // into a definite stale refusal: doing so would erase the ambiguity and
2102
+ // make a duplicate provider envelope eligible for a new dispatch.
2103
+ if (effect?.state === "uncertain") continue;
1737
2104
  if (!this.#mappedInboundDispatchable(record, receipt, effect)) {
1738
2105
  await this.#terminalizeStaleInboundDispatch(key, receipt, "stale_mapping");
1739
2106
  continue;
@@ -1747,11 +2114,7 @@ export class SlackNotificationDaemon {
1747
2114
  await this.#terminalizeStaleInboundDispatch(key, receipt, "stale_binding");
1748
2115
  continue;
1749
2116
  }
1750
- if (
1751
- effect.state === "uncertain" ||
1752
- (effect.state === "leased" && (effect.leaseExpiresAt ?? 0) > this.#now())
1753
- )
1754
- continue;
2117
+ if (effect.state === "leased" && (effect.leaseExpiresAt ?? 0) > this.#now()) continue;
1755
2118
  dispatchableEffectIds.add(receipt.effectId);
1756
2119
  const inflightKey = `${key}\u0000${receipt.key}`;
1757
2120
  if (this.#inflightInbound.has(inflightKey)) continue;
@@ -1764,6 +2127,11 @@ export class SlackNotificationDaemon {
1764
2127
  }
1765
2128
  }
1766
2129
  for (const effect of await this.#journal.list()) {
2130
+ if (effect.state === "dispatching") {
2131
+ if ((effect.leaseExpiresAt ?? 0) > this.#now()) continue;
2132
+ await this.#journal.recoverDispatchingAsUncertain(effect.id, { onlyIfExpired: true });
2133
+ continue;
2134
+ }
1767
2135
  if (
1768
2136
  dispatchableEffectIds.has(effect.id) ||
1769
2137
  effect.transport !== "slack" ||
@@ -1774,6 +2142,7 @@ export class SlackNotificationDaemon {
1774
2142
  effect.kind !== "sdk.inbound.user_message")
1775
2143
  )
1776
2144
  continue;
2145
+ if (effect.state === "uncertain") continue;
1777
2146
  const adopted = await this.#adoptOrphanInbound(effect as ChatEffect<SlackInboundEffectPayload>);
1778
2147
  if (!adopted) continue;
1779
2148
  const endpoint = await this.#resolveAttachment(adopted.sessionId);
@@ -1788,8 +2157,7 @@ export class SlackNotificationDaemon {
1788
2157
  await this.#terminalizeStaleInboundDispatch(adopted.key, adopted.receipt, "stale_binding");
1789
2158
  continue;
1790
2159
  }
1791
- if (effect.state === "uncertain" || (effect.state === "leased" && (effect.leaseExpiresAt ?? 0) > this.#now()))
1792
- continue;
2160
+ if (effect.state === "leased" && (effect.leaseExpiresAt ?? 0) > this.#now()) continue;
1793
2161
  await this.#dispatchInbound({
1794
2162
  key: adopted.key,
1795
2163
  endpoint,
@@ -1925,8 +2293,8 @@ export class SlackNotificationDaemon {
1925
2293
  receipt: SlackInboundDispatchReceipt,
1926
2294
  status: "stale_binding" | "stale_mapping",
1927
2295
  ): Promise<void> {
1928
- await this.#journal.terminalize(receipt.effectId, { status });
1929
- await this.#finalizeTerminalInboundDispatch(key, receipt);
2296
+ if (await this.#terminalizeInboundEffect(receipt.effectId, status))
2297
+ await this.#finalizeTerminalInboundDispatch(key, receipt);
1930
2298
  }
1931
2299
  async #finalizeTerminalInboundDispatch(key: string, receipt: SlackInboundDispatchReceipt): Promise<void> {
1932
2300
  await this.store.transact(key, current => {
@@ -2020,7 +2388,8 @@ export class SlackNotificationDaemon {
2020
2388
  current.seenEventIds.includes(routing.eventId) ||
2021
2389
  current.seenInteractionIds.includes(routing.interactionId) ||
2022
2390
  current.seenRetryKeys.includes(routing.retryKey) ||
2023
- (routing.kind === "action" && replyableActionId(current.pendingActionId) !== routing.actionId)
2391
+ (routing.kind === "action" && replyableActionId(current.pendingActionId) !== routing.actionId) ||
2392
+ (routing.kind === "message" && replyableActionId(current.pendingActionId) !== undefined)
2024
2393
  )
2025
2394
  return current;
2026
2395
  receipt = {
@@ -2049,7 +2418,32 @@ export class SlackNotificationDaemon {
2049
2418
  }
2050
2419
 
2051
2420
  async #terminalizeRejectedInbound(effectId: string): Promise<void> {
2052
- await this.#journal.terminalize(effectId, { status: "rejected" });
2421
+ await this.#terminalizeInboundEffect(effectId, "rejected");
2422
+ }
2423
+
2424
+ async #terminalizeInboundEffect(
2425
+ effectId: string,
2426
+ status: "rejected" | "stale_binding" | "stale_mapping",
2427
+ ): Promise<boolean> {
2428
+ let effect = await this.#journal.read(effectId);
2429
+ if (!effect || effect.state === "terminal") return true;
2430
+ if (effect.state === "uncertain") return false;
2431
+ let lease: ChatEffectLease | undefined;
2432
+ if (effect.state === "leased") {
2433
+ if ((effect.leaseExpiresAt ?? 0) > this.#now()) return false;
2434
+ const claimed = await this.#journal.claim(
2435
+ effectId,
2436
+ this.#publicationOwnerId,
2437
+ Math.max(this.#publicationLeaseMs, 100),
2438
+ );
2439
+ if (!claimed) {
2440
+ effect = await this.#journal.read(effectId);
2441
+ return effect?.state === "terminal";
2442
+ }
2443
+ lease = { owner: this.#publicationOwnerId, epoch: claimed.epoch };
2444
+ }
2445
+ const terminal = await this.#journal.terminalizeDecided(effectId, { status }, lease);
2446
+ return terminal?.state === "terminal";
2053
2447
  }
2054
2448
 
2055
2449
  async #finishDispatch(
@@ -2135,7 +2529,7 @@ export class SlackNotificationDaemon {
2135
2529
  return;
2136
2530
  // Receipt recovery must retain the original mapping through an endpoint roll;
2137
2531
  // inbound routing still fences it against the now-current endpoint generation.
2138
- await this.store.transact(this.#intentKey(effect.sessionId), current =>
2532
+ const activated = await this.store.transact(this.#intentKey(effect.sessionId), current =>
2139
2533
  current &&
2140
2534
  current.state === "posting_root" &&
2141
2535
  current.sessionId === effect.sessionId &&
@@ -2154,6 +2548,13 @@ export class SlackNotificationDaemon {
2154
2548
  })
2155
2549
  : current,
2156
2550
  );
2551
+ if (
2552
+ activated?.state === "active" &&
2553
+ activated.sessionId === effect.sessionId &&
2554
+ activated.endpointGeneration === effect.endpointGeneration &&
2555
+ activated.rootTs === rootTs
2556
+ )
2557
+ this.#admitActivatedRoot(activated);
2157
2558
  }
2158
2559
 
2159
2560
  async #activateReconciledAction(
@@ -2345,7 +2746,8 @@ export class SlackNotificationDaemon {
2345
2746
  .reduce<number | undefined>(
2346
2747
  (earliest, effect) => {
2347
2748
  const claimAt =
2348
- effect.state === "leased" && Number.isFinite(effect.leaseExpiresAt)
2749
+ (effect.state === "leased" || effect.state === "dispatching") &&
2750
+ Number.isFinite(effect.leaseExpiresAt)
2349
2751
  ? effect.leaseExpiresAt
2350
2752
  : effect.state === "pending" ||
2351
2753
  effect.state === "accepted" ||
@@ -2438,6 +2840,15 @@ export class SlackNotificationDaemon {
2438
2840
  const threadTs = payload.threadTs;
2439
2841
  const records = Object.values((await this.store.load()).conversations);
2440
2842
  if (typeof threadTs === "string") {
2843
+ if (effect.id.startsWith("close-marker-cleanup:") || effect.id.startsWith("close-marker:"))
2844
+ return records.some(
2845
+ record =>
2846
+ record.sessionId === effect.sessionId &&
2847
+ (record.state === "active" || record.state === "resumed_root") &&
2848
+ record.rootTs === threadTs &&
2849
+ record.endpointGeneration === effect.endpointGeneration &&
2850
+ record.cleanupEffectId === effect.id,
2851
+ );
2441
2852
  return records.some(
2442
2853
  record =>
2443
2854
  record.sessionId === effect.sessionId &&
@@ -2603,6 +3014,23 @@ export class SlackNotificationDaemon {
2603
3014
  }
2604
3015
  if (workGeneration !== this.#workGeneration)
2605
3016
  throw new Error(`Slack effect ${id} lost its shutdown fence`);
3017
+ if (id.startsWith("close-marker:") || id.startsWith("close-marker-cleanup:")) {
3018
+ const sent = await this.store.dispatchWithSnapshotFence(this.#intentKey(sessionId), current => {
3019
+ if (
3020
+ !current ||
3021
+ current.sessionId !== sessionId ||
3022
+ current.cleanupEffectId !== id ||
3023
+ current.endpointGeneration !== endpointGeneration
3024
+ )
3025
+ return undefined;
3026
+ return {
3027
+ result: this.options.provider.postMessage({ ...effect!.payload, signal }),
3028
+ dispatched: Promise.resolve(),
3029
+ };
3030
+ });
3031
+ if (!sent) throw new SlackStaleEffectError();
3032
+ return sent.value;
3033
+ }
2606
3034
  return await this.options.provider.postMessage({ ...effect!.payload, signal });
2607
3035
  },
2608
3036
  workGeneration,
@@ -2621,6 +3049,7 @@ export class SlackNotificationDaemon {
2621
3049
 
2622
3050
  return posted;
2623
3051
  } catch (error) {
3052
+ if (error instanceof ConversationLockTimeoutError) throw error;
2624
3053
  if (error instanceof SlackReconciledAbsentEffectError) {
2625
3054
  await this.#rescheduleAfterEffectTransition(
2626
3055
  this.#journal.record(id, lease, "terminal", { provider: "slack", status: "not_found" }),
@@ -2933,9 +3362,9 @@ export class SlackNotificationDaemon {
2933
3362
  );
2934
3363
  }
2935
3364
  #isDefiniteSdkPreSendFailure(error: unknown): boolean {
3365
+ if (error instanceof SdkPreparedDispatchError) return true;
2936
3366
  if (error instanceof SlackEndpointBindingError) return true;
2937
3367
  if (error instanceof SessionRouterError) return error.phase === "pre_send";
2938
- if (error instanceof SdkClientError) return error.code === "connection_closed";
2939
3368
  return (
2940
3369
  error instanceof Error &&
2941
3370
  error.name === "ChatDeliveryError" &&