@gajae-code/coding-agent 0.16.4 → 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 (198) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli/customize-doctor.d.ts +22 -0
  4. package/dist/types/config/file-lock.d.ts +31 -3
  5. package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
  6. package/dist/types/coordinator-mcp/server.d.ts +7 -0
  7. package/dist/types/discovery/helpers.d.ts +33 -0
  8. package/dist/types/exec/bash-executor.d.ts +17 -1
  9. package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
  10. package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
  11. package/dist/types/exec/bash-shell-signals.d.ts +2 -0
  12. package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
  13. package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
  14. package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
  15. package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
  16. package/dist/types/exec/bash-shell-worker.d.ts +1 -0
  17. package/dist/types/exec/isolated-shell.d.ts +19 -0
  18. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
  19. package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
  20. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +46 -6
  22. package/dist/types/extensibility/extensions/types.d.ts +16 -0
  23. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
  24. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
  25. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  26. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  27. package/dist/types/extensibility/runtime-skill-discovery.d.ts +4 -1
  28. package/dist/types/extensibility/skill-management.d.ts +9 -4
  29. package/dist/types/extensibility/skills.d.ts +2 -0
  30. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
  31. package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +12 -0
  33. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
  34. package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
  35. package/dist/types/modes/components/bash-execution.d.ts +1 -0
  36. package/dist/types/modes/components/execution-shared.d.ts +1 -0
  37. package/dist/types/modes/interactive-mode.d.ts +2 -0
  38. package/dist/types/modes/types.d.ts +6 -0
  39. package/dist/types/sdk/broker/ensure.d.ts +32 -0
  40. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
  43. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
  44. package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
  45. package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
  47. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  48. package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
  49. package/dist/types/sdk/client/client.d.ts +6 -0
  50. package/dist/types/sdk/host/host.d.ts +3 -1
  51. package/dist/types/sdk/host/session-runtime.d.ts +4 -2
  52. package/dist/types/sdk/lifecycle/service.d.ts +1 -0
  53. package/dist/types/sdk/router/session-router.d.ts +10 -2
  54. package/dist/types/sdk/session.d.ts +5 -1
  55. package/dist/types/session/agent-session.d.ts +2 -0
  56. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  57. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  58. package/dist/types/session/messages.d.ts +1 -0
  59. package/dist/types/session/session-manager.d.ts +3 -1
  60. package/dist/types/setup/paseo/announce.d.ts +4 -3
  61. package/dist/types/skill-state/canonical-skills.d.ts +4 -0
  62. package/dist/types/system-prompt.d.ts +8 -0
  63. package/dist/types/task/executor.d.ts +3 -0
  64. package/dist/types/tools/bash.d.ts +1 -0
  65. package/dist/types/tools/github-cache.d.ts +2 -1
  66. package/dist/types/tools/index.d.ts +2 -0
  67. package/package.json +9 -7
  68. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  69. package/src/capability/index.ts +14 -2
  70. package/src/capability/types.ts +4 -0
  71. package/src/cli/customize-doctor.ts +155 -12
  72. package/src/cli.ts +22 -0
  73. package/src/commands/sdk.ts +74 -31
  74. package/src/config/file-lock-gc.ts +40 -2
  75. package/src/config/file-lock.ts +198 -14
  76. package/src/config/model-preset-registry.ts +115 -16
  77. package/src/config/model-registry.ts +16 -12
  78. package/src/coordinator-mcp/question-state.ts +33 -2
  79. package/src/coordinator-mcp/server.ts +477 -101
  80. package/src/crash/sanitize.ts +1 -1
  81. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
  82. package/src/discovery/builtin.ts +50 -62
  83. package/src/discovery/helpers.ts +297 -32
  84. package/src/exec/bash-executor.ts +330 -50
  85. package/src/exec/bash-shell-guardian.ts +388 -0
  86. package/src/exec/bash-shell-runtime-entry.ts +5 -0
  87. package/src/exec/bash-shell-signals.ts +29 -0
  88. package/src/exec/bash-shell-supervisor-entry.ts +3 -0
  89. package/src/exec/bash-shell-supervisor.ts +359 -0
  90. package/src/exec/bash-shell-worker-entry.ts +5 -0
  91. package/src/exec/bash-shell-worker-protocol.ts +36 -0
  92. package/src/exec/bash-shell-worker.ts +116 -0
  93. package/src/exec/isolated-shell.ts +393 -0
  94. package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
  95. package/src/extensibility/extensions/function-hooks.ts +959 -0
  96. package/src/extensibility/extensions/index.ts +22 -0
  97. package/src/extensibility/extensions/loader.ts +53 -2
  98. package/src/extensibility/extensions/runner.ts +895 -264
  99. package/src/extensibility/extensions/types.ts +42 -0
  100. package/src/extensibility/extensions/wrapper.ts +56 -15
  101. package/src/extensibility/gjc-plugins/compiler.ts +20 -2
  102. package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
  103. package/src/extensibility/gjc-plugins/registry.ts +113 -1
  104. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
  105. package/src/extensibility/gjc-plugins/schema.ts +121 -4
  106. package/src/extensibility/gjc-plugins/types.ts +25 -0
  107. package/src/extensibility/hooks/loader.ts +133 -16
  108. package/src/extensibility/hooks/types.ts +28 -0
  109. package/src/extensibility/runtime-skill-discovery.ts +80 -47
  110. package/src/extensibility/skill-management.ts +200 -52
  111. package/src/extensibility/skills.ts +29 -9
  112. package/src/gjc-runtime/empty-delete-gc.ts +94 -4
  113. package/src/gjc-runtime/gc-runtime.ts +29 -0
  114. package/src/gjc-runtime/linux-proc.ts +14 -0
  115. package/src/gjc-runtime/session-state-lock.ts +51 -8
  116. package/src/gjc-runtime/session-state-sidecar.ts +27 -11
  117. package/src/gjc-runtime/state-writer.ts +30 -29
  118. package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
  119. package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
  120. package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
  121. package/src/gjc-runtime/workflow-manifest.ts +16 -0
  122. package/src/harness-control-plane/session-lease.ts +2 -2
  123. package/src/internal-urls/docs-index.generated.ts +6 -6
  124. package/src/lsp/config.ts +10 -3
  125. package/src/lsp/index.ts +1 -1
  126. package/src/lsp/path-trust.ts +69 -14
  127. package/src/main.ts +6 -1
  128. package/src/modes/acp/acp-agent.ts +16 -3
  129. package/src/modes/components/bash-execution.ts +4 -1
  130. package/src/modes/components/execution-shared.ts +4 -1
  131. package/src/modes/components/session-observer-overlay.ts +1 -1
  132. package/src/modes/components/tool-status-header.ts +3 -1
  133. package/src/modes/controllers/command-controller.ts +1 -0
  134. package/src/modes/controllers/event-controller.ts +3 -2
  135. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  136. package/src/modes/controllers/input-controller.ts +6 -3
  137. package/src/modes/interactive-mode.ts +6 -5
  138. package/src/modes/print-mode.ts +5 -1
  139. package/src/modes/prompt-action-autocomplete.ts +54 -27
  140. package/src/modes/runtime-init.ts +2 -0
  141. package/src/modes/types.ts +16 -3
  142. package/src/modes/utils/ui-helpers.ts +1 -0
  143. package/src/prompts/tools/subagent.md +1 -0
  144. package/src/runtime-mcp/manager.ts +20 -2
  145. package/src/runtime-mcp/pool.test.ts +20 -11
  146. package/src/sdk/broker/broker.ts +119 -28
  147. package/src/sdk/broker/ensure.ts +248 -41
  148. package/src/sdk/broker/lifecycle-ledger.ts +7 -17
  149. package/src/sdk/broker/lifecycle.ts +56 -6
  150. package/src/sdk/broker/startup-failure.ts +15 -6
  151. package/src/sdk/bus/chat-daemon-control.ts +3 -1
  152. package/src/sdk/bus/chat-daemon-runtime.ts +35 -5
  153. package/src/sdk/bus/chat-effect-journal.ts +132 -7
  154. package/src/sdk/bus/conversation-store.ts +29 -0
  155. package/src/sdk/bus/lifecycle-commands.ts +17 -11
  156. package/src/sdk/bus/slack-conversation.ts +1 -0
  157. package/src/sdk/bus/slack-daemon.ts +387 -48
  158. package/src/sdk/bus/telegram-daemon.ts +16 -2
  159. package/src/sdk/bus/topic-registry.ts +19 -0
  160. package/src/sdk/cli/master-cli.ts +1 -1
  161. package/src/sdk/cli/session-cli.ts +2 -2
  162. package/src/sdk/client/client.ts +66 -17
  163. package/src/sdk/host/host.ts +29 -9
  164. package/src/sdk/host/query/revision-store.ts +19 -13
  165. package/src/sdk/host/reverse-leases.ts +7 -4
  166. package/src/sdk/host/session-runtime.test.ts +50 -8
  167. package/src/sdk/host/session-runtime.ts +158 -51
  168. package/src/sdk/lifecycle/service.ts +4 -0
  169. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  170. package/src/sdk/router/session-router.ts +89 -38
  171. package/src/sdk/session.ts +155 -26
  172. package/src/sdk/transport/relay.ts +35 -13
  173. package/src/session/agent-session.ts +117 -52
  174. package/src/session/fallback-chain-controller.ts +38 -0
  175. package/src/session/internal/managed-session-storage.ts +19 -5
  176. package/src/session/messages.ts +5 -0
  177. package/src/session/session-manager.ts +29 -5
  178. package/src/session-import/redact.ts +5 -1
  179. package/src/setup/paseo/announce.ts +49 -43
  180. package/src/setup/paseo/setup-deps.ts +1 -1
  181. package/src/skill-state/canonical-skills.ts +12 -0
  182. package/src/skills/skills.test.ts +41 -21
  183. package/src/system-prompt.ts +25 -3
  184. package/src/task/executor.ts +5 -0
  185. package/src/task/index.ts +4 -0
  186. package/src/tools/bash.ts +28 -11
  187. package/src/tools/gh.ts +26 -3
  188. package/src/tools/github-cache.ts +68 -16
  189. package/src/tools/index.ts +2 -0
  190. package/src/tools/job.ts +13 -2
  191. package/src/tools/read.ts +14 -2
  192. package/src/tools/render-utils.ts +1 -3
  193. package/src/tools/resource-gc.ts +28 -8
  194. package/src/tools/skill-discovery.ts +5 -0
  195. package/src/tools/skill.ts +15 -0
  196. package/src/tools/subagent.ts +3 -2
  197. package/src/tools/tool-catalog.generated.ts +2 -2
  198. package/src/tools/ultragoal-ask-guard.ts +2 -2
@@ -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,
@@ -117,6 +117,8 @@ export interface SlackNotificationDaemonOptions {
117
117
  content: string,
118
118
  attachment: SessionAttachment,
119
119
  idempotencyKey: string,
120
+ beforeDispatch: () => void,
121
+ dispatchFence: <T>(dispatch: () => Promise<T>) => Promise<T>,
120
122
  ) => Promise<boolean>;
121
123
  }
122
124
 
@@ -225,6 +227,10 @@ export class SlackNotificationDaemon {
225
227
  readonly #activeWork = new Set<Promise<unknown>>();
226
228
  readonly #workInvalidators = new Set<SlackProviderWork>();
227
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>();
228
234
  readonly #rollovers = new Map<string, Promise<SlackRootPublication>>();
229
235
  #workGeneration = 0;
230
236
  #lifecycleTail: Promise<void> = Promise.resolve();
@@ -682,7 +688,9 @@ export class SlackNotificationDaemon {
682
688
  }
683
689
 
684
690
  async postRoot(sessionId: string, body: string, endpointGeneration?: number): Promise<SlackConversation> {
685
- 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;
686
694
  }
687
695
 
688
696
  /**
@@ -716,7 +724,7 @@ export class SlackNotificationDaemon {
716
724
  authority.attachmentAuthorityId ??
717
725
  (attachment?.generation === authority.endpointGeneration ? attachment.authorityId : undefined);
718
726
  await this.#verifyExistingRoot(rootTs);
719
- return await claimSlackThreadBinding({
727
+ const conversation = await claimSlackThreadBinding({
720
728
  store: this.store,
721
729
  key: this.#intentKey(sessionId),
722
730
  teamId: this.options.teamId,
@@ -737,6 +745,8 @@ export class SlackNotificationDaemon {
737
745
  },
738
746
  now: this.#now,
739
747
  });
748
+ this.#admitActivatedRoot(conversation);
749
+ return conversation;
740
750
  }
741
751
 
742
752
  /**
@@ -968,6 +978,7 @@ export class SlackNotificationDaemon {
968
978
  ? await this.#resumeWithRootPublication(sessionId, body, generation, rootPublication?.clientMsgId)
969
979
  : await this.#postRoot(sessionId, body, generation, rootPublication?.clientMsgId);
970
980
  conversation = publication.conversation;
981
+ this.#admitActivatedRoot(conversation);
971
982
  bodyWasUsedAsRoot ||= publication.created;
972
983
  }
973
984
  if (!conversation.rootTs) return conversation;
@@ -1130,13 +1141,68 @@ export class SlackNotificationDaemon {
1130
1141
  }
1131
1142
 
1132
1143
  async close(sessionId: string, marker = "Session closed.", endpointGeneration?: number): Promise<boolean> {
1133
- 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
+ }
1134
1165
  }
1135
1166
 
1136
1167
  async recoverCleanup(sessionId: string, endpointGeneration: number, authorityId?: string): Promise<boolean> {
1137
1168
  const found = await this.findSession(sessionId, true);
1138
1169
  if (!found) return true;
1139
- 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
+ }
1140
1206
  if (found.record.state === "closed_marker" || found.record.state === "error") return true;
1141
1207
  if (
1142
1208
  (found.record.state === "posting_root" || found.record.state === "resumed_root") &&
@@ -1146,10 +1212,14 @@ export class SlackNotificationDaemon {
1146
1212
  if (found.record.state !== "active") return false;
1147
1213
  if (!found.record.rootTs) return false;
1148
1214
  if (found.record.attachmentAuthorityId === authorityId && found.record.cleanupEffectId === undefined) return true;
1149
- const effectId = `close-marker-cleanup:${sessionId}:${found.record.clientMsgId ?? found.record.rootTs}`;
1150
- if (found.record.cleanupEffectId !== effectId) return false;
1215
+ const effectId = found.record.cleanupEffectId;
1216
+ if (!effectId) return false;
1151
1217
  const effect = await this.#journal.read(effectId);
1152
- 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) {
1153
1223
  await this.store.transact(found.key, current =>
1154
1224
  current?.cleanupEffectId === effectId
1155
1225
  ? nextRecord(current, { cleanupEffectId: undefined, updatedAt: this.#now() })
@@ -1162,8 +1232,19 @@ export class SlackNotificationDaemon {
1162
1232
  }
1163
1233
 
1164
1234
  async retireAttachment(sessionId: string, endpointGeneration: number): Promise<void> {
1165
- const found = await this.findSession(sessionId, true);
1166
- 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
+ }
1167
1248
  const attachmentAuthorityId = found.record.attachmentAuthorityId;
1168
1249
  const scope: SlackProviderWorkScope = {
1169
1250
  sessionId,
@@ -1174,6 +1255,7 @@ export class SlackNotificationDaemon {
1174
1255
  let releaseScope = false;
1175
1256
  try {
1176
1257
  const draining = this.#invalidateAndDrainProviderWork(scope, deadline);
1258
+ releaseScope = true;
1177
1259
  let inboundDispatches: SlackInboundDispatchReceipt[] = [];
1178
1260
  // Persist the mapping fence before waiting on terminal effects. A detached
1179
1261
  // terminalization can then never be replayed through this attachment.
@@ -1193,11 +1275,10 @@ export class SlackNotificationDaemon {
1193
1275
  updatedAt: this.#now(),
1194
1276
  });
1195
1277
  });
1196
- releaseScope = true;
1197
1278
  await draining;
1198
1279
  if (inboundDispatches.length === 0) return;
1199
1280
  const terminalization = this.#retirementTerminalization(
1200
- inboundDispatches.map(receipt => this.#journal.terminalize(receipt.effectId, { status: "stale_binding" })),
1281
+ inboundDispatches.map(receipt => this.#terminalizeInboundEffect(receipt.effectId, "stale_binding")),
1201
1282
  );
1202
1283
  this.#trackRetirementTerminalization(slackProviderWorkScopeKey(scope), terminalization);
1203
1284
  const remaining = deadline - this.#now();
@@ -1213,6 +1294,7 @@ export class SlackNotificationDaemon {
1213
1294
  logger.warn("Slack attachment retirement exceeded the 5000ms deadline; fenced pending inbound effects.");
1214
1295
  } finally {
1215
1296
  if (releaseScope) this.#releaseRetiredProviderWorkScope(scope);
1297
+ this.#retiringAttachmentGenerations.delete(generationKey);
1216
1298
  }
1217
1299
  }
1218
1300
 
@@ -1221,13 +1303,17 @@ export class SlackNotificationDaemon {
1221
1303
  marker: string,
1222
1304
  allowRemovedAttachment: boolean,
1223
1305
  expectedEndpointGeneration?: number,
1306
+ onResolved?: (record: SlackConversation) => void,
1224
1307
  ): Promise<boolean> {
1225
1308
  const found = await this.findSession(sessionId, true);
1226
1309
  if (!found?.record.rootTs || found.record.state !== "active") return false;
1227
1310
  if (expectedEndpointGeneration !== undefined && found.record.endpointGeneration !== expectedEndpointGeneration)
1228
1311
  return false;
1229
- const effectId = `${allowRemovedAttachment ? "close-marker-cleanup" : "close-marker"}:${sessionId}:${found.record.clientMsgId ?? found.record.rootTs}`;
1230
- 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
+ {
1231
1317
  let admitted = false;
1232
1318
  await this.store.transact(found.key, current => {
1233
1319
  if (
@@ -1268,6 +1354,7 @@ export class SlackNotificationDaemon {
1268
1354
  );
1269
1355
  await this.#postDurable(effectId, sessionId, this.#requireEndpointGeneration(found.record), durable.payload);
1270
1356
  let closed = false;
1357
+ let inboundDispatches: SlackInboundDispatchReceipt[] = [];
1271
1358
  await this.store.transact(found.key, current => {
1272
1359
  if (
1273
1360
  current?.state !== "active" ||
@@ -1279,13 +1366,24 @@ export class SlackNotificationDaemon {
1279
1366
  )
1280
1367
  return current;
1281
1368
  closed = true;
1369
+ inboundDispatches = [...(current.inboundDispatches ?? [])];
1282
1370
  return nextRecord(current, {
1283
1371
  state: "closed_marker",
1284
1372
  cleanupEffectId: undefined,
1285
1373
  pendingActionId: undefined,
1374
+ inboundDispatches: [],
1286
1375
  updatedAt: this.#now(),
1287
1376
  });
1288
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
+ );
1289
1387
 
1290
1388
  return closed;
1291
1389
  }
@@ -1296,7 +1394,9 @@ export class SlackNotificationDaemon {
1296
1394
  endpointGeneration?: number,
1297
1395
  publicationId?: string,
1298
1396
  ): Promise<SlackConversation> {
1299
- 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;
1300
1400
  }
1301
1401
 
1302
1402
  async #resume(
@@ -1415,7 +1515,7 @@ export class SlackNotificationDaemon {
1415
1515
  }
1416
1516
  let closed = false;
1417
1517
  try {
1418
- closed = await this.#track(this.#close(sessionId, "Session closed.", false, previousGeneration));
1518
+ closed = await this.#track(this.#closeWithFence(sessionId, "Session closed.", false, previousGeneration));
1419
1519
  } catch (error) {
1420
1520
  if (!(error instanceof SlackStaleEffectError)) throw error;
1421
1521
  // The old-generation close marker was deliberately suppressed after
@@ -1582,7 +1682,7 @@ export class SlackNotificationDaemon {
1582
1682
  });
1583
1683
  });
1584
1684
  if (!sessionId || !receipt) {
1585
- await this.#journal.terminalize(effectId, { status: "rejected" });
1685
+ await this.#terminalizeInboundEffect(effectId, "rejected");
1586
1686
  return undefined;
1587
1687
  }
1588
1688
  return { key, endpoint, sessionId, receipt };
@@ -1601,6 +1701,7 @@ export class SlackNotificationDaemon {
1601
1701
  await this.#finalizeTerminalInboundDispatch(claim.key, claim.receipt);
1602
1702
  return false;
1603
1703
  }
1704
+ if (effect?.state === "uncertain") return false;
1604
1705
  if (
1605
1706
  !current ||
1606
1707
  !this.#mappedInboundDispatchable(current, claim.receipt, effect) ||
@@ -1637,18 +1738,40 @@ export class SlackNotificationDaemon {
1637
1738
  ? {}
1638
1739
  : { attachmentAuthorityId: claim.receipt.attachmentAuthorityId }),
1639
1740
  };
1741
+ let dispatchEntered = false;
1640
1742
  try {
1641
1743
  if (effect.payload.type === "command") {
1642
1744
  const payload = effect.payload;
1643
- const accepted = await this.#withEffectLease(effect.id, lease, workScope, async () => {
1644
- if (!(await this.#inboundEffectCurrent(claim, effect.id))) throw new SlackStaleEffectError();
1645
- return await (this.options.onCommand?.(
1646
- claim.sessionId,
1647
- payload.content,
1648
- claim.endpoint,
1649
- payload.idempotencyKey,
1650
- ) ?? Promise.resolve(false));
1651
- });
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
+ );
1652
1775
  if (!(await this.#inboundEffectCurrent(claim, effect.id))) {
1653
1776
  await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_binding");
1654
1777
  return false;
@@ -1659,10 +1782,30 @@ export class SlackNotificationDaemon {
1659
1782
  if (recorded) await this.#finishDispatch(claim, "terminal");
1660
1783
  return accepted && !!recorded;
1661
1784
  }
1662
- await this.#withEffectLease(effect.id, lease, workScope, async () => {
1663
- if (!(await this.#inboundEffectCurrent(claim, effect.id))) throw new SlackStaleEffectError();
1664
- await claim.endpoint.send(effect.payload);
1665
- });
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
+ );
1666
1809
  if (!(await this.#inboundEffectCurrent(claim, effect.id))) {
1667
1810
  await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_binding");
1668
1811
  return false;
@@ -1675,7 +1818,7 @@ export class SlackNotificationDaemon {
1675
1818
  await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_binding");
1676
1819
  return false;
1677
1820
  }
1678
- const state = this.#isDefiniteSdkPreSendFailure(error) ? "accepted" : "uncertain";
1821
+ const state = this.#isDefiniteSdkPreSendFailure(error) || !dispatchEntered ? "accepted" : "uncertain";
1679
1822
 
1680
1823
  const recorded = await this.#rescheduleAfterEffectTransition(
1681
1824
  this.#journal.record(effect.id, lease, state, { status: state }),
@@ -1691,6 +1834,18 @@ export class SlackNotificationDaemon {
1691
1834
 
1692
1835
  effectId: string,
1693
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;
1694
1849
  const endpoint = await this.#resolveAttachment(claim.sessionId);
1695
1850
  if (!endpoint?.isCurrent()) return false;
1696
1851
  if (
@@ -1717,12 +1872,120 @@ export class SlackNotificationDaemon {
1717
1872
  );
1718
1873
  }
1719
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
+
1720
1982
  async #withEffectLease<T>(
1721
1983
  id: string,
1722
1984
  lease: ChatEffectLease,
1723
1985
  scope: SlackProviderWorkScope,
1724
1986
  operation: (signal: AbortSignal) => Promise<T>,
1725
1987
  workGeneration = this.#workGeneration,
1988
+ preserveAmbiguityOnRetire = false,
1726
1989
  ): Promise<T> {
1727
1990
  const scopeKey = slackProviderWorkScopeKey(scope);
1728
1991
  const controller = new AbortController();
@@ -1741,13 +2004,16 @@ export class SlackNotificationDaemon {
1741
2004
  await invalidation;
1742
2005
  };
1743
2006
  const terminalize = async (): Promise<void> => {
2007
+ if (preserveAmbiguityOnRetire && (await this.#journal.read(id))?.state === "dispatching")
2008
+ return await invalidate();
1744
2009
  abort();
1745
2010
  terminalization ??= (async (): Promise<void> => {
1746
2011
  const receipt = { status: "stale_binding" };
1747
2012
  const terminalized = await this.#rescheduleAfterEffectTransition(
1748
- this.#journal.terminalize(id, receipt, lease),
2013
+ this.#journal.terminalizeDecided(id, receipt, lease),
1749
2014
  );
1750
- if (!terminalized) await this.#rescheduleAfterEffectTransition(this.#journal.terminalize(id, receipt));
2015
+ if (!terminalized)
2016
+ await this.#rescheduleAfterEffectTransition(this.#journal.terminalizeDecided(id, receipt));
1751
2017
  })();
1752
2018
  await terminalization;
1753
2019
  };
@@ -1818,11 +2084,23 @@ export class SlackNotificationDaemon {
1818
2084
  const dispatchableEffectIds = new Set<string>();
1819
2085
  for (const [key, record] of Object.entries(document.conversations)) {
1820
2086
  for (const receipt of record.inboundDispatches ?? []) {
1821
- 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
+ }
1822
2095
  if (effect?.state === "terminal") {
1823
2096
  await this.#finalizeTerminalInboundDispatch(key, receipt);
1824
2097
  continue;
1825
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;
1826
2104
  if (!this.#mappedInboundDispatchable(record, receipt, effect)) {
1827
2105
  await this.#terminalizeStaleInboundDispatch(key, receipt, "stale_mapping");
1828
2106
  continue;
@@ -1836,11 +2114,7 @@ export class SlackNotificationDaemon {
1836
2114
  await this.#terminalizeStaleInboundDispatch(key, receipt, "stale_binding");
1837
2115
  continue;
1838
2116
  }
1839
- if (
1840
- effect.state === "uncertain" ||
1841
- (effect.state === "leased" && (effect.leaseExpiresAt ?? 0) > this.#now())
1842
- )
1843
- continue;
2117
+ if (effect.state === "leased" && (effect.leaseExpiresAt ?? 0) > this.#now()) continue;
1844
2118
  dispatchableEffectIds.add(receipt.effectId);
1845
2119
  const inflightKey = `${key}\u0000${receipt.key}`;
1846
2120
  if (this.#inflightInbound.has(inflightKey)) continue;
@@ -1853,6 +2127,11 @@ export class SlackNotificationDaemon {
1853
2127
  }
1854
2128
  }
1855
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
+ }
1856
2135
  if (
1857
2136
  dispatchableEffectIds.has(effect.id) ||
1858
2137
  effect.transport !== "slack" ||
@@ -1863,6 +2142,7 @@ export class SlackNotificationDaemon {
1863
2142
  effect.kind !== "sdk.inbound.user_message")
1864
2143
  )
1865
2144
  continue;
2145
+ if (effect.state === "uncertain") continue;
1866
2146
  const adopted = await this.#adoptOrphanInbound(effect as ChatEffect<SlackInboundEffectPayload>);
1867
2147
  if (!adopted) continue;
1868
2148
  const endpoint = await this.#resolveAttachment(adopted.sessionId);
@@ -1877,8 +2157,7 @@ export class SlackNotificationDaemon {
1877
2157
  await this.#terminalizeStaleInboundDispatch(adopted.key, adopted.receipt, "stale_binding");
1878
2158
  continue;
1879
2159
  }
1880
- if (effect.state === "uncertain" || (effect.state === "leased" && (effect.leaseExpiresAt ?? 0) > this.#now()))
1881
- continue;
2160
+ if (effect.state === "leased" && (effect.leaseExpiresAt ?? 0) > this.#now()) continue;
1882
2161
  await this.#dispatchInbound({
1883
2162
  key: adopted.key,
1884
2163
  endpoint,
@@ -2014,8 +2293,8 @@ export class SlackNotificationDaemon {
2014
2293
  receipt: SlackInboundDispatchReceipt,
2015
2294
  status: "stale_binding" | "stale_mapping",
2016
2295
  ): Promise<void> {
2017
- await this.#journal.terminalize(receipt.effectId, { status });
2018
- await this.#finalizeTerminalInboundDispatch(key, receipt);
2296
+ if (await this.#terminalizeInboundEffect(receipt.effectId, status))
2297
+ await this.#finalizeTerminalInboundDispatch(key, receipt);
2019
2298
  }
2020
2299
  async #finalizeTerminalInboundDispatch(key: string, receipt: SlackInboundDispatchReceipt): Promise<void> {
2021
2300
  await this.store.transact(key, current => {
@@ -2139,7 +2418,32 @@ export class SlackNotificationDaemon {
2139
2418
  }
2140
2419
 
2141
2420
  async #terminalizeRejectedInbound(effectId: string): Promise<void> {
2142
- 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";
2143
2447
  }
2144
2448
 
2145
2449
  async #finishDispatch(
@@ -2225,7 +2529,7 @@ export class SlackNotificationDaemon {
2225
2529
  return;
2226
2530
  // Receipt recovery must retain the original mapping through an endpoint roll;
2227
2531
  // inbound routing still fences it against the now-current endpoint generation.
2228
- await this.store.transact(this.#intentKey(effect.sessionId), current =>
2532
+ const activated = await this.store.transact(this.#intentKey(effect.sessionId), current =>
2229
2533
  current &&
2230
2534
  current.state === "posting_root" &&
2231
2535
  current.sessionId === effect.sessionId &&
@@ -2244,6 +2548,13 @@ export class SlackNotificationDaemon {
2244
2548
  })
2245
2549
  : current,
2246
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);
2247
2558
  }
2248
2559
 
2249
2560
  async #activateReconciledAction(
@@ -2435,7 +2746,8 @@ export class SlackNotificationDaemon {
2435
2746
  .reduce<number | undefined>(
2436
2747
  (earliest, effect) => {
2437
2748
  const claimAt =
2438
- effect.state === "leased" && Number.isFinite(effect.leaseExpiresAt)
2749
+ (effect.state === "leased" || effect.state === "dispatching") &&
2750
+ Number.isFinite(effect.leaseExpiresAt)
2439
2751
  ? effect.leaseExpiresAt
2440
2752
  : effect.state === "pending" ||
2441
2753
  effect.state === "accepted" ||
@@ -2528,6 +2840,15 @@ export class SlackNotificationDaemon {
2528
2840
  const threadTs = payload.threadTs;
2529
2841
  const records = Object.values((await this.store.load()).conversations);
2530
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
+ );
2531
2852
  return records.some(
2532
2853
  record =>
2533
2854
  record.sessionId === effect.sessionId &&
@@ -2693,6 +3014,23 @@ export class SlackNotificationDaemon {
2693
3014
  }
2694
3015
  if (workGeneration !== this.#workGeneration)
2695
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
+ }
2696
3034
  return await this.options.provider.postMessage({ ...effect!.payload, signal });
2697
3035
  },
2698
3036
  workGeneration,
@@ -2711,6 +3049,7 @@ export class SlackNotificationDaemon {
2711
3049
 
2712
3050
  return posted;
2713
3051
  } catch (error) {
3052
+ if (error instanceof ConversationLockTimeoutError) throw error;
2714
3053
  if (error instanceof SlackReconciledAbsentEffectError) {
2715
3054
  await this.#rescheduleAfterEffectTransition(
2716
3055
  this.#journal.record(id, lease, "terminal", { provider: "slack", status: "not_found" }),
@@ -3023,9 +3362,9 @@ export class SlackNotificationDaemon {
3023
3362
  );
3024
3363
  }
3025
3364
  #isDefiniteSdkPreSendFailure(error: unknown): boolean {
3365
+ if (error instanceof SdkPreparedDispatchError) return true;
3026
3366
  if (error instanceof SlackEndpointBindingError) return true;
3027
3367
  if (error instanceof SessionRouterError) return error.phase === "pre_send";
3028
- if (error instanceof SdkClientError) return error.code === "connection_closed";
3029
3368
  return (
3030
3369
  error instanceof Error &&
3031
3370
  error.name === "ChatDeliveryError" &&