@llblab/pi-telegram 0.46.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/AGENTS.md +4 -2
  2. package/BACKLOG.md +0 -6
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +5 -2
  5. package/dist/api/activity.d.ts +6 -0
  6. package/dist/api/activity.js +6 -0
  7. package/dist/api/commands.d.ts +6 -0
  8. package/dist/api/commands.js +6 -0
  9. package/dist/api/delivery.d.ts +6 -0
  10. package/dist/api/delivery.js +6 -0
  11. package/dist/api/inbound.d.ts +6 -0
  12. package/dist/api/inbound.js +6 -0
  13. package/dist/api/keyboard.d.ts +6 -0
  14. package/dist/api/keyboard.js +6 -0
  15. package/dist/api/outbound.d.ts +6 -0
  16. package/dist/api/outbound.js +6 -0
  17. package/dist/api/sections.d.ts +7 -0
  18. package/dist/api/sections.js +6 -0
  19. package/dist/api/status.d.ts +6 -0
  20. package/dist/api/status.js +6 -0
  21. package/dist/api/updates.d.ts +6 -0
  22. package/dist/api/updates.js +6 -0
  23. package/dist/api/voice.d.ts +6 -0
  24. package/dist/api/voice.js +6 -0
  25. package/dist/index.d.ts +6 -0
  26. package/dist/index.js +6 -0
  27. package/dist/lib/activity-verbosity.d.ts +52 -0
  28. package/dist/lib/activity-verbosity.js +596 -0
  29. package/dist/lib/activity.d.ts +220 -0
  30. package/dist/lib/activity.js +574 -0
  31. package/dist/lib/agent-messages.d.ts +28 -0
  32. package/dist/lib/agent-messages.js +86 -0
  33. package/dist/lib/bindings.d.ts +250 -0
  34. package/dist/lib/bindings.js +936 -0
  35. package/dist/lib/bus-api.d.ts +18 -0
  36. package/dist/lib/bus-api.js +254 -0
  37. package/dist/lib/bus-follower.d.ts +464 -0
  38. package/dist/lib/bus-follower.js +1686 -0
  39. package/dist/lib/bus-leader.d.ts +292 -0
  40. package/dist/lib/bus-leader.js +2242 -0
  41. package/dist/lib/bus-transport.d.ts +64 -0
  42. package/dist/lib/bus-transport.js +140 -0
  43. package/dist/lib/bus.d.ts +518 -0
  44. package/dist/lib/bus.js +2055 -0
  45. package/dist/lib/channel-posts.d.ts +170 -0
  46. package/dist/lib/channel-posts.js +611 -0
  47. package/dist/lib/command-templates.d.ts +70 -0
  48. package/dist/lib/command-templates.js +744 -0
  49. package/dist/lib/commands.d.ts +541 -0
  50. package/dist/lib/commands.js +1155 -0
  51. package/dist/lib/config.d.ts +252 -0
  52. package/dist/lib/config.js +889 -0
  53. package/dist/lib/delivery.d.ts +163 -0
  54. package/dist/lib/delivery.js +542 -0
  55. package/dist/lib/extension.d.ts +7 -0
  56. package/dist/lib/extension.js +1604 -0
  57. package/dist/lib/generative-app-worker.mjs +104 -0
  58. package/dist/lib/generative-apps.d.ts +212 -0
  59. package/dist/lib/generative-apps.js +1006 -0
  60. package/dist/lib/inbound.d.ts +91 -0
  61. package/dist/lib/inbound.js +502 -0
  62. package/dist/lib/journal.d.ts +665 -0
  63. package/dist/lib/journal.js +3701 -0
  64. package/dist/lib/keyboard.d.ts +23 -0
  65. package/dist/lib/keyboard.js +37 -0
  66. package/dist/lib/lifecycle.d.ts +157 -0
  67. package/dist/lib/lifecycle.js +395 -0
  68. package/dist/lib/locks.d.ts +164 -0
  69. package/dist/lib/locks.js +1208 -0
  70. package/dist/lib/logging.d.ts +50 -0
  71. package/dist/lib/logging.js +274 -0
  72. package/dist/lib/media.d.ts +181 -0
  73. package/dist/lib/media.js +602 -0
  74. package/dist/lib/menu-model.d.ts +217 -0
  75. package/dist/lib/menu-model.js +663 -0
  76. package/dist/lib/menu-queue.d.ts +37 -0
  77. package/dist/lib/menu-queue.js +408 -0
  78. package/dist/lib/menu-settings.d.ts +115 -0
  79. package/dist/lib/menu-settings.js +595 -0
  80. package/dist/lib/menu-status.d.ts +32 -0
  81. package/dist/lib/menu-status.js +112 -0
  82. package/dist/lib/menu-thinking.d.ts +29 -0
  83. package/dist/lib/menu-thinking.js +82 -0
  84. package/dist/lib/menu.d.ts +171 -0
  85. package/dist/lib/menu.js +323 -0
  86. package/dist/lib/model.d.ts +127 -0
  87. package/dist/lib/model.js +409 -0
  88. package/dist/lib/outbound-attachments.d.ts +241 -0
  89. package/dist/lib/outbound-attachments.js +639 -0
  90. package/dist/lib/outbound-buttons.d.ts +69 -0
  91. package/dist/lib/outbound-buttons.js +248 -0
  92. package/dist/lib/outbound-markup.d.ts +42 -0
  93. package/dist/lib/outbound-markup.js +678 -0
  94. package/dist/lib/outbound-voice.d.ts +55 -0
  95. package/dist/lib/outbound-voice.js +152 -0
  96. package/dist/lib/outbound.d.ts +185 -0
  97. package/dist/lib/outbound.js +516 -0
  98. package/dist/lib/ownership.d.ts +77 -0
  99. package/dist/lib/ownership.js +174 -0
  100. package/dist/lib/paths.d.ts +40 -0
  101. package/dist/lib/paths.js +94 -0
  102. package/dist/lib/pi.d.ts +72 -0
  103. package/dist/lib/pi.js +121 -0
  104. package/dist/lib/polling.d.ts +351 -0
  105. package/dist/lib/polling.js +1196 -0
  106. package/dist/lib/preview.d.ts +192 -0
  107. package/dist/lib/preview.js +614 -0
  108. package/dist/lib/prompt-templates.d.ts +24 -0
  109. package/dist/lib/prompt-templates.js +118 -0
  110. package/dist/lib/prompts.d.ts +57 -0
  111. package/dist/lib/prompts.js +167 -0
  112. package/dist/lib/queue.d.ts +766 -0
  113. package/dist/lib/queue.js +1965 -0
  114. package/dist/lib/recovery.d.ts +86 -0
  115. package/dist/lib/recovery.js +285 -0
  116. package/dist/lib/rendering.d.ts +20 -0
  117. package/dist/lib/rendering.js +983 -0
  118. package/dist/lib/replies.d.ts +214 -0
  119. package/dist/lib/replies.js +737 -0
  120. package/dist/lib/routing.d.ts +207 -0
  121. package/dist/lib/routing.js +2282 -0
  122. package/dist/lib/runtime.d.ts +172 -0
  123. package/dist/lib/runtime.js +402 -0
  124. package/dist/lib/sections.d.ts +165 -0
  125. package/dist/lib/sections.js +327 -0
  126. package/dist/lib/setup.d.ts +82 -0
  127. package/dist/lib/setup.js +150 -0
  128. package/dist/lib/skills.d.ts +8 -0
  129. package/dist/lib/skills.js +12 -0
  130. package/dist/lib/status.d.ts +442 -0
  131. package/dist/lib/status.js +1008 -0
  132. package/dist/lib/sync.d.ts +179 -0
  133. package/dist/lib/sync.js +782 -0
  134. package/dist/lib/target.d.ts +20 -0
  135. package/dist/lib/target.js +27 -0
  136. package/dist/lib/telegram-api.d.ts +541 -0
  137. package/dist/lib/telegram-api.js +1159 -0
  138. package/dist/lib/text-groups.d.ts +89 -0
  139. package/dist/lib/text-groups.js +317 -0
  140. package/dist/lib/thread-cleanup-manager.d.ts +288 -0
  141. package/dist/lib/thread-cleanup-manager.js +560 -0
  142. package/dist/lib/thread-display.d.ts +46 -0
  143. package/dist/lib/thread-display.js +257 -0
  144. package/dist/lib/thread-naming.d.ts +46 -0
  145. package/dist/lib/thread-naming.js +78 -0
  146. package/dist/lib/thread-reconciler.d.ts +239 -0
  147. package/dist/lib/thread-reconciler.js +644 -0
  148. package/dist/lib/threads.d.ts +621 -0
  149. package/dist/lib/threads.js +3679 -0
  150. package/dist/lib/time-injection.d.ts +15 -0
  151. package/dist/lib/time-injection.js +56 -0
  152. package/dist/lib/turns.d.ts +109 -0
  153. package/dist/lib/turns.js +500 -0
  154. package/dist/lib/updates.d.ts +1290 -0
  155. package/dist/lib/updates.js +3634 -0
  156. package/dist/lib/voice.d.ts +117 -0
  157. package/dist/lib/voice.js +174 -0
  158. package/dist/lib/workspace-admission.d.ts +264 -0
  159. package/dist/lib/workspace-admission.js +1136 -0
  160. package/dist/lib/workspace-retirement.d.ts +219 -0
  161. package/dist/lib/workspace-retirement.js +587 -0
  162. package/dist/lib/workspace-slots.d.ts +30 -0
  163. package/dist/lib/workspace-slots.js +54 -0
  164. package/dist/package.json +126 -0
  165. package/dist/pi-telegram/index.js +1 -0
  166. package/dist/skills/generated-control-surface/SKILL.md +107 -0
  167. package/dist/skills/generated-control-surface/references/capability-adapters.md +27 -0
  168. package/dist/skills/generated-control-surface/references/layout-and-state.md +37 -0
  169. package/dist/skills/generative-apps/SKILL.md +115 -0
  170. package/dist/skills/show-me/SKILL.md +166 -0
  171. package/dist/skills/show-me/references/telegram-surfaces.md +43 -0
  172. package/dist/skills/telegram-bridge/SKILL.md +129 -0
  173. package/dist/skills/telegram-bridge/references/configuration.md +15 -0
  174. package/dist/skills/telegram-bridge/references/delivery-and-threads.md +27 -0
  175. package/dist/skills/telegram-bridge/references/diagnosis.md +18 -0
  176. package/docs/README.md +2 -0
  177. package/docs/architecture.md +1 -1
  178. package/docs/compact-matrix-literal.md +10 -3
  179. package/docs/generative-apps.md +15 -13
  180. package/docs/multi-instance-bus.md +6 -4
  181. package/docs/outbound.md +1 -1
  182. package/docs/public-api.md +3 -3
  183. package/lib/bindings.ts +89 -6
  184. package/lib/bus-follower.ts +7 -3
  185. package/lib/bus-leader.ts +44 -19
  186. package/lib/bus.ts +4 -1
  187. package/lib/commands.ts +2 -2
  188. package/lib/config.ts +14 -7
  189. package/lib/delivery.ts +38 -6
  190. package/lib/extension.ts +7 -0
  191. package/lib/generative-apps.ts +379 -13
  192. package/lib/menu-settings.ts +20 -6
  193. package/lib/outbound-markup.ts +16 -7
  194. package/lib/status.ts +15 -12
  195. package/lib/telegram-api.ts +12 -1
  196. package/lib/thread-display.ts +96 -18
  197. package/package.json +56 -13
  198. package/scripts/build-dist.mjs +44 -0
  199. package/scripts/measure-bus.mjs +8 -1
  200. package/scripts/measure-workspace.mjs +8 -1
  201. package/skills/generative-apps/SKILL.md +1 -1
  202. package/skills/show-me/SKILL.md +1 -1
  203. package/skills/show-me/references/telegram-surfaces.md +1 -1
  204. package/skills/telegram-bridge/SKILL.md +1 -1
@@ -0,0 +1,644 @@
1
+ /**
2
+ * Telegram thread lifecycle reconciler
3
+ * Zones: telegram, multi-instance bus, synchronization control plane
4
+ * Owns pure planning for Telegram thread/tab lifecycle reconciliation and destructive topic cleanup authority
5
+ * Excludes live Telegram API calls, inbound routing, menu rendering, and direct thread-store persistence
6
+ */
7
+ const DEFAULT_FRESH_CREATION_GRACE_MS = 30_000;
8
+ function targetKey(target) {
9
+ return `${target.chatId}:${target.threadId}`;
10
+ }
11
+ function isCurrentRecord(record) {
12
+ return (record.status === "active" ||
13
+ record.status === "starting" ||
14
+ record.status === "pending");
15
+ }
16
+ function isActiveOrStartingRecord(record) {
17
+ return record.status === "active" || record.status === "starting";
18
+ }
19
+ function isPreviousLeaderRecord(record) {
20
+ return (record.ownerKind === "leader" ||
21
+ (!record.ownerKind &&
22
+ (record.profileKey?.startsWith("cwd:") === true ||
23
+ record.profileKey?.startsWith("leader:") === true)));
24
+ }
25
+ function isReservationAlive(reservation, nowMs) {
26
+ return (reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs);
27
+ }
28
+ function isPendingProvisionExpired(provision, nowMs) {
29
+ return provision.expiresAtMs !== undefined && provision.expiresAtMs <= nowMs;
30
+ }
31
+ function isPendingProvisionAlive(provision, nowMs, currentLeaderEpoch) {
32
+ if (isPendingProvisionExpired(provision, nowMs)) {
33
+ return false;
34
+ }
35
+ if (currentLeaderEpoch !== undefined &&
36
+ provision.leaderEpoch !== undefined &&
37
+ provision.leaderEpoch !== currentLeaderEpoch) {
38
+ return false;
39
+ }
40
+ return true;
41
+ }
42
+ function isRecentOpenObservation(observation, nowMs, graceMs) {
43
+ return (observation.syncStatus === "open" &&
44
+ nowMs - observation.observedAtMs >= 0 &&
45
+ nowMs - observation.observedAtMs <= graceMs);
46
+ }
47
+ function isCleanupAction(action) {
48
+ return (action.kind === "close-delete-unbound-topic" ||
49
+ action.kind === "close-delete-reserved-topic" ||
50
+ action.kind === "close-stale-replaced-topic" ||
51
+ action.kind === "close-delete-replaced-follower-topic" ||
52
+ action.kind === "close-delete-previous-leader-topic" ||
53
+ action.kind === "close-delete-disconnected-instance-topic" ||
54
+ action.kind === "close-delete-graceful-shutdown-topic" ||
55
+ action.kind === "cancel-superseded-graceful-shutdown-cleanup" ||
56
+ action.kind === "close-delete-expired-pending-provision-topic");
57
+ }
58
+ function isSyncAction(action) {
59
+ return action.kind === "mark-topic-active" || action.kind === "mark-topic-stale";
60
+ }
61
+ function createThreadReconciliationMachineState(input, actions) {
62
+ const pendingProvisionCount = (input.pendingProvisions ?? []).filter((provision) => isPendingProvisionAlive(provision, input.nowMs, input.currentLeaderEpoch)).length;
63
+ const cleanupActionCount = actions.filter(isCleanupAction).length;
64
+ const syncActionCount = actions.filter(isSyncAction).length;
65
+ const phase = cleanupActionCount > 0
66
+ ? "cleanup-required"
67
+ : pendingProvisionCount > 0
68
+ ? "provisioning"
69
+ : syncActionCount > 0
70
+ ? "sync-required"
71
+ : "stable";
72
+ const event = phase === "cleanup-required"
73
+ ? "cleanup-required"
74
+ : phase === "provisioning"
75
+ ? "pending-provision"
76
+ : phase === "sync-required"
77
+ ? "sync-required"
78
+ : "settled";
79
+ return {
80
+ phase,
81
+ event,
82
+ atMs: input.nowMs,
83
+ ...(input.currentLeaderEpoch !== undefined
84
+ ? { leaderEpoch: input.currentLeaderEpoch }
85
+ : {}),
86
+ pendingProvisionCount,
87
+ syncActionCount,
88
+ cleanupActionCount,
89
+ };
90
+ }
91
+ function createThreadReconciliationTransition(previous, current) {
92
+ if (!previous || previous.phase === current.phase)
93
+ return undefined;
94
+ return {
95
+ from: previous.phase,
96
+ to: current.phase,
97
+ event: current.event,
98
+ atMs: current.atMs,
99
+ };
100
+ }
101
+ function getActionLeaderEpoch(action) {
102
+ return "leaderEpoch" in action ? action.leaderEpoch : undefined;
103
+ }
104
+ function isTelegramTopicTargetDeletedOrMissingError(error) {
105
+ if (!(error instanceof Error))
106
+ return false;
107
+ const message = error.message.toLowerCase();
108
+ return (message.includes("topic_id_invalid") ||
109
+ message.includes("message thread not found") ||
110
+ message.includes("thread not found") ||
111
+ message.includes("topic not found") ||
112
+ message.includes("topic deleted"));
113
+ }
114
+ function isTelegramTopicTargetGoneError(error) {
115
+ if (isTelegramTopicTargetDeletedOrMissingError(error))
116
+ return true;
117
+ if (!(error instanceof Error))
118
+ return false;
119
+ const message = error.message.toLowerCase();
120
+ return (message.includes("topic closed") ||
121
+ message.includes("thread closed") ||
122
+ message.includes("forum topic closed") ||
123
+ message.includes("message thread closed"));
124
+ }
125
+ function shouldSkipForStaleLeaderEpoch(action, ports) {
126
+ const actionLeaderEpoch = getActionLeaderEpoch(action);
127
+ const currentLeaderEpoch = ports.getCurrentLeaderEpoch?.();
128
+ if (actionLeaderEpoch === undefined) {
129
+ if (!ports.getCurrentLeaderEpoch)
130
+ return false;
131
+ ports.recordRuntimeEvent?.("telegram", "Thread reconciliation destructive action has no leader epoch", {
132
+ phase: "thread-reconciler-missing-leader-epoch",
133
+ currentLeaderEpoch,
134
+ action: action.kind,
135
+ chatId: action.target.chatId,
136
+ threadId: action.target.threadId,
137
+ });
138
+ return true;
139
+ }
140
+ if (currentLeaderEpoch === actionLeaderEpoch)
141
+ return false;
142
+ ports.recordRuntimeEvent?.("telegram", "Skipped stale-epoch thread reconciliation action", {
143
+ phase: "thread-reconciler-stale-epoch-skip",
144
+ action: action.kind,
145
+ actionLeaderEpoch,
146
+ currentLeaderEpoch,
147
+ chatId: action.target.chatId,
148
+ threadId: action.target.threadId,
149
+ });
150
+ return true;
151
+ }
152
+ export function createThreadReconciliationRuntime(deps) {
153
+ let state;
154
+ const recordPlan = (plan) => {
155
+ if (!plan.state)
156
+ return;
157
+ state = plan.state;
158
+ if (plan.transition) {
159
+ deps.recordRuntimeEvent("telegram", "Thread reconciliation phase changed", {
160
+ phase: "thread-reconciliation-transition",
161
+ from: plan.transition.from,
162
+ to: plan.transition.to,
163
+ event: plan.transition.event,
164
+ });
165
+ }
166
+ deps.scheduleSnapshotPersist();
167
+ };
168
+ return {
169
+ getState: () => state,
170
+ recordPlan,
171
+ plan(input) {
172
+ const plan = planThreadReconciliation({
173
+ ...input,
174
+ previousState: state,
175
+ });
176
+ recordPlan(plan);
177
+ return plan;
178
+ },
179
+ };
180
+ }
181
+ export function planDisconnectedInstanceThreadCleanup(input) {
182
+ return {
183
+ actions: [
184
+ {
185
+ kind: "close-delete-disconnected-instance-topic",
186
+ target: input.target,
187
+ reason: "manual-disconnect",
188
+ instanceId: input.instanceId,
189
+ ...(input.leaderEpoch !== undefined
190
+ ? { leaderEpoch: input.leaderEpoch }
191
+ : {}),
192
+ },
193
+ ],
194
+ };
195
+ }
196
+ export async function applyThreadReconciliationPlan(plan, ports) {
197
+ let shouldPersist = false;
198
+ const persistFences = [];
199
+ const incompleteActions = [];
200
+ for (const action of plan.actions) {
201
+ if (action.kind === "mark-topic-active") {
202
+ shouldPersist =
203
+ ports.markActiveByTarget?.(action.target) || shouldPersist;
204
+ continue;
205
+ }
206
+ if (action.kind === "mark-topic-stale") {
207
+ shouldPersist =
208
+ ports.markStaleByTarget?.(action.target, action.syncStatus) ||
209
+ shouldPersist;
210
+ continue;
211
+ }
212
+ if (action.kind === "cancel-superseded-graceful-shutdown-cleanup") {
213
+ if (shouldSkipForStaleLeaderEpoch(action, ports)) {
214
+ incompleteActions.push(action);
215
+ continue;
216
+ }
217
+ const changed = ports.removeCleanupIntentById?.(action.cleanupIntentId) ?? false;
218
+ shouldPersist = changed || shouldPersist;
219
+ ports.recordRuntimeEvent?.("telegram", "Cancelled superseded Telegram topic cleanup", {
220
+ phase: "thread-reconciler-cleanup-superseded",
221
+ instanceId: action.instanceId,
222
+ chatId: action.target.chatId,
223
+ threadId: action.target.threadId,
224
+ });
225
+ continue;
226
+ }
227
+ if (action.kind === "close-stale-replaced-topic") {
228
+ if (shouldSkipForStaleLeaderEpoch(action, ports)) {
229
+ incompleteActions.push(action);
230
+ continue;
231
+ }
232
+ if (!ports.callApi) {
233
+ ports.recordRuntimeEvent?.("telegram", "Skipped topic close without API port", {
234
+ phase: "thread-reconciler-close-skip-no-api",
235
+ reason: action.reason,
236
+ chatId: action.target.chatId,
237
+ threadId: action.target.threadId,
238
+ });
239
+ incompleteActions.push(action);
240
+ continue;
241
+ }
242
+ if (ports.isCleanupTargetProtected?.(action.target, action))
243
+ continue;
244
+ let closeConfirmed = false;
245
+ try {
246
+ await ports.callApi("closeForumTopic", {
247
+ chat_id: action.target.chatId,
248
+ message_thread_id: action.target.threadId,
249
+ });
250
+ closeConfirmed = true;
251
+ }
252
+ catch (error) {
253
+ closeConfirmed = isTelegramTopicTargetGoneError(error);
254
+ if (!closeConfirmed) {
255
+ ports.recordRuntimeEvent?.("telegram", error, {
256
+ phase: `thread-reconciler-${action.reason}-closeForumTopic`,
257
+ instanceId: action.instanceId,
258
+ chatId: action.target.chatId,
259
+ threadId: action.target.threadId,
260
+ });
261
+ }
262
+ }
263
+ if (shouldSkipForStaleLeaderEpoch(action, ports) || !closeConfirmed) {
264
+ incompleteActions.push(action);
265
+ continue;
266
+ }
267
+ if (ports.isCleanupTargetProtected?.(action.target, action))
268
+ continue;
269
+ const changed = ports.markStaleByTarget?.(action.target, "closed") ?? false;
270
+ if (changed)
271
+ persistFences.push(action);
272
+ shouldPersist = changed || shouldPersist;
273
+ continue;
274
+ }
275
+ if (action.kind === "close-delete-unbound-topic" ||
276
+ action.kind === "close-delete-reserved-topic" ||
277
+ action.kind === "close-delete-replaced-follower-topic" ||
278
+ action.kind === "close-delete-previous-leader-topic" ||
279
+ action.kind === "close-delete-disconnected-instance-topic" ||
280
+ action.kind === "close-delete-graceful-shutdown-topic" ||
281
+ action.kind === "close-delete-expired-pending-provision-topic") {
282
+ if (shouldSkipForStaleLeaderEpoch(action, ports)) {
283
+ incompleteActions.push(action);
284
+ continue;
285
+ }
286
+ if (!ports.callApi) {
287
+ ports.recordRuntimeEvent?.("telegram", "Skipped topic cleanup without API port", {
288
+ phase: "thread-reconciler-cleanup-skip-no-api",
289
+ reason: action.reason,
290
+ chatId: action.target.chatId,
291
+ threadId: action.target.threadId,
292
+ });
293
+ incompleteActions.push(action);
294
+ continue;
295
+ }
296
+ let deleteConfirmed = false;
297
+ let superseded = false;
298
+ for (const method of ["closeForumTopic", "deleteForumTopic"]) {
299
+ if (ports.isCleanupTargetProtected?.(action.target, action)) {
300
+ superseded = true;
301
+ break;
302
+ }
303
+ if (shouldSkipForStaleLeaderEpoch(action, ports))
304
+ break;
305
+ try {
306
+ await ports.callApi(method, {
307
+ chat_id: action.target.chatId,
308
+ message_thread_id: action.target.threadId,
309
+ });
310
+ if (method === "deleteForumTopic")
311
+ deleteConfirmed = true;
312
+ }
313
+ catch (error) {
314
+ const confirmsMethodOutcome = method === "deleteForumTopic"
315
+ ? isTelegramTopicTargetDeletedOrMissingError(error)
316
+ : isTelegramTopicTargetGoneError(error);
317
+ if (confirmsMethodOutcome) {
318
+ if (method === "deleteForumTopic")
319
+ deleteConfirmed = true;
320
+ }
321
+ else {
322
+ ports.recordRuntimeEvent?.("telegram", error, {
323
+ phase: `thread-reconciler-${action.reason}-${method}`,
324
+ chatId: action.target.chatId,
325
+ threadId: action.target.threadId,
326
+ });
327
+ }
328
+ }
329
+ }
330
+ if (shouldSkipForStaleLeaderEpoch(action, ports)) {
331
+ incompleteActions.push(action);
332
+ continue;
333
+ }
334
+ if (superseded || ports.isCleanupTargetProtected?.(action.target, action)) {
335
+ ports.recordRuntimeEvent?.("telegram", "Cancelled cleanup of a protected Telegram target", {
336
+ phase: "thread-reconciler-cleanup-target-reused",
337
+ action: action.kind,
338
+ chatId: action.target.chatId,
339
+ threadId: action.target.threadId,
340
+ });
341
+ continue;
342
+ }
343
+ if (!deleteConfirmed) {
344
+ ports.recordRuntimeEvent?.("telegram", "Telegram topic cleanup incomplete", {
345
+ phase: "thread-reconciler-cleanup-incomplete",
346
+ action: action.kind,
347
+ reason: action.reason,
348
+ chatId: action.target.chatId,
349
+ threadId: action.target.threadId,
350
+ ...("messageId" in action ? { messageId: action.messageId } : {}),
351
+ });
352
+ incompleteActions.push(action);
353
+ continue;
354
+ }
355
+ if (shouldSkipForStaleLeaderEpoch(action, ports))
356
+ continue;
357
+ if (action.kind !== "close-delete-previous-leader-topic" &&
358
+ action.kind !== "close-delete-replaced-follower-topic" &&
359
+ action.kind !== "close-delete-disconnected-instance-topic" &&
360
+ action.kind !== "close-delete-expired-pending-provision-topic") {
361
+ const changed = ports.markStaleByTarget?.(action.target, "deleted") ?? false;
362
+ if (changed)
363
+ persistFences.push(action);
364
+ shouldPersist = changed || shouldPersist;
365
+ }
366
+ ports.recordRuntimeEvent?.("telegram", action.kind === "close-delete-unbound-topic"
367
+ ? "Unbound Telegram topic deleted"
368
+ : action.kind === "close-delete-reserved-topic"
369
+ ? "Reserved Telegram topic deleted"
370
+ : action.kind === "close-delete-replaced-follower-topic"
371
+ ? "Replaced follower Telegram topic deleted"
372
+ : action.kind === "close-delete-previous-leader-topic"
373
+ ? "Previous leader Telegram topic deleted"
374
+ : action.kind === "close-delete-disconnected-instance-topic"
375
+ ? "Disconnected instance Telegram topic deleted"
376
+ : action.kind === "close-delete-graceful-shutdown-topic"
377
+ ? "Graceful shutdown Telegram topic deleted"
378
+ : "Expired pending provision Telegram topic deleted", {
379
+ phase: action.kind === "close-delete-unbound-topic"
380
+ ? "thread-reconciler-unbound-topic-delete"
381
+ : action.kind === "close-delete-reserved-topic"
382
+ ? "thread-reconciler-reserved-topic-delete"
383
+ : action.kind === "close-delete-replaced-follower-topic"
384
+ ? "thread-reconciler-replaced-follower-topic-delete"
385
+ : action.kind === "close-delete-previous-leader-topic"
386
+ ? "thread-reconciler-previous-leader-topic-delete"
387
+ : action.kind ===
388
+ "close-delete-disconnected-instance-topic"
389
+ ? "thread-reconciler-disconnected-instance-topic-delete"
390
+ : action.kind ===
391
+ "close-delete-graceful-shutdown-topic"
392
+ ? "thread-reconciler-graceful-shutdown-topic-delete"
393
+ : "thread-reconciler-expired-pending-provision-topic-delete",
394
+ chatId: action.target.chatId,
395
+ threadId: action.target.threadId,
396
+ ...("messageId" in action ? { messageId: action.messageId } : {}),
397
+ });
398
+ if (action.kind === "close-delete-expired-pending-provision-topic" &&
399
+ deleteConfirmed) {
400
+ if (shouldSkipForStaleLeaderEpoch(action, ports))
401
+ continue;
402
+ const changed = ports.removePendingProvisionById?.(action.pendingProvisionId) ??
403
+ false;
404
+ if (changed)
405
+ persistFences.push(action);
406
+ shouldPersist = changed || shouldPersist;
407
+ }
408
+ if (action.kind === "close-delete-graceful-shutdown-topic" &&
409
+ deleteConfirmed) {
410
+ if (shouldSkipForStaleLeaderEpoch(action, ports))
411
+ continue;
412
+ const changed = ports.removeCleanupIntentById?.(action.cleanupIntentId) ?? false;
413
+ if (changed)
414
+ persistFences.push(action);
415
+ shouldPersist = changed || shouldPersist;
416
+ }
417
+ }
418
+ }
419
+ if (shouldPersist) {
420
+ for (const action of persistFences) {
421
+ if (shouldSkipForStaleLeaderEpoch(action, ports)) {
422
+ return {
423
+ changed: true,
424
+ ...(incompleteActions.length > 0 ? { incompleteActions } : {}),
425
+ };
426
+ }
427
+ }
428
+ await ports.persist?.();
429
+ }
430
+ return {
431
+ changed: shouldPersist,
432
+ ...(incompleteActions.length > 0 ? { incompleteActions } : {}),
433
+ };
434
+ }
435
+ export function planThreadReconciliation(input) {
436
+ const graceMs = input.freshCreationGraceMs ?? DEFAULT_FRESH_CREATION_GRACE_MS;
437
+ const knownTargets = new Set(input.records.map((record) => targetKey(record.target)));
438
+ const currentTargets = new Set(input.records
439
+ .filter(isCurrentRecord)
440
+ .map((record) => targetKey(record.target)));
441
+ const reservedTargets = new Set((input.reservations ?? [])
442
+ .filter((reservation) => isReservationAlive(reservation, input.nowMs))
443
+ .map((reservation) => targetKey(reservation.target)));
444
+ const pendingTargets = new Set((input.pendingProvisions ?? [])
445
+ .filter((provision) => isPendingProvisionAlive(provision, input.nowMs, input.currentLeaderEpoch))
446
+ .flatMap((provision) => provision.target ? [targetKey(provision.target)] : []));
447
+ const recentOpenTargets = new Set((input.observations ?? [])
448
+ .filter((observation) => isRecentOpenObservation(observation, input.nowMs, graceMs))
449
+ .map((observation) => targetKey(observation.target)));
450
+ const actions = [];
451
+ for (const cleanup of input.pendingCleanups ?? []) {
452
+ const superseded = input.records.some((record) => isCurrentRecord(record) &&
453
+ targetKey(record.target) === targetKey(cleanup.target) &&
454
+ record.instanceId !== cleanup.instanceId);
455
+ const common = {
456
+ target: cleanup.target,
457
+ cleanupIntentId: cleanup.id,
458
+ instanceId: cleanup.instanceId,
459
+ runtimeGeneration: cleanup.runtimeGeneration,
460
+ ...(input.currentLeaderEpoch !== undefined
461
+ ? { leaderEpoch: input.currentLeaderEpoch }
462
+ : {}),
463
+ };
464
+ if (superseded) {
465
+ actions.push({
466
+ ...common,
467
+ kind: "cancel-superseded-graceful-shutdown-cleanup",
468
+ reason: "replacement-registration",
469
+ });
470
+ }
471
+ else {
472
+ actions.push({
473
+ ...common,
474
+ kind: "close-delete-graceful-shutdown-topic",
475
+ reason: "graceful-shutdown",
476
+ });
477
+ }
478
+ }
479
+ for (const provision of input.pendingProvisions ?? []) {
480
+ if (!provision.target)
481
+ continue;
482
+ if (!isPendingProvisionExpired(provision, input.nowMs))
483
+ continue;
484
+ const key = targetKey(provision.target);
485
+ if (currentTargets.has(key))
486
+ continue;
487
+ if (reservedTargets.has(key))
488
+ continue;
489
+ if (recentOpenTargets.has(key))
490
+ continue;
491
+ actions.push({
492
+ kind: "close-delete-expired-pending-provision-topic",
493
+ target: provision.target,
494
+ reason: "expired-pending-provision",
495
+ pendingProvisionId: provision.id,
496
+ instanceId: provision.instanceId,
497
+ ...(input.currentLeaderEpoch !== undefined
498
+ ? { leaderEpoch: input.currentLeaderEpoch }
499
+ : {}),
500
+ });
501
+ }
502
+ for (const observation of input.observations ?? []) {
503
+ if (observation.syncStatus === "open") {
504
+ if (knownTargets.has(targetKey(observation.target))) {
505
+ actions.push({
506
+ kind: "mark-topic-active",
507
+ target: observation.target,
508
+ reason: "observed-open",
509
+ });
510
+ }
511
+ continue;
512
+ }
513
+ if (observation.syncStatus === "closed" ||
514
+ observation.syncStatus === "deleted") {
515
+ if (currentTargets.has(targetKey(observation.target))) {
516
+ actions.push({
517
+ kind: "mark-topic-stale",
518
+ target: observation.target,
519
+ syncStatus: observation.syncStatus,
520
+ reason: observation.syncStatus === "closed"
521
+ ? "observed-closed"
522
+ : "observed-deleted",
523
+ });
524
+ }
525
+ }
526
+ }
527
+ for (const replacement of input.replacedBindings ?? []) {
528
+ for (const record of input.records) {
529
+ if (record.instanceId !== replacement.instanceId)
530
+ continue;
531
+ if (!isActiveOrStartingRecord(record))
532
+ continue;
533
+ if (targetKey(record.target) === targetKey(replacement.replacementTarget)) {
534
+ continue;
535
+ }
536
+ actions.push({
537
+ kind: "close-stale-replaced-topic",
538
+ target: record.target,
539
+ reason: "replaced-instance-binding",
540
+ instanceId: replacement.instanceId,
541
+ ...(input.currentLeaderEpoch !== undefined
542
+ ? { leaderEpoch: input.currentLeaderEpoch }
543
+ : {}),
544
+ });
545
+ }
546
+ }
547
+ if (input.previousLeaderCleanup) {
548
+ for (const record of input.records) {
549
+ if (!record.instanceId)
550
+ continue;
551
+ if (record.instanceId === input.previousLeaderCleanup.currentInstanceId) {
552
+ continue;
553
+ }
554
+ if (!isActiveOrStartingRecord(record))
555
+ continue;
556
+ if (!isPreviousLeaderRecord(record))
557
+ continue;
558
+ actions.push({
559
+ kind: "close-delete-previous-leader-topic",
560
+ target: record.target,
561
+ reason: "previous-leader",
562
+ instanceId: input.previousLeaderCleanup.currentInstanceId,
563
+ ...(input.currentLeaderEpoch !== undefined
564
+ ? { leaderEpoch: input.currentLeaderEpoch }
565
+ : {}),
566
+ });
567
+ }
568
+ }
569
+ if (input.proactiveReservationCleanup) {
570
+ for (const reservation of input.reservations ?? []) {
571
+ const key = targetKey(reservation.target);
572
+ if (currentTargets.has(key))
573
+ continue;
574
+ if (pendingTargets.has(key))
575
+ continue;
576
+ actions.push({
577
+ kind: "close-delete-reserved-topic",
578
+ target: reservation.target,
579
+ observedAtMs: input.nowMs,
580
+ reason: "startup-reservation",
581
+ ...(input.currentLeaderEpoch !== undefined
582
+ ? { leaderEpoch: input.currentLeaderEpoch }
583
+ : {}),
584
+ });
585
+ }
586
+ }
587
+ for (const message of input.reservedMessages ?? []) {
588
+ const key = targetKey(message.target);
589
+ if (!reservedTargets.has(key))
590
+ continue;
591
+ if (currentTargets.has(key))
592
+ continue;
593
+ if (pendingTargets.has(key))
594
+ continue;
595
+ if (input.currentLeaderEpoch !== undefined &&
596
+ message.leaderEpoch !== undefined &&
597
+ message.leaderEpoch !== input.currentLeaderEpoch) {
598
+ continue;
599
+ }
600
+ actions.push({
601
+ kind: "close-delete-reserved-topic",
602
+ target: message.target,
603
+ observedAtMs: message.observedAtMs,
604
+ messageId: message.messageId,
605
+ reason: "reserved-user-message",
606
+ ...(input.currentLeaderEpoch !== undefined
607
+ ? { leaderEpoch: input.currentLeaderEpoch }
608
+ : {}),
609
+ });
610
+ }
611
+ for (const message of input.unboundMessages ?? []) {
612
+ const key = targetKey(message.target);
613
+ if (currentTargets.has(key))
614
+ continue;
615
+ if (reservedTargets.has(key))
616
+ continue;
617
+ if (pendingTargets.has(key))
618
+ continue;
619
+ if (recentOpenTargets.has(key))
620
+ continue;
621
+ if (input.currentLeaderEpoch !== undefined &&
622
+ message.leaderEpoch !== undefined &&
623
+ message.leaderEpoch !== input.currentLeaderEpoch) {
624
+ continue;
625
+ }
626
+ actions.push({
627
+ kind: "close-delete-unbound-topic",
628
+ target: message.target,
629
+ observedAtMs: message.observedAtMs,
630
+ messageId: message.messageId,
631
+ reason: "unbound-user-message",
632
+ ...(input.currentLeaderEpoch !== undefined
633
+ ? { leaderEpoch: input.currentLeaderEpoch }
634
+ : {}),
635
+ });
636
+ }
637
+ const state = createThreadReconciliationMachineState(input, actions);
638
+ const transition = createThreadReconciliationTransition(input.previousState, state);
639
+ return {
640
+ actions,
641
+ state,
642
+ ...(transition ? { transition } : {}),
643
+ };
644
+ }