@llblab/pi-kit 0.35.0 → 0.35.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 (68) hide show
  1. package/BACKLOG.md +2 -0
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +1 -1
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +3 -2
  5. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -6
  6. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +18 -0
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +3 -2
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +11 -3
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +2 -2
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +22 -0
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +75 -16
  12. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +1 -1
  13. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +11 -1
  14. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +30 -19
  15. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +2 -0
  16. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +21 -8
  17. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +61 -46
  18. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +7 -0
  19. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +80 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +36 -0
  21. package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +80 -0
  22. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +12 -0
  23. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +33 -3
  24. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +2 -1
  25. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +18 -3
  26. package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +13 -0
  27. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +33 -12
  28. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +6 -2
  29. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +46 -4
  30. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +5 -1
  31. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +2 -2
  32. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +2 -0
  33. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +137 -13
  34. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +11 -0
  35. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.js +31 -8
  36. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +12 -5
  37. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +21 -3
  38. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +5 -1
  39. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +5 -1
  40. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +26 -13
  41. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +6 -2
  42. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +57 -12
  43. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  44. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -1
  45. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +6 -2
  46. package/node_modules/@llblab/pi-telegram/docs/outbound.md +4 -2
  47. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +13 -2
  48. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +2 -1
  49. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +111 -15
  50. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -0
  51. package/node_modules/@llblab/pi-telegram/lib/bus.ts +30 -21
  52. package/node_modules/@llblab/pi-telegram/lib/commands.ts +21 -8
  53. package/node_modules/@llblab/pi-telegram/lib/extension.ts +64 -56
  54. package/node_modules/@llblab/pi-telegram/lib/journal.ts +80 -0
  55. package/node_modules/@llblab/pi-telegram/lib/logging.ts +122 -0
  56. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +36 -3
  57. package/node_modules/@llblab/pi-telegram/lib/paths.ts +22 -3
  58. package/node_modules/@llblab/pi-telegram/lib/polling.ts +44 -10
  59. package/node_modules/@llblab/pi-telegram/lib/queue.ts +43 -4
  60. package/node_modules/@llblab/pi-telegram/lib/replies.ts +3 -2
  61. package/node_modules/@llblab/pi-telegram/lib/routing.ts +140 -18
  62. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +40 -7
  63. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +30 -10
  64. package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +6 -1
  65. package/node_modules/@llblab/pi-telegram/lib/threads.ts +37 -20
  66. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +69 -17
  67. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  68. package/package.json +2 -2
@@ -1568,6 +1568,13 @@ export interface TelegramInboundRouteRuntimeDeps<
1568
1568
  requiredJournalBindingKeys: readonly string[],
1569
1569
  ownInputs?: readonly Threads.TelegramTemporaryThreadInput[],
1570
1570
  ) => readonly number[] | undefined;
1571
+ /** Explicit cleanup-only recovery; never called by a read-only protection observer. */
1572
+ recoverTemporaryThreadDeadQueueOwners?: (
1573
+ target: Queue.TelegramQueueTarget,
1574
+ requiredJournalBindingKeys: readonly string[],
1575
+ ownInputs: readonly Threads.TelegramTemporaryThreadInput[],
1576
+ isCurrent: () => boolean,
1577
+ ) => number;
1571
1578
  /** Quiet period after the last cancelled input before one cleanup attempt; defaults to 0 ms. */
1572
1579
  temporaryThreadCleanupDelayMs?: number;
1573
1580
  /** Live-rebind cleanup attempt pacing; the bounded window ends in `not-issued`, never a time-based deletion. */
@@ -6837,6 +6844,93 @@ export function createTelegramInboundRouteRuntime<
6837
6844
  }
6838
6845
  await promptEnqueue(messages, ctx);
6839
6846
  };
6847
+ /** Local menu acceptance owns a one-shot effect; source removal and cleanup precede its release, never its replay. */
6848
+ const stageTemporaryRerouteMenu = async (
6849
+ pending: PendingUnboundReroute,
6850
+ command: Commands.ParsedTelegramCommand,
6851
+ messages: TMessage[],
6852
+ ctx: TContext,
6853
+ assertRecipientCurrent: () => void,
6854
+ completion: NonNullable<ReturnType<typeof Updates.prepareTelegramDeferredSourceCompletion>>,
6855
+ ): Promise<(() => Promise<void>) | undefined> => {
6856
+ const original = pending.messages[0];
6857
+ const cap = captureTemporaryThreadAuthority(ctx);
6858
+ const temporary = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
6859
+ if (!cap?.isCurrent() || !temporary) return undefined;
6860
+ let accept!: (value: boolean) => void, resume!: (value: boolean) => void;
6861
+ const accepted = new Promise<boolean>(resolve => { accept = resolve; });
6862
+ const released = new Promise<boolean>(resolve => { resume = resolve; });
6863
+ const admission: Commands.TelegramSelectedCommandAdmission = {
6864
+ assertSourceCurrent() {
6865
+ if (!completion.isCurrent()) throw new Error("Rerouted menu source authority changed.");
6866
+ },
6867
+ assertRecipientCurrent,
6868
+ reportCompleted() {
6869
+ const result = completion.reportCompleted();
6870
+ accept(result);
6871
+ return result;
6872
+ },
6873
+ async beforeEffects() {
6874
+ if (!(await released)) throw new Error("Rerouted menu execution was not released.");
6875
+ },
6876
+ };
6877
+ const prepare = command.name === "start" ? commandHandler.prepareSelectedStartCommand
6878
+ : command.name === "help" ? commandHandler.prepareSelectedHelpCommand : commandHandler.prepareSelectedMenuCommand;
6879
+ const dispatch = prepare(command, messages, ctx, admission);
6880
+ if (!dispatch) return undefined;
6881
+ const execution = dispatch().catch(error => {
6882
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-execution" });
6883
+ return false;
6884
+ });
6885
+ if (!(await Promise.race([accepted, execution.then(() => false)]))) {
6886
+ resume(false);
6887
+ await execution;
6888
+ return undefined;
6889
+ }
6890
+ return async () => {
6891
+ let allowEffects = false;
6892
+ try {
6893
+ const confirmed = await new Promise<boolean>(resolve => {
6894
+ let done = false, unsubscribe: (() => void) | undefined;
6895
+ const signal = Updates.getTelegramUpdateExecutionFence(original)?.signal;
6896
+ const finish = (result: boolean) => {
6897
+ if (done) return;
6898
+ done = true;
6899
+ clearTimeout(timer);
6900
+ unsubscribe?.();
6901
+ signal?.removeEventListener("abort", abort);
6902
+ resolve(result);
6903
+ };
6904
+ const abort = () => finish(false);
6905
+ const observe = () => {
6906
+ if (!cap.isCurrent() || !completion.isCurrent()) finish(false);
6907
+ else if (completion.inspectCompletion()) finish(true);
6908
+ };
6909
+ const timer = setTimeout(abort, deps.liveRebindCommandCompletionTimeoutMs ?? 8_000);
6910
+ timer.unref?.();
6911
+ unsubscribe = Updates.subscribeTelegramDeferredSourceCompletion(original, observe);
6912
+ signal?.addEventListener("abort", abort, { once: true });
6913
+ if (signal?.aborted) abort(); else observe();
6914
+ });
6915
+ if (!confirmed) throw new Error("Rerouted menu source removal is unconfirmed; execution remains suppressed.");
6916
+ assertRecipientCurrent();
6917
+ cancelTemporaryThreadCleanup(temporary.token);
6918
+ try {
6919
+ await runTemporaryThreadCleanup(temporary.token, cap, false);
6920
+ } catch (error) {
6921
+ // Cleanup uncertainty retains its canonical grant, not command execution authority or a retry license.
6922
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-cleanup" });
6923
+ }
6924
+ assertRecipientCurrent();
6925
+ allowEffects = true;
6926
+ } catch (error) {
6927
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-release" });
6928
+ } finally {
6929
+ resume(allowEffects);
6930
+ await execution;
6931
+ }
6932
+ };
6933
+ };
6840
6934
  const finalizePendingReroute = async (
6841
6935
  rerouteId: string,
6842
6936
  pending: PendingUnboundReroute,
@@ -7258,12 +7352,20 @@ export function createTelegramInboundRouteRuntime<
7258
7352
  });
7259
7353
  if (!cancelledCurrent()) return;
7260
7354
  entry = cap.adopt(entry);
7261
- if (
7262
- !entry ||
7263
- !cap.isCurrent() ||
7264
- isRerouteTargetProtected(target, undefined, entry)
7265
- )
7266
- return;
7355
+ if (!entry || !cap.isCurrent()) return;
7356
+ if (isRerouteTargetProtected(target, undefined, entry) && deps.recoverTemporaryThreadDeadQueueOwners &&
7357
+ store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary") {
7358
+ const inputs = Threads.getTelegramTemporaryThreadInputs(entry);
7359
+ const removed = deps.recoverTemporaryThreadDeadQueueOwners(
7360
+ target, [cap.journalBindingKey, ...inputs.map(input => input.journalBindingKey)],
7361
+ inputs, cap.isCurrent,
7362
+ );
7363
+ if (removed > 0) deps.recordRuntimeEvent?.("routing",
7364
+ "Discarded historical queue receipts from confirmed-dead owners before temporary Thread cleanup.",
7365
+ { phase: "temporary-thread-dead-owner-recovery", removedUpdateCount: removed });
7366
+ }
7367
+ // Recovery is not deletion authority: recapture every ordinary protection after the journal CAS.
7368
+ if (!cap.isCurrent() || isRerouteTargetProtected(target, undefined, entry)) return;
7267
7369
  const issued = store.issueTemporaryThreadCleanup(entry, cap.authority);
7268
7370
  if (!issued || !cap.isCurrent()) return;
7269
7371
  entry = issued.entry;
@@ -8658,6 +8760,7 @@ export function createTelegramInboundRouteRuntime<
8658
8760
  const executeUnboundRerouteCallbackOperation = async (
8659
8761
  query: TCallbackQuery,
8660
8762
  ctx: TContext,
8763
+ afterAdmission: (release: () => Promise<void>) => void,
8661
8764
  ): Promise<boolean> => {
8662
8765
  const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
8663
8766
  if (!parsed) return false;
@@ -9349,7 +9452,20 @@ export function createTelegramInboundRouteRuntime<
9349
9452
  const localMessages = pending.rerouteCopies
9350
9453
  ? cloneTelegramMessagesForThread(pending.messages, parsed.threadId, pending.rerouteCopies.bySourceUpdateId)
9351
9454
  : reroutedMessages;
9352
- await dispatchPendingRerouteMessages(pending, localMessages, ctx);
9455
+ const command = pending.dispatchKind === "command"
9456
+ ? getLiveRebindMenu(localMessages) ?? getLiveRebindHelp(localMessages) : undefined;
9457
+ const completion = pending.rerouteCopies && command
9458
+ ? Updates.prepareTelegramDeferredSourceCompletion(pending.messages[0]) : undefined;
9459
+ if (command && completion) {
9460
+ const release = await stageTemporaryRerouteMenu(pending, command, localMessages, ctx, assertRerouteCurrent, completion);
9461
+ if (!release) {
9462
+ await deps.answerCallbackQuery(query.id, "Command acceptance unconfirmed; not resending");
9463
+ return true;
9464
+ }
9465
+ afterAdmission(release);
9466
+ } else {
9467
+ await dispatchPendingRerouteMessages(pending, localMessages, ctx);
9468
+ }
9353
9469
  assertRerouteCurrent();
9354
9470
  pending.messages = [];
9355
9471
  return completeRoutedReroute();
@@ -9360,17 +9476,23 @@ export function createTelegramInboundRouteRuntime<
9360
9476
  ): Promise<boolean> => {
9361
9477
  const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
9362
9478
  if (!parsed) return false;
9363
- return withPendingRerouteSelection(parsed.rerouteId, query, () => {
9364
- if (!deps.runWorkspaceOperation)
9365
- return executeUnboundRerouteCallbackOperation(query, ctx);
9366
- return deps.runWorkspaceOperation(
9367
- {
9368
- operationId: `workspace-reroute:${query.id}`,
9369
- operationKind: "workspace.route-unbound-thread",
9370
- scopes: [{ kind: "profile" }],
9371
- },
9372
- () => executeUnboundRerouteCallbackOperation(query, ctx),
9373
- );
9479
+ return withPendingRerouteSelection(parsed.rerouteId, query, async () => {
9480
+ let release: (() => Promise<void>) | undefined;
9481
+ const operation = () => executeUnboundRerouteCallbackOperation(query, ctx, value => { release = value; });
9482
+ try {
9483
+ if (!deps.runWorkspaceOperation) return await operation();
9484
+ return await deps.runWorkspaceOperation(
9485
+ {
9486
+ operationId: `workspace-reroute:${query.id}`,
9487
+ operationKind: "workspace.route-unbound-thread",
9488
+ scopes: [{ kind: "profile" }],
9489
+ },
9490
+ operation,
9491
+ );
9492
+ } finally {
9493
+ // Keep the exact source references, but release the non-reentrant Workspace gate before cleanup.
9494
+ await release?.();
9495
+ }
9374
9496
  });
9375
9497
  };
9376
9498
  const handleUnboundRerouteCallback = async (
@@ -6,6 +6,16 @@
6
6
 
7
7
  import * as Machine from "./machine.ts";
8
8
 
9
+ /**
10
+ * Structural port for run-collapsing diagnostics: a repeated identical failure extends its run instead of logging
11
+ * again, and the closing success records one outcome. The composition root owns the collapsed implementation.
12
+ */
13
+ export interface TelegramRepeatedFailureLogPort {
14
+ record: (error: unknown, details?: Record<string, unknown>) => void;
15
+ settled: () => void;
16
+ reset: () => void;
17
+ }
18
+
9
19
  const TELEGRAM_TYPING_ACTION_INTERVAL_MS = 3_000;
10
20
  const TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS = 250;
11
21
 
@@ -344,6 +354,8 @@ function updateTelegramRuntimeStatusSafely<TContext>(
344
354
  export interface TelegramTypingLoopStarterDeps<
345
355
  TContext,
346
356
  > extends TelegramRuntimeEventRecorderPort {
357
+ /** Collapses one sustained presence failure run; the caller supplies its owner. */
358
+ failureLog: TelegramRepeatedFailureLogPort;
347
359
  typing: TelegramRuntimeTypingPort;
348
360
  getDefaultChatId: () => number | undefined;
349
361
  sendTypingAction: (
@@ -374,6 +386,31 @@ export function createTelegramTypingLoopStarter<TContext>(
374
386
  threadId: number | undefined;
375
387
  canContinue: () => boolean;
376
388
  } | undefined;
389
+ // An unreachable transport repeats one identical presence failure every tick; the port collapses the run.
390
+ // Running it through one guard keeps a faulty diagnostics port from escaping the in-flight action owner.
391
+ const typingFailureLog: TelegramRepeatedFailureLogPort = {
392
+ record(error, details) {
393
+ try {
394
+ deps.failureLog.record(error, details);
395
+ } catch {
396
+ // Typing diagnostics cannot escape the in-flight action owner.
397
+ }
398
+ },
399
+ settled() {
400
+ try {
401
+ deps.failureLog.settled();
402
+ } catch {
403
+ // A successful presence outranks its own diagnostic.
404
+ }
405
+ },
406
+ reset() {
407
+ try {
408
+ deps.failureLog.reset();
409
+ } catch {
410
+ // Typing diagnostics cannot fail a loop stop.
411
+ }
412
+ },
413
+ };
377
414
  return (ctx, chatId, options) => {
378
415
  const sessionGeneration = deps.getSessionGeneration?.();
379
416
  const transportAuthority = deps.getTransportAuthority?.();
@@ -422,24 +459,20 @@ export function createTelegramTypingLoopStarter<TContext>(
422
459
  if (!active || !canContinue()) throw new Error("Typing context authority changed.");
423
460
  },
424
461
  });
462
+ typingFailureLog.settled();
425
463
  } catch (error) {
426
464
  if (!active) return;
427
465
  if (!canContinue()) {
428
466
  deps.typing.stop();
429
467
  return;
430
468
  }
431
- try {
432
- deps.recordRuntimeEvent?.("typing", error, {
433
- chatId: targetChatId,
434
- });
435
- } catch {
436
- // Typing diagnostics cannot escape the in-flight action owner.
437
- }
469
+ typingFailureLog.record(error, { chatId: targetChatId });
438
470
  }
439
471
  },
440
472
  shouldContinue: canContinue,
441
473
  onStopped: () => {
442
474
  active = false;
475
+ typingFailureLog.reset();
443
476
  if (currentOwner === owner) currentOwner = undefined;
444
477
  },
445
478
  });
@@ -401,6 +401,8 @@ export interface TelegramApiCallOptions {
401
401
  sleep?: (ms: number) => Promise<void>;
402
402
  /** Observability hook fired before a 429 retry wait, never for 5xx waits. */
403
403
  onRetryWait?: (wait: TelegramApiRetryWait) => void;
404
+ /** Caller-owned expected failure: still thrown, but the caller records the outcome instead of the API log. */
405
+ isExpectedError?: (error: unknown) => boolean;
404
406
  }
405
407
 
406
408
  export class TelegramApiAuthorityError extends Error {
@@ -473,6 +475,8 @@ export interface TelegramAnswerGuestQueryOptions {
473
475
  }
474
476
 
475
477
  export interface TelegramEditGuestInlineMessageContent {
478
+ /** Cached media only: inline edits cannot upload a new local file. */
479
+ media?: { type: "photo" | "document" | "audio"; media: string; caption?: string };
476
480
  text?: string;
477
481
  richMessage?: TelegramInputRichMessage;
478
482
  parseMode?: "HTML";
@@ -917,10 +921,9 @@ export interface TelegramBridgeApiRuntime extends Pick<
917
921
  options?: TelegramAnswerGuestQueryOptions,
918
922
  ) => Promise<void>;
919
923
  /**
920
- * Temporary Guest Mode ACK experiment: answers the guest query and returns
921
- * the sent inline message id so the final answer can edit that early reply.
922
- * Requires direct transport ownership; Telegram does not document editing
923
- * guest answers, so this exists only to falsify that behavior live.
924
+ * Answers the guest query and retains the sent inline message id for final
925
+ * text or cached-media replacement. Requires direct transport ownership;
926
+ * actual guest-media rendering remains live-client gated.
924
927
  */
925
928
  answerGuestQueryForInlineMessage: (
926
929
  guestQueryId: string,
@@ -930,6 +933,7 @@ export interface TelegramBridgeApiRuntime extends Pick<
930
933
  editGuestInlineMessage: (
931
934
  inlineMessageId: string,
932
935
  content: TelegramEditGuestInlineMessageContent,
936
+ options?: Pick<TelegramApiCallOptions, "assertAuthority">,
933
937
  ) => Promise<void>;
934
938
  deleteMessage: (chatId: number, messageId: number) => Promise<void>;
935
939
  prepareTempDir: () => Promise<number>;
@@ -2195,11 +2199,12 @@ export function createTelegramBridgeApiRuntime(
2195
2199
  ) {
2196
2200
  return true as TResponse;
2197
2201
  }
2198
- deps.recordRuntimeEvent(
2199
- "api",
2200
- error,
2201
- withTelegramTransportDiagnostics(error, { method }),
2202
- );
2202
+ if (!options?.isExpectedError?.(error))
2203
+ deps.recordRuntimeEvent(
2204
+ "api",
2205
+ error,
2206
+ withTelegramTransportDiagnostics(error, { method }),
2207
+ );
2203
2208
  throw error;
2204
2209
  }
2205
2210
  };
@@ -2404,14 +2409,29 @@ export function createTelegramBridgeApiRuntime(
2404
2409
  editGuestInlineMessage: async (
2405
2410
  inlineMessageId: string,
2406
2411
  content: TelegramEditGuestInlineMessageContent,
2412
+ options?: Pick<TelegramApiCallOptions, "assertAuthority">,
2407
2413
  ) => {
2414
+ if (content.media) {
2415
+ try {
2416
+ const result = await callRecorded("editMessageMedia", {
2417
+ inline_message_id: inlineMessageId,
2418
+ media: content.media,
2419
+ }, { ...options, maxAttempts: 1, retryRateLimit: false });
2420
+ if (result !== true) throw new Error("Missing inline edit acknowledgement.");
2421
+ } catch (error) {
2422
+ // Only a parsed rejection permits replacing this attempt with a failure notice.
2423
+ if (isTelegramApiRequestRejected(error, "editMessageMedia")) throw error;
2424
+ throw new TelegramApiCommitUnknownError("editMessageMedia", error);
2425
+ }
2426
+ return;
2427
+ }
2408
2428
  await callRecorded("editMessageText", {
2409
2429
  inline_message_id: inlineMessageId,
2410
2430
  ...(content.richMessage
2411
2431
  ? { rich_message: content.richMessage }
2412
2432
  : { text: content.text }),
2413
2433
  ...(content.parseMode ? { parse_mode: content.parseMode } : {}),
2414
- });
2434
+ }, options);
2415
2435
  },
2416
2436
  prepareTempDir: () =>
2417
2437
  prepareTelegramTempDir(deps.tempDir, deps.tempFileMaxAgeMs),
@@ -719,7 +719,12 @@ export async function applyThreadReconciliationPlan(
719
719
  }
720
720
  let deleteConfirmed = false;
721
721
  let superseded = false;
722
- for (const method of ports.skipCloseBeforeDelete
722
+ // Private-chat topics (positive chat ids) cannot be closed; Telegram only supports closeForumTopic in forums.
723
+ const skipClose =
724
+ ports.skipCloseBeforeDelete ||
725
+ (action.kind === "close-delete-graceful-shutdown-topic" &&
726
+ action.target.chatId > 0);
727
+ for (const method of skipClose
723
728
  ? ["deleteForumTopic"]
724
729
  : ["closeForumTopic", "deleteForumTopic"]) {
725
730
  if (ports.isCleanupTargetProtected?.(action.target, action)) {
@@ -252,6 +252,13 @@ export interface TelegramWorkspaceExternalProtectionEvidence {
252
252
  deliveryAuthority: TelegramWorkspaceProtectionState;
253
253
  }
254
254
 
255
+ /** Per-binding probe; `withSnapshot` lets one occupancy capture share binding-independent evidence reads. */
256
+ export type TelegramWorkspaceExternalProtectionCapture = ((
257
+ binding: TelegramWorkspaceThreadBinding,
258
+ ) => TelegramWorkspaceExternalProtectionEvidence) & {
259
+ withSnapshot?: <T>(capture: () => T) => T;
260
+ };
261
+
255
262
  export interface TelegramWorkspaceSlotOccupancySnapshot {
256
263
  bindings: TelegramWorkspaceSlotOccupancy[];
257
264
  reservedSlots: string[];
@@ -876,9 +883,7 @@ export interface TelegramTopicTargetStore {
876
883
  ) => Promise<boolean>;
877
884
  /** Caller must separately prove no external live owner, accepted work, or delivery authority. */
878
885
  captureWorkspaceSlotOccupancy: (
879
- getExternalProtection: (
880
- binding: TelegramWorkspaceThreadBinding,
881
- ) => TelegramWorkspaceExternalProtectionEvidence,
886
+ getExternalProtection: TelegramWorkspaceExternalProtectionCapture,
882
887
  options?: { expectedRetirement?: TelegramWorkspaceRetirementIntent },
883
888
  ) => TelegramWorkspaceSlotOccupancySnapshot;
884
889
  hasWorkspaceBinding: (cwd: string, sessionId?: string) => boolean;
@@ -2723,10 +2728,16 @@ function liveRebindOldTargetUnknown(value: TelegramWorkspaceLiveRebindIntent): b
2723
2728
  function retainLiveRebind(value: TelegramWorkspaceLiveRebindIntent): boolean {
2724
2729
  return liveRebindActive(value) || liveRebindOldTargetUnknown(value) || value.oldTargetProtected === true;
2725
2730
  }
2726
- function protectedLiveRebindOldTargets(snapshot: TelegramWorkspaceRestoreSnapshot | undefined): TelegramTarget[] {
2731
+ function provisioningProtectionIntents(snapshot: TelegramWorkspaceRestoreSnapshot): readonly Pick<TelegramWorkspaceRestoreIntent, "request">[] {
2732
+ return [...snapshot.operations, ...(snapshot.liveRebindings ?? []).filter(retainLiveRebind)];
2733
+ }
2734
+ function protectedLiveRebindOldTargetIntents(snapshot: TelegramWorkspaceRestoreSnapshot | undefined): TelegramWorkspaceLiveRebindIntent[] {
2727
2735
  return (snapshot?.liveRebindings ?? []).filter(value =>
2728
2736
  value.oldTargetProtected === true || liveRebindOldTargetUnknown(value) || value.inputRelease !== undefined && value.cleanup === "issued",
2729
- ).map(value => ({ ...value.request.binding.target }));
2737
+ );
2738
+ }
2739
+ function protectedLiveRebindOldTargets(snapshot: TelegramWorkspaceRestoreSnapshot | undefined): TelegramTarget[] {
2740
+ return protectedLiveRebindOldTargetIntents(snapshot).map(value => ({ ...value.request.binding.target }));
2730
2741
  }
2731
2742
  function pendingWorkspaceRelocationRequests(
2732
2743
  snapshot: TelegramWorkspaceRestoreSnapshot | undefined,
@@ -2821,10 +2832,11 @@ function assertWorkspaceRestoreBindingProtection(
2821
2832
  throw new Error("Protected Workspace Restore owner target changed.");
2822
2833
  }
2823
2834
  }
2824
- for (const old of protectedLiveRebindOldTargets(file.workspaceRestore)) {
2835
+ for (const { request } of protectedLiveRebindOldTargetIntents(file.workspaceRestore)) {
2836
+ const old = request.binding.target;
2825
2837
  if (file.workspaceBindings?.some(value => targetMatches(value.target, old)) ||
2826
2838
  file.threads?.some(value => targetMatches(value.target, old) && ["active", "starting", "pending", "probe-required"].includes(value.status)) ||
2827
- file.pendingProvisions?.some(value => !value.target || targetMatches(value.target, old)) ||
2839
+ file.pendingProvisions?.some(value => conflictsWithWorkspaceRestoreProvision(request, value)) ||
2828
2840
  file.reservations?.some(value => targetMatches(value.target, old)))
2829
2841
  throw new Error("Unknown live cleanup target remains protected.");
2830
2842
  }
@@ -3840,7 +3852,7 @@ export function createTelegramTopicTargetStore(
3840
3852
  };
3841
3853
  const assertRestoreRecoveryProtection = (
3842
3854
  path: string,
3843
- operations: readonly TelegramWorkspaceRestoreIntent[],
3855
+ operations: readonly Pick<TelegramWorkspaceRestoreIntent, "request">[],
3844
3856
  provisions: readonly TelegramThreadPendingProvision[],
3845
3857
  requireKnownTargets = false,
3846
3858
  recoveryEvidence?: TelegramProvisionRecoveryFile,
@@ -5061,7 +5073,7 @@ export function createTelegramTopicTargetStore(
5061
5073
  assertWorkspaceRestoreBindingProtection(file);
5062
5074
  assertRestoreRecoveryProtection(
5063
5075
  path,
5064
- file.workspaceRestore!.operations,
5076
+ provisioningProtectionIntents(file.workspaceRestore!),
5065
5077
  file.pendingProvisions ?? [],
5066
5078
  );
5067
5079
  observe({
@@ -5117,7 +5129,7 @@ export function createTelegramTopicTargetStore(
5117
5129
  assertWorkspaceRestoreBindingProtection(file);
5118
5130
  assertRestoreRecoveryProtection(
5119
5131
  path,
5120
- file.workspaceRestore!.operations,
5132
+ provisioningProtectionIntents(file.workspaceRestore!),
5121
5133
  file.pendingProvisions ?? [],
5122
5134
  );
5123
5135
  const canonical = file.workspaceBindings ?? [],
@@ -5175,9 +5187,9 @@ export function createTelegramTopicTargetStore(
5175
5187
  (row) => currentRow(row) && targetMatches(row.target, old),
5176
5188
  ) ||
5177
5189
  reserved.some((value) => targetMatches(value.target, old)) ||
5178
- provisions.some(
5179
- (value) => !value.target || targetMatches(value.target, old),
5180
- ) ||
5190
+ // Use the same binding/slot/owner/target conflicts as rebind admission. An unrelated unlocated
5191
+ // create must not globally veto cleanup of this exact former binding (nor its issued marker).
5192
+ provisions.some((value) => conflictsWithWorkspaceRestoreProvision(request, value)) ||
5181
5193
  cleanups.some((value) => targetMatches(value.target, old));
5182
5194
  if (
5183
5195
  protectedTarget(
@@ -5390,8 +5402,11 @@ export function createTelegramTopicTargetStore(
5390
5402
  file.reservations?.some((value) =>
5391
5403
  targetMatches(value.target, target),
5392
5404
  ) ||
5405
+ // This exact created frame already proves a separately created routing tab (API ACK or authenticated
5406
+ // implicit creation). An unlocated Workspace create is not its owner; keep that intent unresolved,
5407
+ // but veto a concrete target collision. Generic unknown-target/Restore cleanup keeps its own guards.
5393
5408
  file.pendingProvisions?.some(
5394
- (value) => !value.target || targetMatches(value.target, target),
5409
+ (value) => !!value.target && targetMatches(value.target, target),
5395
5410
  ) ||
5396
5411
  file.pendingCleanups?.some((value) =>
5397
5412
  targetMatches(value.target, target),
@@ -5557,7 +5572,7 @@ export function createTelegramTopicTargetStore(
5557
5572
  parseTopicTargetFile(before.file).pendingProvisions ?? [];
5558
5573
  assertRestoreRecoveryProtection(
5559
5574
  path,
5560
- before.state.operations,
5575
+ provisioningProtectionIntents(before.state),
5561
5576
  provisions,
5562
5577
  );
5563
5578
  const after = { ...before.file, workspaceRestore: next };
@@ -5573,7 +5588,7 @@ export function createTelegramTopicTargetStore(
5573
5588
  return false;
5574
5589
  assertRestoreRecoveryProtection(
5575
5590
  path,
5576
- [...before.state.operations, ...next.operations],
5591
+ [...provisioningProtectionIntents(before.state), ...provisioningProtectionIntents(next)],
5577
5592
  provisions,
5578
5593
  );
5579
5594
  committedBefore = before;
@@ -5598,7 +5613,7 @@ export function createTelegramTopicTargetStore(
5598
5613
  );
5599
5614
  assertRestoreRecoveryProtection(
5600
5615
  path,
5601
- [...before.state.operations, ...next.operations],
5616
+ [...provisioningProtectionIntents(before.state), ...provisioningProtectionIntents(next)],
5602
5617
  provisions,
5603
5618
  );
5604
5619
  if (!currentScope() || !isCurrent()) return;
@@ -6775,7 +6790,7 @@ export function createTelegramTopicTargetStore(
6775
6790
  conflictsWithWorkspaceRestoreProvision(request, provision),
6776
6791
  ) ||
6777
6792
  conflictsWithTemporaryThread(workspaceRestore, provision.target) ||
6778
- protectedLiveRebindOldTargets(workspaceRestore).some(old => !provision.target || targetMatches(old, provision.target))
6793
+ protectedLiveRebindOldTargetIntents(workspaceRestore).some(({ request }) => conflictsWithWorkspaceRestoreProvision(request, provision))
6779
6794
  ) {
6780
6795
  throw new Error("Protected Workspace Restore provisioning conflict.");
6781
6796
  }
@@ -7349,7 +7364,9 @@ export function createTelegramTopicTargetStore(
7349
7364
  },
7350
7365
  captureWorkspaceSlotOccupancy(getExternalProtection, options) {
7351
7366
  const nowMs = getNowMs();
7352
- const bindings = Array.from(workspaceBindings.values()).map((binding) => {
7367
+ const inSnapshot =
7368
+ getExternalProtection.withSnapshot ?? ((capture) => capture());
7369
+ const bindings = inSnapshot(() => Array.from(workspaceBindings.values()).map((binding) => {
7353
7370
  const slot = binding.slot?.toLowerCase() ?? "";
7354
7371
  const locallyProtected = isWorkspaceBindingLocallyProtected(
7355
7372
  binding,
@@ -7388,7 +7405,7 @@ export function createTelegramTopicTargetStore(
7388
7405
  : {}),
7389
7406
  protection,
7390
7407
  };
7391
- });
7408
+ }));
7392
7409
  const localReservedSlots = [
7393
7410
  ...Array.from(workspaceClaims.values()).map(
7394
7411
  (claim) => claim.identity.slot,