@llblab/pi-telegram 0.24.6 → 0.24.8

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.
@@ -6,7 +6,10 @@
6
6
 
7
7
  import { randomUUID } from "node:crypto";
8
8
 
9
- import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
9
+ import type {
10
+ TelegramInlineKeyboardButtonStyle,
11
+ TelegramInlineKeyboardMarkup,
12
+ } from "./keyboard.ts";
10
13
  import {
11
14
  parseTelegramCommentAttributes,
12
15
  parseTopLevelTelegramComment,
@@ -24,6 +27,7 @@ const TELEGRAM_BUTTON_ACTION_TTL_MS = 24 * 60 * 60 * 1000;
24
27
  export interface TelegramOutboundButtonAction {
25
28
  text: string;
26
29
  prompt: string;
30
+ selectedStyle?: TelegramInlineKeyboardButtonStyle;
27
31
  }
28
32
 
29
33
  export interface TelegramOutboundButtonStoredAction extends TelegramOutboundButtonAction {
@@ -51,6 +55,7 @@ export interface TelegramButtonCallbackQuery {
51
55
  message_id?: number;
52
56
  message_thread_id?: number;
53
57
  chat?: { id?: number };
58
+ reply_markup?: TelegramOutboundButtonMarkup;
54
59
  };
55
60
  }
56
61
 
@@ -66,7 +71,12 @@ export interface TelegramButtonCallbackHandlerDeps<TContext = unknown> {
66
71
  query: TelegramButtonCallbackQuery,
67
72
  action: TelegramOutboundButtonAction,
68
73
  ctx: TContext,
69
- ) => void;
74
+ ) => boolean | void;
75
+ editMessageReplyMarkup?: (
76
+ chatId: number,
77
+ messageId: number,
78
+ replyMarkup: TelegramOutboundButtonMarkup,
79
+ ) => Promise<void>;
70
80
  }
71
81
 
72
82
  function nowMs(): number {
@@ -80,11 +90,18 @@ function normalizeMarkdownAfterButtonExtraction(markdown: string): string {
80
90
  function parseButtonsCommentAttributes(input: string): {
81
91
  label?: string;
82
92
  prompt?: string;
93
+ selectedStyle?: TelegramInlineKeyboardButtonStyle;
83
94
  } {
84
95
  const attributes = parseTelegramCommentAttributes(input);
96
+ const selectedStyle = attributes.selected_style;
85
97
  return {
86
98
  ...(attributes.label ? { label: attributes.label } : {}),
87
99
  ...(attributes.prompt ? { prompt: attributes.prompt } : {}),
100
+ ...(selectedStyle === "success" ||
101
+ selectedStyle === "danger" ||
102
+ selectedStyle === "primary"
103
+ ? { selectedStyle }
104
+ : {}),
88
105
  };
89
106
  }
90
107
 
@@ -101,14 +118,34 @@ function parseButtonsCommentRows(
101
118
  }
102
119
  const attributes = parseButtonsCommentAttributes(head);
103
120
  return attributes.label && attributes.prompt
104
- ? [[{ text: attributes.label, prompt: attributes.prompt }]]
121
+ ? [
122
+ [
123
+ {
124
+ text: attributes.label,
125
+ prompt: attributes.prompt,
126
+ ...(attributes.selectedStyle
127
+ ? { selectedStyle: attributes.selectedStyle }
128
+ : {}),
129
+ },
130
+ ],
131
+ ]
105
132
  : [];
106
133
  }
107
134
 
108
- const label = parseButtonsCommentAttributes(head).label;
135
+ const attributes = parseButtonsCommentAttributes(head);
109
136
  const prompt = body.trim();
110
- if (!label || !prompt) return [];
111
- return [[{ text: label, prompt }]];
137
+ if (!attributes.label || !prompt) return [];
138
+ return [
139
+ [
140
+ {
141
+ text: attributes.label,
142
+ prompt,
143
+ ...(attributes.selectedStyle
144
+ ? { selectedStyle: attributes.selectedStyle }
145
+ : {}),
146
+ },
147
+ ],
148
+ ];
112
149
  }
113
150
 
114
151
  export function createTelegramButtonActionStore(
@@ -138,11 +175,19 @@ export function createTelegramButtonActionStore(
138
175
  const action = actions.get(callbackData);
139
176
  if (!action) return undefined;
140
177
  actions.delete(callbackData);
141
- return { text: action.text, prompt: action.prompt };
178
+ return {
179
+ text: action.text,
180
+ prompt: action.prompt,
181
+ ...(action.selectedStyle
182
+ ? { selectedStyle: action.selectedStyle }
183
+ : {}),
184
+ };
142
185
  },
143
186
  };
144
187
  }
145
188
 
189
+ const DEFAULT_TELEGRAM_BUTTON_REPLY_MARKDOWN = "Choose an option:";
190
+
146
191
  export function planTelegramButtonReply(
147
192
  markdown: string,
148
193
  deps: { registerAction: (action: TelegramOutboundButtonAction) => string },
@@ -162,8 +207,12 @@ export function planTelegramButtonReply(
162
207
  }
163
208
  return "";
164
209
  });
210
+ const visibleMarkdown = normalizeMarkdownAfterButtonExtraction(stripped);
165
211
  return {
166
- markdown: normalizeMarkdownAfterButtonExtraction(stripped),
212
+ markdown:
213
+ keyboard.length > 0 && !visibleMarkdown
214
+ ? DEFAULT_TELEGRAM_BUTTON_REPLY_MARKDOWN
215
+ : visibleMarkdown,
167
216
  ...(keyboard.length > 0
168
217
  ? { replyMarkup: { inline_keyboard: keyboard } }
169
218
  : {}),
@@ -203,6 +252,22 @@ export function createTelegramButtonPromptTurn(options: {
203
252
  };
204
253
  }
205
254
 
255
+ export function markTelegramButtonSelected(
256
+ replyMarkup: TelegramOutboundButtonMarkup,
257
+ callbackData: string,
258
+ selectedStyle: TelegramInlineKeyboardButtonStyle = "primary",
259
+ ): TelegramOutboundButtonMarkup | undefined {
260
+ let matched = false;
261
+ const inlineKeyboard = replyMarkup.inline_keyboard.map((row) =>
262
+ row.map((button) => {
263
+ if (button.callback_data !== callbackData) return { ...button };
264
+ matched = true;
265
+ return { ...button, style: selectedStyle };
266
+ }),
267
+ );
268
+ return matched ? { inline_keyboard: inlineKeyboard } : undefined;
269
+ }
270
+
206
271
  export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
207
272
  query: TelegramButtonCallbackQuery,
208
273
  ctx: TContext,
@@ -225,7 +290,22 @@ export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
225
290
  return true;
226
291
  }
227
292
 
228
- deps.enqueueButtonPrompt(query, action, ctx);
293
+ const enqueued = deps.enqueueButtonPrompt(query, action, ctx);
294
+ if (enqueued === false) {
295
+ await deps.answerCallbackQuery(query.id, "Already queued.");
296
+ return true;
297
+ }
298
+ const selectedMarkup =
299
+ query.data && query.message?.reply_markup
300
+ ? markTelegramButtonSelected(
301
+ query.message.reply_markup,
302
+ query.data,
303
+ action.selectedStyle,
304
+ )
305
+ : undefined;
306
+ if (selectedMarkup && deps.editMessageReplyMarkup) {
307
+ await deps.editMessageReplyMarkup(chatId, messageId, selectedMarkup);
308
+ }
229
309
  await deps.answerCallbackQuery(query.id, "Queued.");
230
310
  return true;
231
311
  }
package/lib/outbound.ts CHANGED
@@ -817,6 +817,7 @@ export {
817
817
  createTelegramButtonPromptTurn,
818
818
  createTelegramButtonReplyPlanner,
819
819
  handleTelegramButtonCallbackQuery,
820
+ markTelegramButtonSelected,
820
821
  planTelegramButtonReply,
821
822
  type TelegramButtonActionStore,
822
823
  type TelegramButtonCallbackHandlerDeps,
package/lib/prompts.ts CHANGED
@@ -143,8 +143,9 @@ Assistant-authored Telegram actions:
143
143
  - Keep voice text TTS-friendly; avoid raw Markdown, code, and tables in voice text.
144
144
  - Voice delivery generates and attaches OGG automatically; do not also call \`telegram_attach\` for the same audio.
145
145
  - Voice reply modes are compact: \`hidden\` emits no automatic context, \`mirror\` emits it for voice/audio input, and \`always\` emits it for every Telegram turn. Explicit \`telegram_voice\` remains available for an intentionally distinct spoken payload.
146
- - Button forms: \`<!-- telegram_button: OK -->\`, \`<!-- telegram_button label=Continue prompt="Continue with the current plan." -->\`, or multiline \`<!-- telegram_button label="Show risks"\nList the main risks first.\n-->\`.
147
- - If hidden comments would be the whole reply, add visible text such as \`Choose one:\`.
146
+ - Button forms: \`<!-- telegram_button: OK -->\`, \`<!-- telegram_button label=Continue prompt="Continue with the current plan." -->\`, or multiline \`<!-- telegram_button label="Show risks" selected_style="danger"\nList the main risks first.\n-->\`.
147
+ - Optional \`selected_style\` controls the button after queue admission: \`primary\` (default, blue), \`success\` (green), or \`danger\` (red). It never suppresses the prompt.
148
+ - If hidden button comments form the whole reply, the bridge supplies visible fallback text automatically.
148
149
 
149
150
  Local/TUI direct delivery:
150
151
  - Do not send Telegram actions from local/TUI prompts unless explicitly asked.
package/lib/routing.ts CHANGED
@@ -608,6 +608,11 @@ export interface TelegramInboundRouteRuntimeDeps<
608
608
  mode: "markdown" | "html" | "plain",
609
609
  replyMarkup: Menu.TelegramReplyMarkup,
610
610
  ) => Promise<void>;
611
+ editMessageReplyMarkup?: (
612
+ chatId: number,
613
+ messageId: number,
614
+ replyMarkup: OutboundHandlers.TelegramOutboundButtonMarkup,
615
+ ) => Promise<void>;
611
616
  sendInteractiveMessage?: (
612
617
  chatId: number,
613
618
  text: string,
@@ -1448,11 +1453,28 @@ export function createTelegramInboundRouteRuntime<
1448
1453
  {
1449
1454
  resolveAction: deps.buttonActionStore.resolve,
1450
1455
  answerCallbackQuery: deps.answerCallbackQuery,
1456
+ editMessageReplyMarkup: deps.editMessageReplyMarkup
1457
+ ? async (chatId, messageId, replyMarkup) => {
1458
+ try {
1459
+ await deps.editMessageReplyMarkup?.(
1460
+ chatId,
1461
+ messageId,
1462
+ replyMarkup,
1463
+ );
1464
+ } catch (error) {
1465
+ deps.recordRuntimeEvent?.("telegram", error, {
1466
+ phase: "button-selection-mark",
1467
+ chatId,
1468
+ messageId,
1469
+ });
1470
+ }
1471
+ }
1472
+ : undefined,
1451
1473
  enqueueButtonPrompt: (buttonQuery, action, context) => {
1452
1474
  const chatId = buttonQuery.message?.chat?.id;
1453
1475
  const messageId = buttonQuery.message?.message_id;
1454
1476
  if (typeof chatId !== "number" || typeof messageId !== "number")
1455
- return;
1477
+ return false;
1456
1478
  const queueOrder = deps.bridgeRuntime.queue.allocateItemOrder();
1457
1479
  const turn = OutboundHandlers.createTelegramButtonPromptTurn({
1458
1480
  chatId,
@@ -1471,10 +1493,11 @@ export function createTelegramInboundRouteRuntime<
1471
1493
  deps.telegramQueueStore.getQueuedItems(),
1472
1494
  turn,
1473
1495
  );
1474
- if (!result.appended) return;
1496
+ if (!result.appended) return false;
1475
1497
  deps.telegramQueueStore.setQueuedItems(result.items);
1476
1498
  deps.updateStatus(context);
1477
1499
  requestDispatchNextQueuedTelegramTurn(context);
1500
+ return true;
1478
1501
  },
1479
1502
  },
1480
1503
  );
package/lib/status.ts CHANGED
@@ -1272,11 +1272,13 @@ function buildStatusSummary(ctx: TelegramStatusContext): string {
1272
1272
  return "unknown";
1273
1273
  }
1274
1274
 
1275
- function buildTelegramStatusRoleSuffix(
1275
+ function buildTelegramStatusThreadSummary(
1276
1276
  state: TelegramBridgeStatusLineState | undefined,
1277
- ): string {
1278
- if (state?.botThreadMode !== "enabled" || !state.busRole) return "";
1279
- return ` @${state.busRole}`;
1277
+ ): string | undefined {
1278
+ if (state?.botThreadMode !== "enabled" || !state.busRole) return undefined;
1279
+ const threadName = state.instanceThreadName?.trim();
1280
+ const identity = threadName || (state.instanceSlot ? `[${state.instanceSlot}]` : "");
1281
+ return identity ? `${identity} @${state.busRole}` : undefined;
1280
1282
  }
1281
1283
 
1282
1284
  export function buildStatusHtml(
@@ -1288,12 +1290,11 @@ export function buildStatusHtml(
1288
1290
  const usesSubscription = activeModel
1289
1291
  ? ctx.modelRegistry.isUsingOAuth(activeModel)
1290
1292
  : false;
1291
- const lines: string[] = [
1292
- buildStatusRow(
1293
- "Status",
1294
- `${buildStatusSummary(ctx)}${buildTelegramStatusRoleSuffix(bridgeStatus)}`,
1295
- ),
1296
- ];
1293
+ const lines: string[] = [buildStatusRow("Status", buildStatusSummary(ctx))];
1294
+ const threadSummary = buildTelegramStatusThreadSummary(bridgeStatus);
1295
+ if (threadSummary) {
1296
+ lines.push(buildStatusRow("Thread", threadSummary));
1297
+ }
1297
1298
  const usageSummary = buildUsageSummary(stats);
1298
1299
  const costSummary = buildCostSummary(stats, usesSubscription);
1299
1300
  if (usageSummary) {
package/lib/sync.ts CHANGED
@@ -110,11 +110,19 @@ export interface TelegramLeaderHealthRuntime {
110
110
  export interface TelegramManualThreadDisconnectDeps<TSyncState> {
111
111
  instanceId: string;
112
112
  getCurrentThreadRecord: () =>
113
- | { target: TelegramTarget; instanceId?: string; owner?: { kind?: string } }
113
+ | {
114
+ target: TelegramTarget;
115
+ instanceId?: string;
116
+ profileKey?: string;
117
+ owner?: { kind?: string };
118
+ }
114
119
  | undefined;
115
120
  topicTargetStore: Pick<
116
121
  TelegramTopicTargetStore,
117
- "markOfflineByInstanceId" | "persist"
122
+ | "markStaleByTarget"
123
+ | "persist"
124
+ | "upsertPendingCleanup"
125
+ | "removePendingCleanup"
118
126
  >;
119
127
  callApi: <TResponse>(
120
128
  method: string,
@@ -175,22 +183,44 @@ export function createTelegramManualThreadDisconnectHandler<
175
183
  }
176
184
  }
177
185
  } else {
178
- const stillOwnsLeaderEpoch = () =>
179
- !deps.getCurrentLeaderEpoch ||
180
- (leaderEpoch !== undefined &&
181
- deps.getCurrentLeaderEpoch() === leaderEpoch);
186
+ const target = currentRecord.target as TelegramTarget & {
187
+ threadId: number;
188
+ };
189
+ const runtimeGeneration = currentRecord.instanceId ?? deps.instanceId;
190
+ const intent: ThreadReconciler.TelegramThreadCleanupIntent = {
191
+ id: `cleanup:${deps.instanceId}:${runtimeGeneration}:${target.chatId}:${target.threadId}`,
192
+ owner: isManualFollower ? "manual-follower" : "leader",
193
+ instanceId: deps.instanceId,
194
+ runtimeGeneration,
195
+ ...(currentRecord.profileKey
196
+ ? { profileKey: currentRecord.profileKey }
197
+ : {}),
198
+ target,
199
+ requestedAtMs: (deps.getNowMs ?? Date.now)(),
200
+ };
201
+ deps.topicTargetStore.upsertPendingCleanup(intent);
202
+ await deps.topicTargetStore.persist();
182
203
  const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(
183
- ThreadReconciler.planDisconnectedInstanceThreadCleanup({
184
- target: currentRecord.target as TelegramTarget & {
185
- threadId: number;
186
- },
187
- instanceId: deps.instanceId,
188
- leaderEpoch,
204
+ ThreadReconciler.planThreadReconciliation({
205
+ nowMs: (deps.getNowMs ?? Date.now)(),
206
+ currentLeaderEpoch: leaderEpoch,
207
+ records: [],
208
+ pendingCleanups: [intent],
189
209
  }),
190
210
  {
191
211
  callApi(method, body) {
192
212
  return deps.callApi(method, body);
193
213
  },
214
+ markStaleByTarget(targetToMark, syncStatus, lastSyncError) {
215
+ return deps.topicTargetStore.markStaleByTarget(
216
+ targetToMark,
217
+ syncStatus,
218
+ lastSyncError,
219
+ );
220
+ },
221
+ removeCleanupIntentById(id) {
222
+ return deps.topicTargetStore.removePendingCleanup(id);
223
+ },
194
224
  persist() {
195
225
  return deps.topicTargetStore.persist();
196
226
  },
@@ -203,14 +233,6 @@ export function createTelegramManualThreadDisconnectHandler<
203
233
  "Telegram thread deletion was not confirmed; inspect /telegram-status --debug and retry /telegram-disconnect.",
204
234
  );
205
235
  }
206
- if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
207
- const offlineChanged =
208
- deps.topicTargetStore.markOfflineByInstanceId(deps.instanceId) > 0;
209
- if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
210
- if (offlineChanged) {
211
- await deps.topicTargetStore.persist();
212
- if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
213
- }
214
236
  }
215
237
  const leaderTarget = deps.getLeaderTarget();
216
238
  if (
@@ -48,6 +48,16 @@ export interface TelegramThreadPendingProvision {
48
48
  leaderEpoch?: number | string;
49
49
  }
50
50
 
51
+ export interface TelegramThreadCleanupIntent {
52
+ id: string;
53
+ owner: "leader" | "manual-follower";
54
+ instanceId: string;
55
+ runtimeGeneration: string;
56
+ profileKey?: string;
57
+ target: ThreadTarget;
58
+ requestedAtMs: number;
59
+ }
60
+
51
61
  export interface TelegramUnboundThreadMessageObservation {
52
62
  target: TelegramTarget & { threadId: number };
53
63
  observedAtMs: number;
@@ -130,6 +140,24 @@ export type ThreadReconciliationAction =
130
140
  messageId?: number;
131
141
  leaderEpoch?: number | string;
132
142
  }
143
+ | {
144
+ kind: "close-delete-graceful-shutdown-topic";
145
+ target: TelegramTarget & { threadId: number };
146
+ reason: "graceful-shutdown";
147
+ cleanupIntentId: string;
148
+ instanceId: string;
149
+ runtimeGeneration: string;
150
+ leaderEpoch?: number | string;
151
+ }
152
+ | {
153
+ kind: "cancel-superseded-graceful-shutdown-cleanup";
154
+ target: TelegramTarget & { threadId: number };
155
+ reason: "replacement-registration";
156
+ cleanupIntentId: string;
157
+ instanceId: string;
158
+ runtimeGeneration: string;
159
+ leaderEpoch?: number | string;
160
+ }
133
161
  | {
134
162
  kind: "close-delete-expired-pending-provision-topic";
135
163
  target: TelegramTarget & { threadId: number };
@@ -186,6 +214,7 @@ export interface ThreadReconciliationApplyPorts {
186
214
  ) => boolean;
187
215
  persist?: () => Promise<void>;
188
216
  removePendingProvisionById?: (id: string) => boolean;
217
+ removeCleanupIntentById?: (id: string) => boolean;
189
218
  getCurrentLeaderEpoch?: () => number | string | undefined;
190
219
  recordRuntimeEvent?: (
191
220
  category: string,
@@ -201,6 +230,7 @@ export interface ThreadReconciliationInput {
201
230
  reservations?: readonly ThreadReconciliationReservation[];
202
231
  observations?: readonly ThreadReconciliationObservation[];
203
232
  pendingProvisions?: readonly TelegramThreadPendingProvision[];
233
+ pendingCleanups?: readonly TelegramThreadCleanupIntent[];
204
234
  unboundMessages?: readonly TelegramUnboundThreadMessageObservation[];
205
235
  reservedMessages?: readonly TelegramReservedThreadMessageObservation[];
206
236
  proactiveReservationCleanup?: boolean;
@@ -291,6 +321,8 @@ function isCleanupAction(action: ThreadReconciliationAction): boolean {
291
321
  action.kind === "close-delete-replaced-follower-topic" ||
292
322
  action.kind === "close-delete-previous-leader-topic" ||
293
323
  action.kind === "close-delete-disconnected-instance-topic" ||
324
+ action.kind === "close-delete-graceful-shutdown-topic" ||
325
+ action.kind === "cancel-superseded-graceful-shutdown-cleanup" ||
294
326
  action.kind === "close-delete-expired-pending-provision-topic"
295
327
  );
296
328
  }
@@ -505,6 +537,26 @@ export async function applyThreadReconciliationPlan(
505
537
  shouldPersist;
506
538
  continue;
507
539
  }
540
+ if (action.kind === "cancel-superseded-graceful-shutdown-cleanup") {
541
+ if (shouldSkipForStaleLeaderEpoch(action, ports)) {
542
+ incompleteActions.push(action);
543
+ continue;
544
+ }
545
+ const changed =
546
+ ports.removeCleanupIntentById?.(action.cleanupIntentId) ?? false;
547
+ shouldPersist = changed || shouldPersist;
548
+ ports.recordRuntimeEvent?.(
549
+ "telegram",
550
+ "Cancelled superseded Telegram topic cleanup",
551
+ {
552
+ phase: "thread-reconciler-cleanup-superseded",
553
+ instanceId: action.instanceId,
554
+ chatId: action.target.chatId,
555
+ threadId: action.target.threadId,
556
+ },
557
+ );
558
+ continue;
559
+ }
508
560
  if (action.kind === "close-stale-replaced-topic") {
509
561
  if (shouldSkipForStaleLeaderEpoch(action, ports)) {
510
562
  incompleteActions.push(action);
@@ -558,6 +610,7 @@ export async function applyThreadReconciliationPlan(
558
610
  action.kind === "close-delete-replaced-follower-topic" ||
559
611
  action.kind === "close-delete-previous-leader-topic" ||
560
612
  action.kind === "close-delete-disconnected-instance-topic" ||
613
+ action.kind === "close-delete-graceful-shutdown-topic" ||
561
614
  action.kind === "close-delete-expired-pending-provision-topic"
562
615
  ) {
563
616
  if (shouldSkipForStaleLeaderEpoch(action, ports)) {
@@ -647,7 +700,9 @@ export async function applyThreadReconciliationPlan(
647
700
  ? "Previous leader Telegram topic deleted"
648
701
  : action.kind === "close-delete-disconnected-instance-topic"
649
702
  ? "Disconnected instance Telegram topic deleted"
650
- : "Expired pending provision Telegram topic deleted",
703
+ : action.kind === "close-delete-graceful-shutdown-topic"
704
+ ? "Graceful shutdown Telegram topic deleted"
705
+ : "Expired pending provision Telegram topic deleted",
651
706
  {
652
707
  phase:
653
708
  action.kind === "close-delete-unbound-topic"
@@ -661,7 +716,10 @@ export async function applyThreadReconciliationPlan(
661
716
  : action.kind ===
662
717
  "close-delete-disconnected-instance-topic"
663
718
  ? "thread-reconciler-disconnected-instance-topic-delete"
664
- : "thread-reconciler-expired-pending-provision-topic-delete",
719
+ : action.kind ===
720
+ "close-delete-graceful-shutdown-topic"
721
+ ? "thread-reconciler-graceful-shutdown-topic-delete"
722
+ : "thread-reconciler-expired-pending-provision-topic-delete",
665
723
  chatId: action.target.chatId,
666
724
  threadId: action.target.threadId,
667
725
  ...("messageId" in action ? { messageId: action.messageId } : {}),
@@ -678,6 +736,16 @@ export async function applyThreadReconciliationPlan(
678
736
  if (changed) persistFences.push(action);
679
737
  shouldPersist = changed || shouldPersist;
680
738
  }
739
+ if (
740
+ action.kind === "close-delete-graceful-shutdown-topic" &&
741
+ deleteConfirmed
742
+ ) {
743
+ if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
744
+ const changed =
745
+ ports.removeCleanupIntentById?.(action.cleanupIntentId) ?? false;
746
+ if (changed) persistFences.push(action);
747
+ shouldPersist = changed || shouldPersist;
748
+ }
681
749
  }
682
750
  }
683
751
  if (shouldPersist) {
@@ -736,6 +804,36 @@ export function planThreadReconciliation(
736
804
  );
737
805
 
738
806
  const actions: ThreadReconciliationAction[] = [];
807
+ for (const cleanup of input.pendingCleanups ?? []) {
808
+ const superseded = input.records.some(
809
+ (record) =>
810
+ isCurrentRecord(record) &&
811
+ targetKey(record.target) === targetKey(cleanup.target) &&
812
+ record.instanceId !== cleanup.instanceId,
813
+ );
814
+ const common = {
815
+ target: cleanup.target,
816
+ cleanupIntentId: cleanup.id,
817
+ instanceId: cleanup.instanceId,
818
+ runtimeGeneration: cleanup.runtimeGeneration,
819
+ ...(input.currentLeaderEpoch !== undefined
820
+ ? { leaderEpoch: input.currentLeaderEpoch }
821
+ : {}),
822
+ };
823
+ if (superseded) {
824
+ actions.push({
825
+ ...common,
826
+ kind: "cancel-superseded-graceful-shutdown-cleanup",
827
+ reason: "replacement-registration",
828
+ });
829
+ } else {
830
+ actions.push({
831
+ ...common,
832
+ kind: "close-delete-graceful-shutdown-topic",
833
+ reason: "graceful-shutdown",
834
+ });
835
+ }
836
+ }
739
837
  for (const provision of input.pendingProvisions ?? []) {
740
838
  if (!provision.target) continue;
741
839
  if (!isPendingProvisionExpired(provision, input.nowMs)) continue;