@llblab/pi-kit 0.36.1 → 0.36.2

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 (70) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +4 -0
  4. package/node_modules/@llblab/pi-claude-usage/README.md +2 -2
  5. package/node_modules/@llblab/pi-claude-usage/lib/status-format.ts +2 -3
  6. package/node_modules/@llblab/pi-claude-usage/package.json +1 -1
  7. package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +5 -0
  8. package/node_modules/@llblab/pi-codex-usage/README.md +1 -1
  9. package/node_modules/@llblab/pi-codex-usage/lib/status-format.ts +2 -3
  10. package/node_modules/@llblab/pi-codex-usage/package.json +1 -1
  11. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
  12. package/node_modules/@llblab/pi-telegram/BACKLOG.md +17 -5
  13. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +23 -0
  14. package/node_modules/@llblab/pi-telegram/README.md +4 -2
  15. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +17 -1
  16. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +12 -2
  17. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +17 -6
  18. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +1 -0
  19. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +1 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +42 -10
  21. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +1 -0
  22. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.js +9 -0
  23. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +3 -2
  25. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +2 -2
  26. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.d.ts +6 -3
  27. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.js +50 -9
  28. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +119 -14
  29. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +11 -0
  30. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +9 -0
  31. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +39 -3
  32. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +144 -16
  33. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.d.ts +1 -1
  34. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.js +15 -8
  35. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +14 -1
  36. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +122 -8
  37. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +15 -1
  38. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +22 -6
  39. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +4 -3
  40. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +3 -3
  41. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +1 -0
  42. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +52 -2
  43. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +17 -0
  44. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +25 -0
  45. package/node_modules/@llblab/pi-telegram/dist/package.json +2 -1
  46. package/node_modules/@llblab/pi-telegram/docs/architecture.md +10 -10
  47. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +9 -3
  48. package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -0
  49. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
  50. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +24 -1
  51. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +16 -0
  52. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -5
  53. package/node_modules/@llblab/pi-telegram/lib/bus.ts +3 -0
  54. package/node_modules/@llblab/pi-telegram/lib/extension.ts +43 -11
  55. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +12 -0
  56. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +2 -0
  57. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +3 -2
  58. package/node_modules/@llblab/pi-telegram/lib/pi.ts +2 -0
  59. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +79 -16
  60. package/node_modules/@llblab/pi-telegram/lib/routing.ts +137 -24
  61. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +14 -0
  62. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +173 -17
  63. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +14 -8
  64. package/node_modules/@llblab/pi-telegram/lib/threads.ts +166 -10
  65. package/node_modules/@llblab/pi-telegram/lib/updates.ts +33 -6
  66. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +8 -7
  67. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +56 -2
  68. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +44 -0
  69. package/node_modules/@llblab/pi-telegram/package.json +2 -1
  70. package/package.json +4 -4
@@ -2026,6 +2026,8 @@ export function createTelegramInboundRouteRuntime<
2026
2026
  ): boolean =>
2027
2027
  pending.phase.kind !== "waiting" && pending.phase.kind !== "released";
2028
2028
  const pendingUnboundReroutes = new Map<string, PendingUnboundReroute>();
2029
+ // Body-free, bounded warm deduplication: album members can expire through the same chooser.
2030
+ const expiredRoutingViews = new Set<string>();
2029
2031
  const implicitThreadCreations = new Map<
2030
2032
  string,
2031
2033
  {
@@ -2542,6 +2544,20 @@ export function createTelegramInboundRouteRuntime<
2542
2544
  temporaryDeleteConfirmed = true;
2543
2545
  return result;
2544
2546
  } catch (error) {
2547
+ const missingTarget = TelegramApi.getTelegramApiErrorRequestTarget(error);
2548
+ if (method === "deleteForumTopic" && missingTarget && error instanceof Error &&
2549
+ TelegramApi.isTelegramApiRequestRejected(error, method) &&
2550
+ TelegramApi.isTelegramStaleTargetHttpError(error) &&
2551
+ ThreadReconciler.isTelegramTopicDeletedErrorMessage(error.message) &&
2552
+ missingTarget?.chatId === temporaryCleanup.target?.chatId &&
2553
+ missingTarget?.threadId === temporaryCleanup.target?.threadId &&
2554
+ missingTarget?.chatId === body.chat_id && missingTarget?.threadId === body.message_thread_id) {
2555
+ // Only the client's parsed exact-target HTTP 400 absence closes the one-shot grant.
2556
+ // Closed/denied/foreign/forged responses remain uncertain and never license another request.
2557
+ assertExecutionCurrent?.();
2558
+ temporaryDeleteConfirmed = true;
2559
+ return true as TResponse;
2560
+ }
2545
2561
  temporaryTransportUncertain = true;
2546
2562
  throw error;
2547
2563
  }
@@ -8102,7 +8118,11 @@ export function createTelegramInboundRouteRuntime<
8102
8118
  signal: AbortSignal,
8103
8119
  ): Promise<void> => {
8104
8120
  const operator = deps.configStore.getAllowedUserId(),
8105
- epoch = deps.getCurrentLeaderEpoch?.();
8121
+ epoch = deps.getCurrentLeaderEpoch?.(),
8122
+ generation = deps.getSessionGeneration?.(),
8123
+ instanceId = deps.getCurrentInstanceId?.(),
8124
+ scope = deps.getAdmissionScope?.();
8125
+ const sendTextReply = deps.sendTextReply, editInteractiveMessage = deps.editInteractiveMessage;
8106
8126
  const message = source.original.update.message as
8107
8127
  Partial<TMessage> | undefined;
8108
8128
  const runtimeCurrent = () =>
@@ -8112,6 +8132,9 @@ export function createTelegramInboundRouteRuntime<
8112
8132
  deps.isContextActive?.(ctx) === true &&
8113
8133
  deps.configStore.getAllowedUserId() === operator &&
8114
8134
  deps.getCurrentLeaderEpoch?.() === epoch &&
8135
+ deps.getSessionGeneration?.() === generation &&
8136
+ deps.getCurrentInstanceId?.() === instanceId &&
8137
+ deps.getAdmissionScope?.() === scope &&
8115
8138
  deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
8116
8139
  source.original.routingInput?.operatorUserId === operator &&
8117
8140
  message?.from?.id === operator &&
@@ -8142,6 +8165,13 @@ export function createTelegramInboundRouteRuntime<
8142
8165
  if (!result) return;
8143
8166
  // Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
8144
8167
  let movedIntoTab = false;
8168
+ let unissued = source.original.routingInput?.phase === "waiting" &&
8169
+ (!temporary || temporary.forwardProtocol === "one-shot-v1") &&
8170
+ ![...(temporary?.forwardedInputs ?? []), ...(temporary?.completedInputs ?? [])].some(input =>
8171
+ input.journalBindingKey === source.journalBindingKey && input.updateIds.includes(source.original.updateId));
8172
+ const views = new Map<string, { chatId: number; threadId?: number; messageId: number }>();
8173
+ const chooser = source.original.routingInput?.chooser;
8174
+ if (chooser) views.set(`${chooser.chatId}:${chooser.messageId}`, { ...chooser });
8145
8175
  for (const [id, pending] of pendingUnboundReroutes) {
8146
8176
  if (
8147
8177
  !Updates.collectTelegramAdmissionSourceUpdateIds(
@@ -8150,32 +8180,55 @@ export function createTelegramInboundRouteRuntime<
8150
8180
  )
8151
8181
  continue;
8152
8182
  movedIntoTab ||= pending.allTabCopiesForwarded !== undefined;
8183
+ unissued &&= isPendingRerouteUntouched(pending) || pending.phase.kind === "released";
8184
+ if (pending.chooserMessageId !== undefined)
8185
+ views.set(`${pending.sourceTarget.chatId}:${pending.chooserMessageId}`, {
8186
+ ...pending.sourceTarget, messageId: pending.chooserMessageId,
8187
+ });
8153
8188
  removePendingReroute(id);
8154
- if (
8155
- signal.aborted ||
8156
- deps.isContextActive?.(ctx) !== true ||
8157
- deps.configStore.getAllowedUserId() !== operator ||
8158
- deps.getCurrentLeaderEpoch?.() !== epoch ||
8159
- deps.getAdmissionJournalBinding?.() !== source.journalBindingKey
8160
- )
8161
- continue;
8162
- if (
8163
- deps.editInteractiveMessage &&
8164
- pending.chooserMessageId !== undefined
8165
- ) {
8189
+ }
8190
+ const notice = unissued
8191
+ ? "<b>⌛ Routing expired; input was not delivered.</b>"
8192
+ : "<b>⌛ Routing expired; delivery is unconfirmed.</b>\nPreviously accepted work may continue.";
8193
+ const noticeKey = `${source.journalBindingKey}:${source.original.routingInput?.expiresAtMs}:${chooser ? `${chooser.chatId}:${chooser.messageId}` : source.original.updateId}`;
8194
+ if (runtimeCurrent() && !expiredRoutingViews.has(noticeKey)) {
8195
+ if (expiredRoutingViews.size >= 100) expiredRoutingViews.delete(expiredRoutingViews.values().next().value!);
8196
+ expiredRoutingViews.add(noticeKey);
8197
+ let visible = false;
8198
+ const assertNoticeAuthority = () => {
8199
+ if (!runtimeCurrent()) throw new Error("Routing expiry notice authority changed.");
8200
+ };
8201
+ for (const view of views.values()) {
8202
+ if (!runtimeCurrent() || !editInteractiveMessage || deps.editInteractiveMessage !== editInteractiveMessage) break;
8203
+ if (view.chatId !== message!.chat!.id || !Number.isSafeInteger(view.threadId) || view.threadId! <= 0) continue;
8166
8204
  try {
8167
- await deps.editInteractiveMessage(
8168
- pending.sourceTarget.chatId,
8169
- pending.chooserMessageId,
8170
- "<b>⌛ Routing choice expired.</b>",
8171
- "html",
8172
- { inline_keyboard: [] },
8173
- );
8174
- } catch (error) {
8175
- deps.recordRuntimeEvent?.("routing", error, {
8176
- phase: "routing-input-expiry-view",
8205
+ await editInteractiveMessage(view.chatId, view.messageId, notice, "html", { inline_keyboard: [] }, {
8206
+ target: { chatId: view.chatId, threadId: view.threadId }, assertAuthority: assertNoticeAuthority,
8177
8207
  });
8208
+ visible = true;
8209
+ } catch (error) {
8210
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-view" });
8211
+ }
8212
+ }
8213
+ // A temporary tab is about to disappear; a cold/missing chooser also needs an independent owned destination.
8214
+ const leader = instanceId ? deps.threadStore?.getActiveByInstanceId(instanceId) : undefined;
8215
+ if ((!visible || temporary?.target) && runtimeCurrent() && leader &&
8216
+ leader.target.chatId === message!.chat!.id && Number.isSafeInteger(leader.target.threadId) && leader.target.threadId! > 0) {
8217
+ const target = { chatId: leader.target.chatId, threadId: leader.target.threadId };
8218
+ try {
8219
+ const assertFallback = () => {
8220
+ assertNoticeAuthority();
8221
+ const active = deps.threadStore?.getActiveByInstanceId(instanceId!);
8222
+ if (deps.sendTextReply !== sendTextReply || active?.target.chatId !== target.chatId || active.target.threadId !== target.threadId)
8223
+ throw new Error("Routing expiry notice destination changed.");
8224
+ };
8225
+ assertFallback();
8226
+ await sendTextReply(target.chatId, 0, notice, { parseMode: "HTML", target, assertAuthority: assertFallback });
8227
+ } catch (error) {
8228
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-notice" });
8178
8229
  }
8230
+ } else if (!visible && runtimeCurrent()) {
8231
+ deps.recordRuntimeEvent?.("routing", "Routing expiry notice has no current owned Thread.", { phase: "routing-input-expiry-notice-unavailable" });
8179
8232
  }
8180
8233
  }
8181
8234
  // The scheduled stage captures only context/epoch and a tab token. It reconstructs disposition from body-free proof,
@@ -8434,7 +8487,12 @@ export function createTelegramInboundRouteRuntime<
8434
8487
  )
8435
8488
  continue;
8436
8489
  try {
8437
- await retireCancellationChooser(id, pending, isCurrent);
8490
+ if (await retireCancellationChooser(id, pending, isCurrent))
8491
+ await deleteCancelledOrphanTab(
8492
+ pending,
8493
+ pending.abandonment!,
8494
+ isCurrent,
8495
+ );
8438
8496
  } catch (error) {
8439
8497
  deps.recordRuntimeEvent?.("routing", error, {
8440
8498
  phase: "recovered-chooser-cleanup",
@@ -8463,6 +8521,60 @@ export function createTelegramInboundRouteRuntime<
8463
8521
  }
8464
8522
  return true;
8465
8523
  };
8524
+ /**
8525
+ * A cancelled input in an unbound (non-temporary) tab leaves nothing to route: once no chooser, queue, live owner,
8526
+ * Workspace/Restore record or retained journal arrival still names the tab, it is deleted like a routing tab. Any
8527
+ * missing proof or failure keeps the tab; this never retries and never blocks the Cancel outcome.
8528
+ */
8529
+ const deleteCancelledOrphanTab = async (
8530
+ pending: PendingUnboundReroute,
8531
+ cancellation: NonNullable<PendingUnboundReroute["abandonment"]>,
8532
+ isAuthorityCurrent: () => boolean,
8533
+ ): Promise<void> => {
8534
+ const { chatId, threadId } = pending.sourceTarget;
8535
+ if (
8536
+ threadId === undefined ||
8537
+ pending.temporaryThread ||
8538
+ isTemporaryTabTarget({ chatId, threadId }) ||
8539
+ hasPendingRerouteForTarget({ chatId, threadId }) ||
8540
+ !deps.inspectTemporaryThreadSources
8541
+ )
8542
+ return;
8543
+ const target = { chatId, threadId };
8544
+ try {
8545
+ const own = {
8546
+ journalBindingKey: cancellation.journalBindingKey,
8547
+ updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(
8548
+ pending.messages,
8549
+ ),
8550
+ };
8551
+ const assertCurrent = (): void => {
8552
+ if (!isAuthorityCurrent())
8553
+ throw new Error("Unbound tab cleanup authority changed.");
8554
+ };
8555
+ assertCurrent();
8556
+ if (
8557
+ deps.inspectTemporaryThreadSources(
8558
+ target,
8559
+ [cancellation.journalBindingKey],
8560
+ [own],
8561
+ )?.length !== 0
8562
+ )
8563
+ return;
8564
+ assertCurrent();
8565
+ await closeReroutedUnboundTopic(
8566
+ target,
8567
+ pending.chooserMessageId,
8568
+ assertCurrent,
8569
+ );
8570
+ } catch (error) {
8571
+ deps.recordRuntimeEvent?.("routing", error, {
8572
+ phase: "unbound-cancel-tab-cleanup",
8573
+ chatId: target.chatId,
8574
+ threadId: target.threadId,
8575
+ });
8576
+ }
8577
+ };
8466
8578
  const handleUnboundRerouteCancelCallback = async (
8467
8579
  query: TCallbackQuery,
8468
8580
  ctx: TContext,
@@ -8580,6 +8692,7 @@ export function createTelegramInboundRouteRuntime<
8580
8692
  await Promise.all([
8581
8693
  deps.answerCallbackQuery(query.id, "Routing cancelled"),
8582
8694
  deleteAllTabCopies(pending!),
8695
+ deleteCancelledOrphanTab(pending!, cancellation!, isAuthorityCurrent),
8583
8696
  ]);
8584
8697
  } catch (error) {
8585
8698
  deps.recordRuntimeEvent?.("routing", error, {
@@ -1049,6 +1049,20 @@ export class TelegramApiStaleTargetError extends Error {
1049
1049
  }
1050
1050
  }
1051
1051
 
1052
+ /** The leader's exact refusal of a follower API call outside that follower's own Thread; carries only the requested target. */
1053
+ export class TelegramApiFollowerNotAllowedError extends Error {
1054
+ readonly requestTarget: { chatId: number; threadId: number } | undefined;
1055
+
1056
+ constructor(
1057
+ message: string,
1058
+ requestTarget?: { chatId: number; threadId: number },
1059
+ ) {
1060
+ super(message);
1061
+ this.name = "TelegramApiFollowerNotAllowedError";
1062
+ this.requestTarget = requestTarget ? { ...requestTarget } : undefined;
1063
+ }
1064
+ }
1065
+
1052
1066
  export function getTelegramApiErrorRequestTarget(
1053
1067
  error: unknown,
1054
1068
  ): { chatId: number; threadId: number } | undefined {
@@ -22,7 +22,14 @@ import type {
22
22
  TelegramWorkspaceDeletionPermit,
23
23
  TelegramWorkspaceDestructiveFence,
24
24
  TelegramWorkspaceRetirementFence,
25
+ TelegramWorkspaceAdmissionLedger,
26
+ TelegramWorkspaceAdmissionOwner,
25
27
  } from "./workspace-admission.ts";
28
+ import { isTelegramWorkspaceRetirementFence } from "./workspace-admission.ts";
29
+ import type { TelegramTopicTargetStore, TelegramWorkspaceThreadBinding } from "./threads.ts";
30
+ import type { TelegramWorkspaceOperationRunner } from "./workspace-retirement.ts";
31
+ import type { TelegramWorkspaceThreadDeletionTransport } from "./telegram-api.ts";
32
+ import type { TelegramProcessLiveness } from "./process-identity.ts";
26
33
 
27
34
  export interface TelegramThreadCleanupBindingSnapshot {
28
35
  cwd: string;
@@ -56,7 +63,8 @@ export interface TelegramThreadCleanupCandidate {
56
63
  sessionId?: string;
57
64
  sessionKey?: string;
58
65
  instanceSlot: string;
59
- slot: string;
66
+ /** Null is explicit absence in reviewed work; it never denotes a reusable letter. */
67
+ slot: string | null;
60
68
  target: { chatId: number; threadId: number };
61
69
  inactiveSinceMs: number;
62
70
  bindingUpdatedAtMs: number;
@@ -116,8 +124,7 @@ export function planTelegramInactiveThreadCleanup(input: {
116
124
  !binding.cwd ||
117
125
  !binding.workspaceKey ||
118
126
  !binding.instanceSlot ||
119
- typeof slot !== "string" ||
120
- !slot ||
127
+ (slot !== undefined && (typeof slot !== "string" || !/^[A-Z]$/u.test(slot))) ||
121
128
  !validSessionIdentity(binding) ||
122
129
  !validTarget(binding.target) ||
123
130
  bindingKeys.has(binding.bindingKey) ||
@@ -152,8 +159,6 @@ export function planTelegramInactiveThreadCleanup(input: {
152
159
  const inactiveSinceMs = binding.inactiveSinceMs;
153
160
  const slot = binding.slot;
154
161
  if (
155
- typeof slot !== "string" ||
156
- !slot ||
157
162
  !Number.isSafeInteger(inactiveSinceMs) ||
158
163
  inactiveSinceMs === undefined ||
159
164
  inactiveSinceMs < 0 ||
@@ -180,7 +185,7 @@ export function planTelegramInactiveThreadCleanup(input: {
180
185
  ? { sessionId: binding.sessionId, sessionKey: binding.sessionKey }
181
186
  : {}),
182
187
  instanceSlot: binding.instanceSlot,
183
- slot,
188
+ slot: slot ?? null,
184
189
  target: { ...binding.target },
185
190
  inactiveSinceMs,
186
191
  bindingUpdatedAtMs: binding.updatedAtMs,
@@ -409,8 +414,7 @@ function validateWorkSet(
409
414
  !validSessionIdentity(raw) ||
410
415
  typeof raw.instanceSlot !== "string" ||
411
416
  !raw.instanceSlot ||
412
- typeof raw.slot !== "string" ||
413
- !raw.slot ||
417
+ (raw.slot !== null && (typeof raw.slot !== "string" || !/^[A-Z]$/u.test(raw.slot))) ||
414
418
  !isObject(raw.target) ||
415
419
  !onlyKeys(raw.target, ["chatId", "threadId"]) ||
416
420
  !validTarget(
@@ -509,7 +513,11 @@ function sameCandidates(
509
513
  left: readonly TelegramThreadCleanupCandidate[],
510
514
  right: readonly TelegramThreadCleanupCandidate[],
511
515
  ): boolean {
512
- return JSON.stringify(left) === JSON.stringify(right);
516
+ const keys = ["profileName", "bindingKey", "cwd", "workspaceKey", "sessionId", "sessionKey", "instanceSlot", "slot", "inactiveSinceMs", "bindingUpdatedAtMs"] as const;
517
+ return left.length === right.length && left.every((candidate, index) => {
518
+ const other = right[index]!;
519
+ return keys.every(key => candidate[key] === other[key]) && targetKey(candidate.target) === targetKey(other.target);
520
+ });
513
521
  }
514
522
 
515
523
  function parseTelegramThreadCleanupWorkFile(
@@ -544,6 +552,8 @@ export function createTelegramThreadCleanupWorkStore(options: {
544
552
  runtimeDir?: string;
545
553
  profileName: string;
546
554
  tokenSha256: string;
555
+ /** Exact captured ledger identity; standalone fixtures may use the plain profile identity. */
556
+ admissionProfileKey?: string;
547
557
  maxWorkSets?: number;
548
558
  maxBytes?: number;
549
559
  getNowMs?: () => number;
@@ -680,12 +690,12 @@ export function createTelegramThreadCleanupWorkStore(options: {
680
690
  !entry ||
681
691
  entry.bindingUpdatedAtMs !== input.bindingUpdatedAtMs ||
682
692
  permit.destructiveKind !== "manual-thread-cleanup" ||
683
- permit.profileKey !== entry.profileName ||
693
+ permit.profileKey !== (options.admissionProfileKey ?? entry.profileName) ||
684
694
  permit.bindingKey !== entry.bindingKey ||
685
695
  permit.slot !== entry.slot ||
686
696
  targetKey(permit.target) !== targetKey(entry.target) ||
687
697
  !permit.operationId ||
688
- !permit.retirementIntentId ||
698
+ permit.retirementIntentId !== workSet.operationId ||
689
699
  !safeTime(permit.issuedAtMs)
690
700
  )
691
701
  throw new Error(
@@ -842,12 +852,13 @@ export async function executeTelegramInactiveThreadCleanup(input: {
842
852
 
843
853
  export function createTelegramThreadCleanupPermitRuntime(deps: {
844
854
  ledger: {
855
+ getProfileKey(): string;
845
856
  read(): { fence?: TelegramWorkspaceDestructiveFence };
846
857
  acquireThreadCleanupFence(input: {
847
858
  operationId: string;
848
859
  cleanupWorkSetId: string;
849
860
  bindingKey: string;
850
- slot: string;
861
+ slot: string | null;
851
862
  target: { chatId: number; threadId: number };
852
863
  leaderEpoch: number | string;
853
864
  cleanupRequestedAtMs: number;
@@ -930,7 +941,7 @@ export function createTelegramThreadCleanupPermitRuntime(deps: {
930
941
  const fence = deps.ledger.read().fence;
931
942
  return fence?.operationId === operationId &&
932
943
  fence.destructiveKind === "manual-thread-cleanup" &&
933
- fence.profileKey === candidate.profileName &&
944
+ fence.profileKey === deps.ledger.getProfileKey() &&
934
945
  fence.retirementIntentId === workSetId &&
935
946
  fence.bindingKey === candidate.bindingKey &&
936
947
  fence.slot === candidate.slot &&
@@ -1045,8 +1056,17 @@ export function createTelegramThreadCleanupPermitRuntime(deps: {
1045
1056
  : { kind: "already-issued" };
1046
1057
  },
1047
1058
  async settleDeleted(fence, commit) {
1059
+ const assertAuthority = () => {
1060
+ const owner = deps.getOwner();
1061
+ if (deps.getLeaderEpoch() !== fence.leaderEpoch ||
1062
+ owner.processId !== fence.owner.processId || owner.processBirthId !== fence.owner.processBirthId)
1063
+ throw new Error("Telegram Thread cleanup settlement authority changed.");
1064
+ };
1065
+ assertAuthority();
1048
1066
  const ready = deps.ledger.confirmThreadCleanupAbsence(fence);
1049
- if (!(await commit())) return "commit-pending";
1067
+ const committed = await commit();
1068
+ assertAuthority();
1069
+ if (!committed) return "commit-pending";
1050
1070
  deps.ledger.completeThreadCleanupFence(ready);
1051
1071
  return "completed";
1052
1072
  },
@@ -1069,6 +1089,138 @@ export interface TelegramInactiveThreadCleanupCoordinatorDeps<TBinding> {
1069
1089
  ): Promise<boolean>;
1070
1090
  }
1071
1091
 
1092
+ export interface TelegramInactiveThreadCleanupAuthority {
1093
+ profileName: string;
1094
+ leaderEpoch: number | string;
1095
+ ledger: TelegramWorkspaceAdmissionLedger;
1096
+ isCurrent(): boolean;
1097
+ }
1098
+
1099
+ /** Review owns ordinary admission; confirmed cleanup uses the shared gate and its exclusive manual fence instead. */
1100
+ export function createTelegramInactiveThreadCleanupRuntime(deps: {
1101
+ store: Pick<TelegramTopicTargetStore, "load" | "listWorkspaceBindings" | "listReservations" | "listPendingProvisions" | "listPendingCleanups" | "commitInactiveWorkspaceCleanup">;
1102
+ captureAuthority(): TelegramInactiveThreadCleanupAuthority | undefined;
1103
+ getWorkStore(authority: TelegramInactiveThreadCleanupAuthority): TelegramThreadCleanupWorkStore;
1104
+ getProtection(binding: TelegramWorkspaceThreadBinding): {
1105
+ liveOwner: TelegramThreadCleanupProtectionState;
1106
+ acceptedWork: TelegramThreadCleanupProtectionState;
1107
+ deliveryAuthority: TelegramThreadCleanupProtectionState;
1108
+ };
1109
+ getProcessLiveness(owner: TelegramWorkspaceAdmissionOwner): TelegramProcessLiveness;
1110
+ runExclusive<T>(operation: () => Promise<T>): Promise<T>;
1111
+ runWorkspaceOperation: TelegramWorkspaceOperationRunner;
1112
+ deleteThread: TelegramWorkspaceThreadDeletionTransport;
1113
+ onError?: (error: unknown) => void;
1114
+ }): {
1115
+ review(): Promise<{ count: number; operationId?: string }>;
1116
+ clean(operationId: string): Promise<TelegramInactiveThreadCleanupResult>;
1117
+ } {
1118
+ const capture = () => {
1119
+ const authority = deps.captureAuthority();
1120
+ if (!authority || !authority.isCurrent())
1121
+ throw new Error("Inactive Thread cleanup requires current leader authority.");
1122
+ const ledgerKey = authority.ledger.getProfileKey();
1123
+ const owner = authority.ledger.getOwner();
1124
+ const assertCurrent = () => {
1125
+ if (!authority.isCurrent() || authority.ledger.getProfileKey() !== ledgerKey)
1126
+ throw new Error("Inactive Thread cleanup authority changed.");
1127
+ };
1128
+ const evidence = () => {
1129
+ assertCurrent();
1130
+ return captureTelegramInactiveThreadCleanupEvidence({
1131
+ profileName: authority.profileName,
1132
+ listBindings: deps.store.listWorkspaceBindings,
1133
+ getProtection: deps.getProtection,
1134
+ listReservations: deps.store.listReservations,
1135
+ listPendingProvisions: deps.store.listPendingProvisions,
1136
+ listPendingCleanups: deps.store.listPendingCleanups,
1137
+ });
1138
+ };
1139
+ return { authority, owner, assertCurrent, evidence };
1140
+ };
1141
+ return {
1142
+ async review() {
1143
+ const scope = capture();
1144
+ return createTelegramInactiveThreadCleanupReviewRuntime({
1145
+ getProfileName: () => scope.authority.profileName,
1146
+ listBindings: deps.store.listWorkspaceBindings,
1147
+ getProtection: deps.getProtection,
1148
+ listReservations: deps.store.listReservations,
1149
+ listPendingProvisions: deps.store.listPendingProvisions,
1150
+ listPendingCleanups: deps.store.listPendingCleanups,
1151
+ getWorkStore() { scope.assertCurrent(); return deps.getWorkStore(scope.authority); },
1152
+ runWorkspaceOperation(metadata, operation) {
1153
+ return deps.runWorkspaceOperation(metadata, async () => {
1154
+ scope.assertCurrent();
1155
+ await deps.store.load();
1156
+ scope.assertCurrent();
1157
+ return operation();
1158
+ });
1159
+ },
1160
+ }).review();
1161
+ },
1162
+ async clean(operationId) {
1163
+ const scope = capture();
1164
+ return deps.runExclusive(async () => {
1165
+ scope.assertCurrent();
1166
+ const { authority } = scope;
1167
+ const work = deps.getWorkStore(authority);
1168
+ const exactBinding = (candidate: TelegramThreadCleanupCandidate) => {
1169
+ const fresh = planTelegramInactiveThreadCleanup(scope.evidence()).find(binding => binding.bindingKey === candidate.bindingKey);
1170
+ if (!fresh || !sameCandidates([fresh], [candidate])) return undefined;
1171
+ return deps.store.listWorkspaceBindings().find(binding => binding.bindingKey === fresh.bindingKey);
1172
+ };
1173
+ const loadExactBinding = async (candidate: TelegramThreadCleanupCandidate) => {
1174
+ scope.assertCurrent();
1175
+ await deps.store.load();
1176
+ scope.assertCurrent();
1177
+ return exactBinding(candidate);
1178
+ };
1179
+ const permitRuntime = createTelegramThreadCleanupPermitRuntime({
1180
+ ledger: authority.ledger,
1181
+ getLeaderEpoch: () => authority.isCurrent() ? authority.leaderEpoch : undefined,
1182
+ getProfileName: () => authority.profileName,
1183
+ getOwner: () => scope.owner,
1184
+ canAdoptFence: fence => authority.isCurrent() && deps.getProcessLiveness(fence.owner) === "dead",
1185
+ revalidateUnderFence: async candidate => !!(await loadExactBinding(candidate)),
1186
+ });
1187
+ return cleanReviewedInactiveThreads({
1188
+ store: work, operationId, permitRuntime,
1189
+ resolveFullBinding: loadExactBinding,
1190
+ async deleteWithPermit(permit, candidate) {
1191
+ try {
1192
+ await deps.deleteThread(() => {
1193
+ scope.assertCurrent();
1194
+ const fence = authority.ledger.read().fence;
1195
+ if (!fence || !isTelegramWorkspaceRetirementFence(fence) ||
1196
+ fence.destructiveKind !== "manual-thread-cleanup" || fence.phase !== "deletion-issued" ||
1197
+ fence.operationId !== permit.operationId || fence.retirementIntentId !== operationId ||
1198
+ fence.profileKey !== permit.profileKey || fence.bindingKey !== candidate.bindingKey ||
1199
+ fence.slot !== candidate.slot || fence.deletionIssuedAtMs !== permit.issuedAtMs ||
1200
+ fence.leaderEpoch !== authority.leaderEpoch || fence.owner.processId !== scope.owner.processId ||
1201
+ fence.owner.processBirthId !== scope.owner.processBirthId ||
1202
+ targetKey(fence.target) !== targetKey(permit.target) || !exactBinding(candidate))
1203
+ throw new Error("Inactive Thread cleanup deletion permit is stale.");
1204
+ return { ...permit.target };
1205
+ });
1206
+ scope.assertCurrent();
1207
+ } catch (error) {
1208
+ deps.onError?.(error);
1209
+ throw error;
1210
+ }
1211
+ },
1212
+ async commitBinding(candidate) {
1213
+ scope.assertCurrent();
1214
+ await deps.store.load();
1215
+ scope.assertCurrent();
1216
+ return deps.store.commitInactiveWorkspaceCleanup(candidate, authority.isCurrent);
1217
+ },
1218
+ });
1219
+ });
1220
+ },
1221
+ };
1222
+ }
1223
+
1072
1224
  export function createTelegramInactiveThreadCleanupSettingsPort<TBinding>(
1073
1225
  deps: TelegramInactiveThreadCleanupCoordinatorDeps<TBinding>,
1074
1226
  ): (operationId: string) => Promise<TelegramInactiveThreadCleanupResult> {
@@ -1105,9 +1257,13 @@ export async function cleanReviewedInactiveThreads<TBinding>(
1105
1257
  entry,
1106
1258
  input.operationId,
1107
1259
  );
1108
- if (ready)
1109
- await input.permitRuntime.settleDeleted(ready, async () => true);
1110
- deleted += 1;
1260
+ if (ready) {
1261
+ const settlement = await input.permitRuntime.settleDeleted(ready, async () => true);
1262
+ if (settlement === "completed") deleted += 1;
1263
+ else outcomeUnknown += 1;
1264
+ } else if (input.permitRuntime.diagnoseRecovery(entry, input.operationId) !== "none") {
1265
+ outcomeUnknown += 1;
1266
+ } else deleted += 1;
1111
1267
  continue;
1112
1268
  }
1113
1269
  if (entry.state === "outcome-unknown") {
@@ -174,8 +174,12 @@ export function resolveTelegramWorkspaceDisplayNames(
174
174
  : undefined;
175
175
  if (manualName) {
176
176
  labels.set(binding.bindingKey, boundedLabel(manualName));
177
+ } else if (!slot) {
178
+ // A displaced binding retains its generated identity, not the letter now owned elsewhere.
179
+ const name = binding.threadName ? labelText(binding.threadName) : undefined;
180
+ if (name) labels.set(binding.bindingKey, boundedLabel(name));
177
181
  } else if (mode === "letters") {
178
- if (slot) labels.set(binding.bindingKey, slot);
182
+ labels.set(binding.bindingKey, slot);
179
183
  } else if (mode === "names") {
180
184
  const name = binding.threadName ? labelText(binding.threadName) : slot;
181
185
  if (name) labels.set(binding.bindingKey, boundedLabel(name));
@@ -191,12 +195,11 @@ export function resolveTelegramWorkspaceDisplayNames(
191
195
  bindings.filter((candidate) => candidate.cwd === binding.cwd)
192
196
  .length > 1
193
197
  : (liveDirectoryCounts.get(binding.cwd) ?? 0) > 1;
194
- if (showSuffix && !slot) continue;
195
198
  const suffix = !showSuffix
196
199
  ? ""
197
200
  : mode === "directory-title"
198
201
  ? ` ${slot}`
199
- : `_${slot!.toLowerCase()}`;
202
+ : `_${slot.toLowerCase()}`;
200
203
  labels.set(binding.bindingKey, boundedLabel(base, suffix));
201
204
  }
202
205
  }
@@ -378,9 +381,9 @@ export function createTelegramThreadDisplaySettingsRuntime(deps: {
378
381
  /** Caller owns triggering and reporting; no timer or background retry is created. */
379
382
  export function createTelegramThreadDisplayReconciler(
380
383
  deps: TelegramThreadDisplayReconcilerDeps,
381
- ): { reconcile(): Promise<{ changed: number }> } {
384
+ ): { reconcile(bindingKey?: string): Promise<{ changed: number }> } {
382
385
  let tail: Promise<unknown> = Promise.resolve();
383
- const apply = async (): Promise<{ changed: number }> => {
386
+ const apply = async (bindingKey?: string): Promise<{ changed: number }> => {
384
387
  const epoch = deps.getLeaderEpoch();
385
388
  const profile = deps.getProfileKey();
386
389
  const mode = deps.getMode();
@@ -406,6 +409,7 @@ export function createTelegramThreadDisplayReconciler(
406
409
  );
407
410
  let changed = 0;
408
411
  for (const binding of bindings) {
412
+ if (bindingKey !== undefined && binding.bindingKey !== bindingKey) continue;
409
413
  const isBindingCurrent = deps.captureBindingAuthority(binding);
410
414
  if (!isBindingCurrent) continue;
411
415
  const title = titles.get(binding.bindingKey);
@@ -426,7 +430,7 @@ export function createTelegramThreadDisplayReconciler(
426
430
  };
427
431
  assertBinding(binding);
428
432
  if ((binding.displayTitle ?? binding.threadName) === title) continue;
429
- await deps.callApi(
433
+ const acknowledged = await deps.callApi<boolean>(
430
434
  "editForumTopic",
431
435
  {
432
436
  chat_id: binding.target.chatId,
@@ -436,6 +440,8 @@ export function createTelegramThreadDisplayReconciler(
436
440
  { maxAttempts: 1 },
437
441
  );
438
442
  assertBinding(binding);
443
+ if (acknowledged !== true)
444
+ throw new Error("Telegram Thread display update was not acknowledged.");
439
445
  if (!deps.store.setWorkspaceDisplayTitle(binding, title)) {
440
446
  throw new Error(
441
447
  "Telegram Thread display binding changed before title commit.",
@@ -452,8 +458,8 @@ export function createTelegramThreadDisplayReconciler(
452
458
  return { changed };
453
459
  };
454
460
  return {
455
- reconcile() {
456
- const run = tail.then(apply);
461
+ reconcile(bindingKey) {
462
+ const run = tail.then(() => apply(bindingKey));
457
463
  tail = run.catch(() => undefined);
458
464
  return run;
459
465
  },