@llblab/pi-telegram 0.54.4 → 0.55.1

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 (80) hide show
  1. package/AGENTS.md +4 -2
  2. package/BACKLOG.md +1 -1
  3. package/CHANGELOG.md +16 -0
  4. package/README.md +5 -3
  5. package/dist/lib/activity.d.ts +5 -1
  6. package/dist/lib/activity.js +31 -2
  7. package/dist/lib/bindings.d.ts +3 -1
  8. package/dist/lib/bindings.js +8 -2
  9. package/dist/lib/bus-api.d.ts +2 -2
  10. package/dist/lib/bus-api.js +7 -2
  11. package/dist/lib/bus-follower.d.ts +50 -3
  12. package/dist/lib/bus-follower.js +313 -146
  13. package/dist/lib/bus-leader.d.ts +3 -0
  14. package/dist/lib/bus-leader.js +99 -18
  15. package/dist/lib/bus.d.ts +7 -3
  16. package/dist/lib/bus.js +24 -16
  17. package/dist/lib/delivery.d.ts +4 -0
  18. package/dist/lib/delivery.js +48 -15
  19. package/dist/lib/extension.js +48 -6
  20. package/dist/lib/lifecycle.d.ts +68 -0
  21. package/dist/lib/lifecycle.js +178 -58
  22. package/dist/lib/machine.d.ts +23 -0
  23. package/dist/lib/machine.js +37 -0
  24. package/dist/lib/media.d.ts +114 -28
  25. package/dist/lib/media.js +292 -119
  26. package/dist/lib/model.d.ts +54 -15
  27. package/dist/lib/model.js +98 -53
  28. package/dist/lib/polling.js +2 -0
  29. package/dist/lib/preview.d.ts +128 -113
  30. package/dist/lib/preview.js +448 -366
  31. package/dist/lib/queue.d.ts +2 -2
  32. package/dist/lib/queue.js +3 -3
  33. package/dist/lib/routing.d.ts +7 -2
  34. package/dist/lib/routing.js +585 -229
  35. package/dist/lib/runtime.d.ts +77 -11
  36. package/dist/lib/runtime.js +174 -92
  37. package/dist/lib/sections.js +0 -1
  38. package/dist/lib/telegram-api.d.ts +11 -9
  39. package/dist/lib/telegram-api.js +53 -4
  40. package/dist/lib/text-groups.d.ts +0 -26
  41. package/dist/lib/text-groups.js +51 -98
  42. package/dist/lib/thread-display.js +14 -6
  43. package/dist/lib/threads.d.ts +18 -5
  44. package/dist/lib/threads.js +107 -30
  45. package/dist/lib/turns.d.ts +6 -0
  46. package/dist/lib/turns.js +12 -1
  47. package/dist/lib/updates.d.ts +13 -4
  48. package/dist/lib/updates.js +54 -19
  49. package/dist/package.json +1 -1
  50. package/docs/activity.md +1 -1
  51. package/docs/architecture.md +14 -11
  52. package/docs/delivery.md +7 -5
  53. package/docs/multi-instance-bus.md +20 -8
  54. package/docs/public-api.md +2 -2
  55. package/docs/ui-style.md +2 -2
  56. package/lib/activity.ts +29 -5
  57. package/lib/bindings.ts +9 -1
  58. package/lib/bus-api.ts +11 -3
  59. package/lib/bus-follower.ts +417 -203
  60. package/lib/bus-leader.ts +88 -14
  61. package/lib/bus.ts +30 -18
  62. package/lib/delivery.ts +47 -19
  63. package/lib/extension.ts +51 -9
  64. package/lib/lifecycle.ts +229 -55
  65. package/lib/machine.ts +60 -0
  66. package/lib/media.ts +438 -172
  67. package/lib/model.ts +169 -74
  68. package/lib/polling.ts +2 -0
  69. package/lib/preview.ts +715 -566
  70. package/lib/queue.ts +5 -5
  71. package/lib/routing.ts +614 -257
  72. package/lib/runtime.ts +260 -104
  73. package/lib/sections.ts +0 -2
  74. package/lib/telegram-api.ts +61 -8
  75. package/lib/text-groups.ts +75 -136
  76. package/lib/thread-display.ts +15 -4
  77. package/lib/threads.ts +120 -36
  78. package/lib/turns.ts +15 -1
  79. package/lib/updates.ts +60 -21
  80. package/package.json +1 -1
package/lib/threads.ts CHANGED
@@ -349,6 +349,10 @@ export type TelegramWorkspaceLiveRebindIntent = Pick<
349
349
  | "updatedAtMs"
350
350
  > & {
351
351
  kind: "live-rebind";
352
+ /** Versioned native input gate: terminal cleanup does not itself authorize or settle input. */
353
+ inputRelease?: "held" | "issued" | "accepted";
354
+ /** A session-replacing producer must not drop earlier old-target protection or carry its delete grant forward. */
355
+ oldTargetProtected?: true;
352
356
  recipient: TelegramWorkspaceRestoreRecipient;
353
357
  } & (
354
358
  | { phase: "rebound"; cleanup?: never }
@@ -418,6 +422,7 @@ export interface TelegramWorkspaceRestore {
418
422
  request: TelegramWorkspaceRestoreRequest,
419
423
  recipient: TelegramWorkspaceRestoreRecipient,
420
424
  authority: TelegramWorkspaceRestoreAuthority,
425
+ options?: { holdInput: true },
421
426
  ): Promise<TelegramWorkspaceLiveRebindIntent | undefined>;
422
427
  /** Caller proves local apply for release or current live clearance for cleanup; unknown issuance never repeats. */
423
428
  advanceLiveRebind(
@@ -425,6 +430,9 @@ export interface TelegramWorkspaceRestore {
425
430
  step:
426
431
  | "release"
427
432
  | "issue-cleanup"
433
+ | "issue-input"
434
+ | "accept-input"
435
+ | "protect-old-target"
428
436
  | Extract<
429
437
  TelegramWorkspaceLiveRebindIntent,
430
438
  { phase: "finished" }
@@ -501,11 +509,15 @@ export interface TelegramWorkspaceRestore {
501
509
  expected: TelegramTemporaryThreadEntry,
502
510
  authority: TelegramWorkspaceRestoreAuthority,
503
511
  ): boolean;
504
- /** Publishes `creating` before the caller's single creation request; an existing source entry is returned, never recreated. */
512
+ /**
513
+ * Publishes `creating` before the caller's single creation request; an existing source entry is returned, never
514
+ * recreated. `group` records an album as the tab's one creating input; it must start at the source update.
515
+ */
505
516
  reserveTemporaryThread(
506
517
  source: TelegramTemporaryThreadEntry["source"],
507
518
  token: string,
508
519
  authority: TelegramWorkspaceRestoreAuthority,
520
+ group?: TelegramTemporaryThreadInput,
509
521
  ): { reserved: boolean; entry: TelegramTemporaryThreadEntry } | undefined;
510
522
  /** Caller proves a fresh owner-authenticated implicit Telegram creation and exact live source under profile admission.
511
523
  * Registers that observed unbound target without issuing or fabricating a Bot API creation. */
@@ -745,6 +757,8 @@ export interface TelegramTopicTargetStore {
745
757
  ) => undefined,
746
758
  ) => void;
747
759
  /** Current live-origin ownership veto; effect callers still require Workspace admission. False is not work/recipient/deletion authority. */
760
+ hasPreDispatchRebindEvidence: () => boolean;
761
+ listProtectedLiveRebindTargets: () => TelegramTarget[];
748
762
  isWorkspaceLiveRebindCleanupTargetProtected: (
749
763
  expected: TelegramWorkspaceLiveRebindIntent,
750
764
  ) => boolean;
@@ -948,6 +962,7 @@ export function createTelegramCleanupTargetProtection(
948
962
  | "listPendingProvisions"
949
963
  | "listPendingCleanups"
950
964
  | "listTemporaryThreadTargets"
965
+ | "listProtectedLiveRebindTargets"
951
966
  >
952
967
  >,
953
968
  departingRecord?: TelegramTopicTargetRecord,
@@ -966,6 +981,9 @@ export function createTelegramCleanupTargetProtection(
966
981
  JSON.parse(JSON.stringify(right)),
967
982
  );
968
983
  return (target, action) => {
984
+ try {
985
+ if (store.listProtectedLiveRebindTargets?.().some(candidate => targetMatches(candidate, target))) return true;
986
+ } catch { return true; }
969
987
  // A source-bound temporary tab is removed only by its own Forward/Cancel, never by generic reconciliation.
970
988
  if (store.listTemporaryThreadTargets) {
971
989
  try {
@@ -2366,6 +2384,8 @@ function isWorkspaceLiveRebindIntent(
2366
2384
  "recipient",
2367
2385
  "phase",
2368
2386
  "cleanup",
2387
+ "inputRelease",
2388
+ "oldTargetProtected",
2369
2389
  ]) ||
2370
2390
  value.kind !== "live-rebind" ||
2371
2391
  !isTelegramWorkspaceRestoreRequest(value.request) ||
@@ -2383,6 +2403,11 @@ function isWorkspaceLiveRebindIntent(
2383
2403
  (value.request.owner.owner?.kind === "leader" ? "leader" : "follower")
2384
2404
  )
2385
2405
  return false;
2406
+ if (value.inputRelease !== undefined &&
2407
+ (!["held", "issued", "accepted"].includes(value.inputRelease as string) ||
2408
+ (value.inputRelease !== "held" && !["released", "finished"].includes(value.phase as string)))) return false;
2409
+ if (value.oldTargetProtected !== undefined &&
2410
+ (value.oldTargetProtected !== true || value.inputRelease === undefined || value.phase !== "finished" || value.cleanup !== "not-issued")) return false;
2386
2411
  if (value.phase === "rebound") return value.cleanup === undefined;
2387
2412
  if (value.phase === "released")
2388
2413
  return value.cleanup === undefined || value.cleanup === "issued";
@@ -2675,13 +2700,37 @@ function conflictsWithWorkspaceRestoreProvision(
2675
2700
  );
2676
2701
  }
2677
2702
 
2703
+ /** Canonical issuance is distinct from cleanup completion, source disposition and peer dispatch readiness. */
2704
+ export function isTelegramLiveRebindInputAuthorized(value: TelegramWorkspaceLiveRebindIntent): boolean {
2705
+ return value.inputRelease === undefined
2706
+ ? value.phase === "released" && value.cleanup === undefined
2707
+ : ["issued", "accepted"].includes(value.inputRelease) &&
2708
+ ["released", "finished"].includes(value.phase) && value.cleanup !== "issued";
2709
+ }
2710
+ function liveRebindInputPending(value: TelegramWorkspaceLiveRebindIntent): boolean {
2711
+ return value.inputRelease !== undefined && value.inputRelease !== "accepted";
2712
+ }
2713
+ function liveRebindActive(value: TelegramWorkspaceLiveRebindIntent): boolean {
2714
+ return value.phase !== "finished" || liveRebindInputPending(value);
2715
+ }
2716
+ function liveRebindOldTargetUnknown(value: TelegramWorkspaceLiveRebindIntent): boolean {
2717
+ return value.phase === "finished" && value.cleanup === "unknown" && value.inputRelease !== undefined;
2718
+ }
2719
+ function retainLiveRebind(value: TelegramWorkspaceLiveRebindIntent): boolean {
2720
+ return liveRebindActive(value) || liveRebindOldTargetUnknown(value) || value.oldTargetProtected === true;
2721
+ }
2722
+ function protectedLiveRebindOldTargets(snapshot: TelegramWorkspaceRestoreSnapshot | undefined): TelegramTarget[] {
2723
+ return (snapshot?.liveRebindings ?? []).filter(value =>
2724
+ value.oldTargetProtected === true || liveRebindOldTargetUnknown(value) || value.inputRelease !== undefined && value.cleanup === "issued",
2725
+ ).map(value => ({ ...value.request.binding.target }));
2726
+ }
2678
2727
  function pendingWorkspaceRelocationRequests(
2679
2728
  snapshot: TelegramWorkspaceRestoreSnapshot | undefined,
2680
2729
  ): TelegramWorkspaceRestoreRequest[] {
2681
2730
  return [
2682
2731
  ...(snapshot?.operations ?? []).map((value) => value.request),
2683
2732
  ...(snapshot?.liveRebindings ?? [])
2684
- .filter((value) => value.phase !== "finished")
2733
+ .filter(liveRebindActive)
2685
2734
  .map((value) => value.request),
2686
2735
  ];
2687
2736
  }
@@ -2749,7 +2798,7 @@ function assertWorkspaceRestoreBindingProtection(
2749
2798
  );
2750
2799
  const live = file.workspaceRestore?.liveRebindings?.some(
2751
2800
  (value) =>
2752
- value.phase !== "finished" &&
2801
+ liveRebindActive(value) &&
2753
2802
  value.request.operationId === request.operationId,
2754
2803
  );
2755
2804
  if (
@@ -2768,6 +2817,13 @@ function assertWorkspaceRestoreBindingProtection(
2768
2817
  throw new Error("Protected Workspace Restore owner target changed.");
2769
2818
  }
2770
2819
  }
2820
+ for (const old of protectedLiveRebindOldTargets(file.workspaceRestore)) {
2821
+ if (file.workspaceBindings?.some(value => targetMatches(value.target, old)) ||
2822
+ file.threads?.some(value => targetMatches(value.target, old) && ["active", "starting", "pending", "probe-required"].includes(value.status)) ||
2823
+ file.pendingProvisions?.some(value => !value.target || targetMatches(value.target, old)) ||
2824
+ file.reservations?.some(value => targetMatches(value.target, old)))
2825
+ throw new Error("Unknown live cleanup target remains protected.");
2826
+ }
2771
2827
  const operations = [
2772
2828
  ...(file.workspaceRestore?.operations ?? []),
2773
2829
  ...(file.workspaceRestore?.liveRebindings ?? []),
@@ -2875,14 +2931,14 @@ function parseWorkspaceRestore(
2875
2931
  .some(
2876
2932
  (other) =>
2877
2933
  operation.request.operationId === other.request.operationId ||
2878
- (operation.phase !== "finished" &&
2879
- other.phase !== "finished" &&
2934
+ (liveRebindActive(operation) &&
2935
+ liveRebindActive(other) &&
2880
2936
  workspaceRestoresConflict(operation.request, other.request)),
2881
2937
  ) ||
2882
2938
  snapshot.operations.some(
2883
2939
  (other) =>
2884
2940
  operation.request.operationId === other.request.operationId ||
2885
- (operation.phase !== "finished" &&
2941
+ (liveRebindActive(operation) &&
2886
2942
  workspaceRestoresConflict(operation.request, other.request)),
2887
2943
  ),
2888
2944
  )
@@ -2915,7 +2971,7 @@ function parseWorkspaceRestore(
2915
2971
  !!entry.target &&
2916
2972
  [
2917
2973
  ...snapshot.operations,
2918
- ...liveOperations.filter((value) => value.phase !== "finished"),
2974
+ ...liveOperations.filter(liveRebindActive),
2919
2975
  ].some(
2920
2976
  ({ request }) =>
2921
2977
  targetMatches(request.binding.target, entry.target!) ||
@@ -4424,9 +4480,7 @@ export function createTelegramTopicTargetStore(
4424
4480
  ) ||
4425
4481
  !isDeepStrictEqual(
4426
4482
  transition.restore.liveRebindings?.slice(0, -1),
4427
- (workspaceRestore?.liveRebindings ?? []).filter(
4428
- (value) => value.phase !== "finished",
4429
- ),
4483
+ (workspaceRestore?.liveRebindings ?? []).filter(retainLiveRebind),
4430
4484
  ))
4431
4485
  )
4432
4486
  return false;
@@ -5006,6 +5060,14 @@ export function createTelegramTopicTargetStore(
5006
5060
  )
5007
5061
  throw new Error("Workspace live-rebind observation changed.");
5008
5062
  },
5063
+ hasPreDispatchRebindEvidence() {
5064
+ const file = parseTopicTargetFile(readStoreSnapshot(getPath(), false) ?? { version: 1, records: [] });
5065
+ return file.workspaceRestore?.liveRebindings?.some(value => value.inputRelease !== undefined) === true;
5066
+ },
5067
+ listProtectedLiveRebindTargets() {
5068
+ const file = parseTopicTargetFile(readStoreSnapshot(getPath(), false) ?? { version: 1, records: [] });
5069
+ return protectedLiveRebindOldTargets(file.workspaceRestore);
5070
+ },
5009
5071
  isWorkspaceLiveRebindCleanupTargetProtected(input) {
5010
5072
  try {
5011
5073
  const expected = structuredClone(input),
@@ -5130,8 +5192,8 @@ export function createTelegramTopicTargetStore(
5130
5192
  file.workspaceRestore!.liveRebindings?.some(
5131
5193
  (other) =>
5132
5194
  other.request.operationId !== request.operationId &&
5133
- other.phase !== "finished" &&
5134
- touchesOld(other),
5195
+ (liveRebindActive(other) && touchesOld(other) ||
5196
+ (liveRebindOldTargetUnknown(other) || other.oldTargetProtected) && targetMatches(other.request.binding.target, old)),
5135
5197
  )
5136
5198
  )
5137
5199
  return true;
@@ -5357,6 +5419,8 @@ export function createTelegramTopicTargetStore(
5357
5419
  )
5358
5420
  return false;
5359
5421
  const state = read().state;
5422
+ if (protectedLiveRebindOldTargets(state).some(old => targetMatches(old, intent.request.binding.target) || targetMatches(old, intent.request.target)))
5423
+ return false;
5360
5424
  if (
5361
5425
  pendingWorkspaceRelocationRequests(state).some((request) =>
5362
5426
  workspaceRestoresConflict(request, intent.request),
@@ -5369,9 +5433,7 @@ export function createTelegramTopicTargetStore(
5369
5433
  return false;
5370
5434
  if (state.revision === Number.MAX_SAFE_INTEGER)
5371
5435
  throw new Error("Workspace Restore revision exhausted.");
5372
- const pendingLive = (state.liveRebindings ?? []).filter(
5373
- (value) => value.phase !== "finished",
5374
- );
5436
+ const pendingLive = (state.liveRebindings ?? []).filter(retainLiveRebind);
5375
5437
  if ((live ? pendingLive.length : state.operations.length) >= 26)
5376
5438
  throw new Error("Workspace Restore operation capacity reached.");
5377
5439
  const restore: TelegramWorkspaceRestoreSnapshot = {
@@ -5694,7 +5756,7 @@ export function createTelegramTopicTargetStore(
5694
5756
  list: () => structuredClone(storage.read().operations),
5695
5757
  listLiveRebindings: () =>
5696
5758
  structuredClone(storage.read().liveRebindings ?? []),
5697
- async commitLiveRebind(requestInput, recipientInput, authority) {
5759
+ async commitLiveRebind(requestInput, recipientInput, authority, options) {
5698
5760
  const request = structuredClone(requestInput),
5699
5761
  recipient = structuredClone(recipientInput);
5700
5762
  const executor = structuredClone(authority.executor),
@@ -5715,6 +5777,7 @@ export function createTelegramTopicTargetStore(
5715
5777
  createdAtMs: at,
5716
5778
  updatedAtMs: at,
5717
5779
  phase: "rebound",
5780
+ ...(options?.holdInput ? { inputRelease: "held" as const } : {}),
5718
5781
  };
5719
5782
  if (!current() || !isWorkspaceLiveRebindIntent(intent))
5720
5783
  return undefined;
@@ -5729,7 +5792,8 @@ export function createTelegramTopicTargetStore(
5729
5792
  isDeepStrictEqual(value.request, request) &&
5730
5793
  isDeepStrictEqual(value.recipient, recipient) &&
5731
5794
  isDeepStrictEqual(value.executor, executor) &&
5732
- value.operatorUserId === operatorUserId
5795
+ value.operatorUserId === operatorUserId &&
5796
+ (value.inputRelease !== undefined) === (options?.holdInput === true)
5733
5797
  ? structuredClone(value)
5734
5798
  : undefined;
5735
5799
  };
@@ -5760,17 +5824,23 @@ export function createTelegramTopicTargetStore(
5760
5824
  !entry ||
5761
5825
  !isDeepStrictEqual(entry, expected) ||
5762
5826
  entry.operatorUserId !== operator ||
5763
- entry.phase === "finished"
5827
+ (entry.phase === "finished" && step !== "issue-input" && step !== "accept-input")
5764
5828
  )
5765
5829
  return undefined;
5766
- const issuing = step === "release" || step === "issue-cleanup";
5830
+ const issuing = step === "release" || step === "issue-cleanup" || step === "issue-input" || step === "accept-input" || step === "protect-old-target";
5767
5831
  if (
5768
5832
  (issuing || step === "confirmed") &&
5769
5833
  !isDeepStrictEqual(entry.executor, executor)
5770
5834
  )
5771
5835
  return undefined;
5772
5836
  let next: TelegramWorkspaceLiveRebindIntent;
5773
- if (step === "release") {
5837
+ if (step === "issue-input" || step === "accept-input") {
5838
+ if (!["released", "finished"].includes(entry.phase) || entry.cleanup === "issued" || entry.inputRelease !== (step === "issue-input" ? "held" : "issued")) return undefined;
5839
+ next = { ...entry, inputRelease: step === "issue-input" ? "issued" : "accepted" };
5840
+ } else if (step === "protect-old-target") {
5841
+ if (entry.phase !== "released" || entry.cleanup !== undefined || entry.inputRelease !== "held") return undefined;
5842
+ next = { ...entry, phase: "finished", cleanup: "not-issued", oldTargetProtected: true };
5843
+ } else if (step === "release") {
5774
5844
  if (entry.phase !== "rebound") return undefined;
5775
5845
  next = { ...entry, phase: "released" };
5776
5846
  } else if (step === "issue-cleanup") {
@@ -6086,8 +6156,20 @@ export function createTelegramTopicTargetStore(
6086
6156
  },
6087
6157
  listTemporaryThreads: () =>
6088
6158
  structuredClone(storage.read().temporaryThreads ?? []),
6089
- reserveTemporaryThread(sourceInput, token, authority) {
6159
+ reserveTemporaryThread(sourceInput, token, authority, groupInput) {
6090
6160
  const source = structuredClone(sourceInput);
6161
+ const group: TelegramTemporaryThreadInput = groupInput
6162
+ ? structuredClone(groupInput)
6163
+ : {
6164
+ journalBindingKey: source.journalBindingKey,
6165
+ updateIds: [source.updateId],
6166
+ };
6167
+ if (
6168
+ !isTemporaryThreadInput(group) ||
6169
+ group.journalBindingKey !== source.journalBindingKey ||
6170
+ Math.min(...group.updateIds) !== source.updateId
6171
+ )
6172
+ return undefined;
6091
6173
  let reserved = false;
6092
6174
  const entry = mutate<TelegramTemporaryThreadEntry>(
6093
6175
  authority,
@@ -6108,12 +6190,7 @@ export function createTelegramTopicTargetStore(
6108
6190
  throw new Error("Invalid Workspace Restore clock.");
6109
6191
  const next: TelegramTemporaryThreadEntry = {
6110
6192
  source,
6111
- inputs: [
6112
- {
6113
- journalBindingKey: source.journalBindingKey,
6114
- updateIds: [source.updateId],
6115
- },
6116
- ],
6193
+ inputs: [group],
6117
6194
  operatorUserId: operator,
6118
6195
  executor,
6119
6196
  token,
@@ -6131,7 +6208,9 @@ export function createTelegramTopicTargetStore(
6131
6208
  getTelegramTemporaryThreadInputs(value).some(
6132
6209
  (input) =>
6133
6210
  input.journalBindingKey === source.journalBindingKey &&
6134
- input.updateIds.includes(source.updateId),
6211
+ input.updateIds.some((id) =>
6212
+ group.updateIds.includes(id),
6213
+ ),
6135
6214
  ),
6136
6215
  )
6137
6216
  )
@@ -6659,7 +6738,8 @@ export function createTelegramTopicTargetStore(
6659
6738
  pendingWorkspaceRelocationRequests(workspaceRestore).some((request) =>
6660
6739
  conflictsWithWorkspaceRestoreProvision(request, reservation),
6661
6740
  ) ||
6662
- conflictsWithTemporaryThread(workspaceRestore, reservation.target)
6741
+ conflictsWithTemporaryThread(workspaceRestore, reservation.target) ||
6742
+ protectedLiveRebindOldTargets(workspaceRestore).some(old => targetMatches(old, reservation.target))
6663
6743
  ) {
6664
6744
  throw new Error("Protected Workspace Restore provisioning conflict.");
6665
6745
  }
@@ -6677,7 +6757,8 @@ export function createTelegramTopicTargetStore(
6677
6757
  pendingWorkspaceRelocationRequests(workspaceRestore).some((request) =>
6678
6758
  conflictsWithWorkspaceRestoreProvision(request, provision),
6679
6759
  ) ||
6680
- conflictsWithTemporaryThread(workspaceRestore, provision.target)
6760
+ conflictsWithTemporaryThread(workspaceRestore, provision.target) ||
6761
+ protectedLiveRebindOldTargets(workspaceRestore).some(old => !provision.target || targetMatches(old, provision.target))
6681
6762
  ) {
6682
6763
  throw new Error("Protected Workspace Restore provisioning conflict.");
6683
6764
  }
@@ -7692,7 +7773,7 @@ export function createTelegramTopicTargetStore(
7692
7773
  if (workspaceRetirementCommitInFlight) return undefined;
7693
7774
  const next = normalizeWorkspaceBindingRecord(binding);
7694
7775
  if (next && hasWorkspaceRetirementConflict(next)) return undefined;
7695
- if (!next) return undefined;
7776
+ if (!next || protectedLiveRebindOldTargets(workspaceRestore).some(old => targetMatches(old, next.target))) return undefined;
7696
7777
  const nextMapKey = getWorkspaceBindingMapKey(next);
7697
7778
  let claim = workspaceClaims.get(nextMapKey);
7698
7779
  const replacedTargetBinding = Array.from(workspaceBindings.values()).find(
@@ -7762,18 +7843,21 @@ export function createTelegramTopicTargetStore(
7762
7843
  if (previous?.manualThreadName && !next.manualThreadName) {
7763
7844
  next.manualThreadName = previous.manualThreadName;
7764
7845
  }
7765
- if (previous) {
7846
+ if (previous || replacedTargetBinding) {
7766
7847
  const journalBindingKeys = Array.from(
7767
7848
  new Set([
7768
- ...(previous.journalBindingKeys ?? []),
7849
+ ...(previous?.journalBindingKeys ?? []),
7850
+ ...(replacedTargetBinding?.journalBindingKeys ?? []),
7769
7851
  ...(next.journalBindingKeys ?? []),
7770
7852
  ]),
7771
7853
  );
7772
- if (journalBindingKeys.length || previous.journalBindingsComplete)
7854
+ const retainedComplete =
7855
+ (!previous || previous.journalBindingsComplete === true) &&
7856
+ (!replacedTargetBinding || replacedTargetBinding.journalBindingsComplete === true);
7857
+ if (journalBindingKeys.length || retainedComplete)
7773
7858
  next.journalBindingKeys = journalBindingKeys;
7774
7859
  else delete next.journalBindingKeys;
7775
- if (previous.journalBindingsComplete)
7776
- next.journalBindingsComplete = true;
7860
+ if (retainedComplete) next.journalBindingsComplete = true;
7777
7861
  else delete next.journalBindingsComplete;
7778
7862
  }
7779
7863
  if (previous && !targetMatches(previous.target, next.target)) {
package/lib/turns.ts CHANGED
@@ -44,6 +44,20 @@ import {
44
44
 
45
45
  export const TELEGRAM_PREFIX = "[telegram]";
46
46
 
47
+ const REPLY_ANCHOR_SUPPRESSED = Symbol.for("@llblab/pi-telegram:reply-anchor-suppressed");
48
+
49
+ /** A routed source keeps its unique message/file/group identity, but cannot quote an anchor in its former tab. */
50
+ export function withoutTelegramTurnReplyAnchor<TMessage extends object>(message: TMessage): TMessage {
51
+ return { ...message, [REPLY_ANCHOR_SUPPRESSED]: true };
52
+ }
53
+
54
+ /** A confirmed same-target copy may replace the former-tab suppression on a warm input projection. */
55
+ export function withTelegramTurnReplyAnchor<TMessage extends { message_id: number }>(message: TMessage): TMessage {
56
+ if (!Number.isSafeInteger(message.message_id) || message.message_id <= 0)
57
+ throw new Error("Telegram reply anchor requires a positive message ID.");
58
+ return { ...message, [REPLY_ANCHOR_SUPPRESSED]: false };
59
+ }
60
+
47
61
  export const TELEGRAM_GUEST_TURN_NOTE =
48
62
  "[guest] delivery: answer quickly with one concise, self-contained reply";
49
63
 
@@ -733,7 +747,7 @@ function buildPreparedTelegramPromptTurn(
733
747
  kind: "prompt",
734
748
  chatId: firstMessage.chat.id,
735
749
  target: getTelegramTurnTarget(firstMessage),
736
- replyToMessageId: firstMessage.message_id,
750
+ replyToMessageId: Reflect.get(firstMessage, REPLY_ANCHOR_SUPPRESSED) === true ? 0 : firstMessage.message_id,
737
751
  sourceMessageIds: collectTelegramMessageIds(options.messages),
738
752
  queueOrder: options.queueOrder,
739
753
  queueLane: "default",
package/lib/updates.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { isDeepStrictEqual } from "node:util";
9
+ import { withoutTelegramTurnReplyAnchor } from "./turns.ts";
9
10
 
10
11
  import type {
11
12
  TelegramBusEnvelope,
@@ -373,6 +374,8 @@ export interface TelegramForeignOwnedUpdateForwarder<
373
374
  message: TMessage;
374
375
  ownership: TelegramTargetOwnershipView;
375
376
  ctx: TContext;
377
+ /** Local issuance/ACK fence; implementations must not serialize it into payloads. */
378
+ assertAuthority?: () => void;
376
379
  }) =>
377
380
  | Promise<TelegramBusForeignUpdateSettlement>
378
381
  | TelegramBusForeignUpdateSettlement;
@@ -552,7 +555,10 @@ interface TelegramUpdateAdmissionBinding {
552
555
  prepareLiveInput?: (
553
556
  sourceUpdateIds: readonly number[],
554
557
  isCurrent: () => boolean,
558
+ onTransferred?: () => void,
555
559
  ) => TelegramLiveDeferredInputPreparation | undefined;
560
+ /** Internal notification after the worker's exact donor-removal ACK; never issues completion itself. */
561
+ acknowledgeTransferred?: () => void;
556
562
  prepareQueueAdmission?: () =>
557
563
  TelegramDeferredQueueAdmissionPreparation | undefined;
558
564
  prepareSourceCompletion?: () =>
@@ -614,6 +620,7 @@ export function bindTelegramUpdateAdmissionSource<
614
620
  | "inspectSourceSnapshot"
615
621
  | "inspectCompletion"
616
622
  | "isSourceUnsettled"
623
+ | "acknowledgeTransferred"
617
624
  | "prepareLiveInput"
618
625
  | "prepareQueueAdmission"
619
626
  | "prepareSourceCompletion"
@@ -761,20 +768,24 @@ export function prepareTelegramLiveDeferredInput(
761
768
  )
762
769
  )
763
770
  return undefined;
764
- const unsettled = () =>
765
- carriers.every(
766
- (value) =>
767
- getTelegramUpdateAdmissionBinding(value)?.isSourceUnsettled?.() ===
768
- true,
769
- );
771
+ const bindings = carriers.map(getTelegramUpdateAdmissionBinding);
772
+ const acknowledgers = bindings.map(binding => binding?.acknowledgeTransferred);
773
+ const bindingsCurrent = () => carriers.every((value, index) =>
774
+ getTelegramUpdateAdmissionBinding(value) === bindings[index] && bindings[index]?.acknowledgeTransferred === acknowledgers[index]);
775
+ const unsettled = () => bindingsCurrent() && bindings.every(binding => binding?.isSourceUnsettled?.() === true);
770
776
  if (!unsettled()) return undefined;
777
+ let transferred = false;
771
778
  const prepared = getTelegramUpdateAdmissionBinding(first)?.prepareLiveInput?.(
772
779
  ids,
773
780
  () =>
774
- isCurrent() &&
781
+ bindingsCurrent() && isCurrent() &&
775
782
  carriers.every(
776
783
  (value) => getTelegramUpdateExecutionFence(value)?.isCurrent() === true,
777
784
  ),
785
+ () => {
786
+ transferred = true;
787
+ for (let index = 0; index < bindings.length; index++) acknowledgers[index]?.call(bindings[index]);
788
+ },
778
789
  );
779
790
  return (
780
791
  prepared && {
@@ -785,7 +796,7 @@ export function prepareTelegramLiveDeferredInput(
785
796
  unsettled() && prepared.beginRelease(() => unsettled() && canRelease()),
786
797
  settleTransferred: (canSettle) =>
787
798
  unsettled()
788
- ? prepared.settleTransferred(() => unsettled() && canSettle())
799
+ ? prepared.settleTransferred(() => (transferred || unsettled()) && canSettle())
789
800
  : "protected",
790
801
  cancel: () => unsettled() && prepared.cancel(),
791
802
  }
@@ -2350,6 +2361,7 @@ export interface TelegramUpdateWorkerRuntimeDeps<TContext> {
2350
2361
  update: TelegramJournaledUpdate,
2351
2362
  ctx: TContext,
2352
2363
  signal: AbortSignal,
2364
+ target?: TelegramTarget & { threadId: number },
2353
2365
  ) => Promise<TelegramUpdateAdmissionOutcome> | TelegramUpdateAdmissionOutcome;
2354
2366
  /** Native source-only binding, installed by the admission-worker assembly rather than ordinary dispatch. */
2355
2367
  admitPreparedLiveInput?: (
@@ -2466,7 +2478,7 @@ export interface TelegramLiveInputPreparation {
2466
2478
  prepareStatusConsumption?(input: TelegramLiveStatusConsumption): boolean;
2467
2479
  /** Native owner availability before recipient append; not admission or future target activation. */
2468
2480
  canPrepareStatusConsumption?(): boolean;
2469
- release(canRelease: () => boolean): boolean;
2481
+ release(canRelease: () => boolean, target?: TelegramTarget & { threadId: number }): boolean;
2470
2482
  /** Ends a failed pre-append attempt only when no selected input was saved. */
2471
2483
  cancelEmpty(): boolean;
2472
2484
  /** One fenced removal of this never-dispatched recipient copy; unknown issuance cannot be retried. */
@@ -2532,6 +2544,7 @@ export interface TelegramUpdateWorkerRuntime<TContext> {
2532
2544
  sourceUpdateIds: readonly number[];
2533
2545
  signal: AbortSignal;
2534
2546
  isCurrent(): boolean;
2547
+ onTransferred?(): void;
2535
2548
  }) => TelegramLiveDeferredInputPreparation | undefined;
2536
2549
  prepareDeferredQueueAdmission?: (input: {
2537
2550
  updateId: number;
@@ -2652,6 +2665,7 @@ interface TelegramLiveInputPreparationState<TContext> {
2652
2665
  saved?: TelegramUpdateWorkerJournalSnapshot["entries"][number][];
2653
2666
  discardIssued?: true;
2654
2667
  released?: true;
2668
+ target?: TelegramTarget & { threadId: number };
2655
2669
  status?: {
2656
2670
  current(): boolean;
2657
2671
  assertCurrent(): void;
@@ -3707,6 +3721,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3707
3721
  expectedOwner: TelegramUpdateWorkerOwner<TContext>,
3708
3722
  update: TelegramJournaledUpdate,
3709
3723
  preparedExecute?: TelegramUpdateWorkerRuntimeDeps<TContext>["executeUpdate"],
3724
+ target?: TelegramTarget & { threadId: number },
3710
3725
  ): Promise<
3711
3726
  | TelegramUpdateWorkerExecutionSettlement
3712
3727
  | typeof TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED
@@ -3735,6 +3750,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3735
3750
  update,
3736
3751
  expectedOwner.ctx,
3737
3752
  expectedOwner.controller.signal,
3753
+ target,
3738
3754
  ),
3739
3755
  );
3740
3756
  const settlement: Promise<TelegramUpdateWorkerExecutionSettlement> =
@@ -4739,6 +4755,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
4739
4755
  return admitted.outcome;
4740
4756
  }
4741
4757
  : undefined,
4758
+ prepared?.target,
4742
4759
  );
4743
4760
  if (execution === TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED) {
4744
4761
  return "aborted";
@@ -4821,6 +4838,11 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
4821
4838
  snapshotInvalidated = true;
4822
4839
  break;
4823
4840
  }
4841
+ if (prepared?.target) {
4842
+ prepared.ids = new Set([...prepared.ids].filter(id => id !== entry.updateId));
4843
+ if (!prepared.ids.size && livePreparation === prepared) livePreparation = undefined;
4844
+ updateClaimCounts();
4845
+ }
4824
4846
  const postExecutionAuthority = checkAuthority(
4825
4847
  expectedOwner,
4826
4848
  entry.updateId,
@@ -5270,6 +5292,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
5270
5292
  prepareDeferredLiveInput(input) {
5271
5293
  const expectedOwner = owner,
5272
5294
  binding = deps.getJournalBindingKey?.(),
5295
+ onTransferred = input.onTransferred,
5273
5296
  ids = [...input.sourceUpdateIds];
5274
5297
  const sources = ids.map((id) => deferredSources.get(id));
5275
5298
  const ownerCurrent = () =>
@@ -5282,7 +5305,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
5282
5305
  deps.hasAuthority(expectedOwner.ctx) &&
5283
5306
  deps.isContextCurrent?.(expectedOwner.ctx) !== false &&
5284
5307
  isQueueOwnerIdentityCurrent(expectedOwner) &&
5285
- input.isCurrent();
5308
+ input.onTransferred === onTransferred && input.isCurrent();
5286
5309
  const sourcesCurrent = () =>
5287
5310
  ownerCurrent() &&
5288
5311
  sources.every(
@@ -5381,13 +5404,11 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
5381
5404
  undefined,
5382
5405
  () => current() && canSettle(),
5383
5406
  );
5384
- if (
5385
- !current() ||
5386
- !canSettle() ||
5387
- result.removedUpdateIds.length !== ids.length ||
5388
- !ids.every((id) => result.removedUpdateIds.includes(id))
5389
- )
5407
+ if (result.removedUpdateIds.length !== ids.length || !ids.every(id => result.removedUpdateIds.includes(id)))
5390
5408
  return "unknown";
5409
+ // A consumed routing clock is expected only after this exact ACK, never after uncertain removal.
5410
+ onTransferred?.call(input);
5411
+ if (!current() || !canSettle()) return "unknown";
5391
5412
  } catch (error) {
5392
5413
  recordRuntimeEvent(error, {
5393
5414
  phase: "live-deferred-transfer-settlement",
@@ -5511,7 +5532,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
5511
5532
  }
5512
5533
  };
5513
5534
  const finish = () => {
5514
- if (!held.released || !held.status) livePreparation = undefined;
5535
+ if (!held.released || !held.status && !held.target) livePreparation = undefined;
5515
5536
  updateClaimCounts();
5516
5537
  notifyStateChange();
5517
5538
  blocked = false;
@@ -5659,7 +5680,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
5659
5680
  };
5660
5681
  return held.status.current();
5661
5682
  },
5662
- release(canRelease) {
5683
+ release(canRelease, target) {
5663
5684
  if (
5664
5685
  !current() ||
5665
5686
  held.discardIssued ||
@@ -5676,6 +5697,14 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
5676
5697
  !current()
5677
5698
  )
5678
5699
  return false;
5700
+ if (target) {
5701
+ if (!Number.isSafeInteger(target.chatId) || target.chatId === 0 || !Number.isSafeInteger(target.threadId) || target.threadId <= 0 ||
5702
+ entries.some(entry => {
5703
+ const message = (entry.update.message ?? entry.update.edited_message) as { chat?: { id?: number } } | undefined;
5704
+ return message?.chat?.id !== target.chatId;
5705
+ })) return false;
5706
+ if (!held.status) held.target = { ...target };
5707
+ }
5679
5708
  released = true;
5680
5709
  held.released = true;
5681
5710
  finish();
@@ -8846,7 +8875,8 @@ function createTelegramUpdateSourceAdmissionHandle<
8846
8875
  signal,
8847
8876
  })
8848
8877
  : undefined,
8849
- prepareLiveInput: (sourceUpdateIds, isCurrent) =>
8878
+ acknowledgeTransferred: () => { settlementReported = true; },
8879
+ prepareLiveInput: (sourceUpdateIds, isCurrent, onTransferred) =>
8850
8880
  execution.isCurrent() &&
8851
8881
  !immediate &&
8852
8882
  !settlementReported &&
@@ -8856,6 +8886,7 @@ function createTelegramUpdateSourceAdmissionHandle<
8856
8886
  sourceUpdateIds,
8857
8887
  signal,
8858
8888
  isCurrent: () => execution.isCurrent() && isCurrent(),
8889
+ onTransferred,
8859
8890
  })
8860
8891
  : undefined,
8861
8892
  isHistorical: (matchesOriginal) =>
@@ -10599,11 +10630,19 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
10599
10630
  ),
10600
10631
  }
10601
10632
  : {}),
10602
- async executeUpdate(update, ctx, signal) {
10633
+ async executeUpdate(update, ctx, signal, target) {
10603
10634
  const typedUpdate = update as TUpdate;
10635
+ const prepared = deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate;
10636
+ // Journal/source evidence remains the original; only the warm released handler projection changes destination.
10637
+ const routed = target ? { ...prepared,
10638
+ ...(prepared.message ? { message: prepared.message.message_thread_id === target.threadId ? { ...prepared.message }
10639
+ : withoutTelegramTurnReplyAnchor({ ...prepared.message, message_thread_id: target.threadId, reply_to_message: undefined }) } : {}),
10640
+ ...(prepared.edited_message ? { edited_message: prepared.edited_message.message_thread_id === target.threadId ? { ...prepared.edited_message }
10641
+ : withoutTelegramTurnReplyAnchor({ ...prepared.edited_message, message_thread_id: target.threadId, reply_to_message: undefined }) } : {}),
10642
+ } as TUpdate : prepared;
10604
10643
  try {
10605
10644
  return await executeUpdate(
10606
- deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate,
10645
+ routed,
10607
10646
  ctx,
10608
10647
  signal,
10609
10648
  );