@llblab/pi-kit 0.19.2 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +6 -6
  4. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +9 -0
  5. package/node_modules/@llblab/pi-state-flow/README.md +4 -4
  6. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +2 -2
  7. package/node_modules/@llblab/pi-state-flow/dist/index.js +2 -2
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +1 -0
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +7 -3
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +41 -11
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +6 -0
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +94 -1
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +2 -5
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +2 -0
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +11 -0
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +4 -2
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +8 -2
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +6 -1
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +19 -6
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +5 -0
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +27 -3
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +2 -2
  23. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  24. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +10 -8
  25. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +2 -2
  26. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +4 -3
  27. package/node_modules/@llblab/pi-state-flow/docs/usage.md +5 -5
  28. package/node_modules/@llblab/pi-state-flow/index.ts +2 -2
  29. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +8 -3
  30. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +41 -9
  31. package/node_modules/@llblab/pi-state-flow/lib/git.ts +83 -1
  32. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +2 -4
  33. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +11 -0
  34. package/node_modules/@llblab/pi-state-flow/lib/status.ts +10 -4
  35. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +23 -6
  36. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +29 -3
  37. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +2 -2
  38. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  39. package/node_modules/@llblab/pi-telegram/AGENTS.md +9 -7
  40. package/node_modules/@llblab/pi-telegram/BACKLOG.md +13 -32
  41. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
  42. package/node_modules/@llblab/pi-telegram/README.md +4 -3
  43. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -2
  44. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +3 -5
  45. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +1 -1
  46. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +93 -9
  47. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +9 -3
  48. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +231 -71
  49. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +4 -0
  50. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +34 -7
  51. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +63 -2
  52. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +3 -0
  53. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +13 -0
  54. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -0
  55. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +25 -4
  56. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +1 -0
  57. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +32 -12
  58. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +10 -0
  59. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +22 -1
  60. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +2 -2
  61. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +1 -1
  62. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +1 -2
  63. package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +9 -0
  64. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +41 -1
  65. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +9 -1
  66. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +23 -3
  67. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +7 -2
  68. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +2 -0
  69. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +68 -37
  70. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +6 -4
  71. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +134 -61
  72. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +9 -0
  73. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +49 -3
  74. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +77 -6
  75. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +384 -8
  76. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +3 -0
  77. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +6 -0
  78. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  79. package/node_modules/@llblab/pi-telegram/docs/architecture.md +21 -14
  80. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +31 -10
  81. package/node_modules/@llblab/pi-telegram/docs/updates.md +2 -0
  82. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +5 -7
  83. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +69 -12
  84. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +236 -94
  85. package/node_modules/@llblab/pi-telegram/lib/bus.ts +39 -9
  86. package/node_modules/@llblab/pi-telegram/lib/extension.ts +69 -2
  87. package/node_modules/@llblab/pi-telegram/lib/journal.ts +14 -0
  88. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +23 -4
  89. package/node_modules/@llblab/pi-telegram/lib/locks.ts +32 -13
  90. package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -1
  91. package/node_modules/@llblab/pi-telegram/lib/polling.ts +2 -2
  92. package/node_modules/@llblab/pi-telegram/lib/queue.ts +2 -3
  93. package/node_modules/@llblab/pi-telegram/lib/sync.ts +45 -1
  94. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +30 -2
  95. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +11 -3
  96. package/node_modules/@llblab/pi-telegram/lib/threads.ts +73 -36
  97. package/node_modules/@llblab/pi-telegram/lib/updates.ts +145 -78
  98. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +67 -4
  99. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +465 -7
  100. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +7 -0
  101. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  102. package/package.json +3 -3
@@ -1,13 +1,24 @@
1
1
  /**
2
- * Workspace retirement preparation
2
+ * Workspace slot rotation
3
3
  * Zones: telegram, workspace identity, lifecycle
4
- * Owns fail-closed protection composition, pressure selection, exact-intent admission,
5
- * successor adoption, and gated mocked deletion with durable retirement commit.
4
+ * Owns fail-closed protection, demand-driven pressure retirement, exact-intent admission,
5
+ * successor recovery, and fenced one-shot deletion before durable slot reuse.
6
6
  */
7
7
 
8
8
  import { isDeepStrictEqual } from "node:util";
9
9
 
10
+ import type {
11
+ TelegramUpdateJournalDeadQueueOwnerRecoveryInput,
12
+ TelegramUpdateJournalDeadQueueOwnerRecoveryResult,
13
+ TelegramUpdateJournalEntry,
14
+ TelegramUpdateJournalQueueOwnerIdentity,
15
+ } from "./journal.ts";
10
16
  import type { TelegramTarget } from "./target.ts";
17
+ import {
18
+ getTelegramApiErrorRequestTarget,
19
+ isTelegramApiRequestRejected,
20
+ type TelegramWorkspaceThreadDeletionTransport,
21
+ } from "./telegram-api.ts";
11
22
  import type {
12
23
  TelegramTopicTargetStore,
13
24
  TelegramWorkspaceExternalProtectionEvidence,
@@ -17,6 +28,7 @@ import type {
17
28
  } from "./threads.ts";
18
29
  import {
19
30
  planTelegramWorkspaceSlotAllocation,
31
+ TelegramWorkspaceSlotUnavailableError,
20
32
  type TelegramWorkspaceSlotOccupancy,
21
33
  } from "./workspace-slots.ts";
22
34
  import {
@@ -93,6 +105,7 @@ export interface TelegramWorkspaceJournalProtectionCapture {
93
105
  interface TelegramWorkspaceJournalReader {
94
106
  recoveryKey?: string;
95
107
  journal: { read: () => { entries: readonly { update: unknown }[] } };
108
+ readForProtection?: () => { entries: readonly { update: unknown }[] };
96
109
  }
97
110
 
98
111
  export function captureTelegramWorkspaceJournalProtectionSources(input: {
@@ -123,9 +136,10 @@ export function captureTelegramWorkspaceJournalProtectionSources(input: {
123
136
  sources.push({ kind: "unknown", scope });
124
137
  return;
125
138
  }
139
+ const read = binding.readForProtection ?? binding.journal.read;
126
140
  const snapshot = input.withJournalReference
127
- ? input.withJournalReference(binding, binding.journal.read)
128
- : binding.journal.read();
141
+ ? input.withJournalReference(binding, read)
142
+ : read();
129
143
  sources.push({ kind: "available", scope, entries: snapshot.entries });
130
144
  } catch {
131
145
  complete = false;
@@ -466,6 +480,244 @@ export function createTelegramWorkspaceExternalProtectionCapture(deps: {
466
480
  };
467
481
  }
468
482
 
483
+ export function isCurrentTelegramWorkspaceBinding(
484
+ store: Pick<TelegramTopicTargetStore, "listWorkspaceBindings">,
485
+ expected: TelegramWorkspaceThreadBinding,
486
+ ): boolean {
487
+ return store.listWorkspaceBindings().some((binding) =>
488
+ isDeepStrictEqual(binding, expected));
489
+ }
490
+
491
+ export interface TelegramWorkspaceDeadQueueJournalBinding {
492
+ recoveryKey?: string;
493
+ readForProtection?: () => { entries: readonly TelegramUpdateJournalEntry[] };
494
+ journal: {
495
+ recoverDeadQueueOwner: (
496
+ input: TelegramUpdateJournalDeadQueueOwnerRecoveryInput,
497
+ ) => TelegramUpdateJournalDeadQueueOwnerRecoveryResult;
498
+ };
499
+ }
500
+
501
+ export type TelegramWorkspaceDeadQueueReclamation =
502
+ | { kind: "not-needed" }
503
+ | { kind: "recovered"; receipts: number; updateIds: number[] }
504
+ | {
505
+ kind: "blocked";
506
+ reason:
507
+ | "authority-changed"
508
+ | "live-owner"
509
+ | "local-work"
510
+ | "incomplete-source"
511
+ | "unsupported-custody"
512
+ | "owner-alive"
513
+ | "owner-unverifiable"
514
+ | "mutation-refused"
515
+ | "protection-retained";
516
+ };
517
+
518
+ /**
519
+ * Demand-only preparation for pressure retirement. Every removed group is still
520
+ * owned by the journal's exact dead-owner CAS; this function never clears local
521
+ * queue memory or turns unknown evidence into deletion authority.
522
+ */
523
+ export function createTelegramWorkspaceDeadOwnerQueueReclaimer(deps: {
524
+ getExternalProtection: (
525
+ binding: TelegramWorkspaceThreadBinding,
526
+ ) => TelegramWorkspaceExternalProtectionEvidence;
527
+ getActiveTurnTarget: () => TelegramTarget | undefined;
528
+ getQueuedItems: () => readonly { chatId: number; target?: TelegramTarget }[];
529
+ resolveLeaderJournal: () => TelegramWorkspaceDeadQueueJournalBinding | undefined;
530
+ createFollowerJournalResolver: (
531
+ journalBindingKey: string,
532
+ ) => () => TelegramWorkspaceDeadQueueJournalBinding | undefined;
533
+ discoverFollowerJournals?: () => { paths: readonly string[]; complete: boolean };
534
+ createJournalPathResolver?: (
535
+ path: string,
536
+ ) => () => TelegramWorkspaceDeadQueueJournalBinding | undefined;
537
+ withJournalReference?: <T>(
538
+ binding: TelegramWorkspaceDeadQueueJournalBinding,
539
+ operation: () => T,
540
+ ) => T;
541
+ getRecoveryOwner: () => TelegramUpdateJournalQueueOwnerIdentity;
542
+ getQueueOwnerLiveness: (
543
+ owner: { processId: number; processBirthId: string },
544
+ ) => "alive" | "dead" | "unverifiable";
545
+ isBindingCurrent: (binding: TelegramWorkspaceThreadBinding) => boolean;
546
+ onMutationError?: (error: unknown) => void;
547
+ }): (
548
+ binding: TelegramWorkspaceThreadBinding,
549
+ isCurrent: () => boolean,
550
+ ) => Promise<TelegramWorkspaceDeadQueueReclamation> {
551
+ const localProtection = (
552
+ binding: TelegramWorkspaceThreadBinding,
553
+ ): "clear" | "protected" | "unknown" => {
554
+ const active = deps.getActiveTurnTarget();
555
+ if (active && sameTarget(active, binding.target)) return "protected";
556
+ let unknown = false;
557
+ for (const item of deps.getQueuedItems()) {
558
+ if (item.target && sameTarget(item.target, binding.target)) return "protected";
559
+ if (!item.target && item.chatId === binding.target.chatId) unknown = true;
560
+ }
561
+ return unknown ? "unknown" : "clear";
562
+ };
563
+ return async (binding, isCurrent) => {
564
+ if (!isCurrent() || !deps.isBindingCurrent(binding)) {
565
+ return { kind: "blocked", reason: "authority-changed" };
566
+ }
567
+ const initial = deps.getExternalProtection(binding);
568
+ if (initial.liveOwner !== "clear" || initial.deliveryAuthority !== "clear") {
569
+ return { kind: "blocked", reason: "live-owner" };
570
+ }
571
+ if (localProtection(binding) !== "clear") {
572
+ return { kind: "blocked", reason: "local-work" };
573
+ }
574
+ if (initial.acceptedWork === "clear") return { kind: "not-needed" };
575
+
576
+ const discovery = binding.journalBindingsComplete === true
577
+ ? undefined
578
+ : deps.discoverFollowerJournals?.();
579
+ let complete = binding.journalBindingsComplete === true || discovery?.complete === true;
580
+ const sources: Array<{
581
+ scope: "shared" | "binding" | "discovered";
582
+ binding: TelegramWorkspaceDeadQueueJournalBinding;
583
+ entries: readonly TelegramUpdateJournalEntry[];
584
+ }> = [];
585
+ const recoveryKeys = new Set<string>();
586
+ const capture = (
587
+ scope: "shared" | "binding" | "discovered",
588
+ resolve: () => TelegramWorkspaceDeadQueueJournalBinding | undefined,
589
+ ): void => {
590
+ try {
591
+ const source = resolve();
592
+ if (!source?.recoveryKey || !source.readForProtection) {
593
+ complete = false;
594
+ return;
595
+ }
596
+ if (recoveryKeys.has(source.recoveryKey)) return;
597
+ const snapshot = deps.withJournalReference
598
+ ? deps.withJournalReference(source, source.readForProtection)
599
+ : source.readForProtection();
600
+ recoveryKeys.add(source.recoveryKey);
601
+ sources.push({ scope, binding: source, entries: snapshot.entries });
602
+ } catch {
603
+ complete = false;
604
+ }
605
+ };
606
+ capture("shared", deps.resolveLeaderJournal);
607
+ for (const key of binding.journalBindingKeys ?? []) {
608
+ capture("binding", deps.createFollowerJournalResolver(key));
609
+ }
610
+ for (const path of discovery?.paths ?? []) {
611
+ if (!deps.createJournalPathResolver) {
612
+ complete = false;
613
+ break;
614
+ }
615
+ capture("discovered", deps.createJournalPathResolver(path));
616
+ }
617
+ if (!complete) return { kind: "blocked", reason: "incomplete-source" };
618
+
619
+ const plans: Array<{
620
+ source: TelegramWorkspaceDeadQueueJournalBinding;
621
+ recovery: TelegramUpdateJournalDeadQueueOwnerRecoveryInput;
622
+ }> = [];
623
+ for (const source of sources) {
624
+ const relevant = source.scope === "binding"
625
+ ? [...source.entries]
626
+ : source.entries.filter((entry) => {
627
+ const target = getJournalUpdateTarget(entry.update);
628
+ return !!target && sameTarget(target, binding.target);
629
+ });
630
+ if (source.scope === "binding" && relevant.some((entry) => {
631
+ const target = getJournalUpdateTarget(entry.update);
632
+ return !target || !sameTarget(target, binding.target);
633
+ })) return { kind: "blocked", reason: "unsupported-custody" };
634
+ const seen = new Set<string>();
635
+ for (const entry of relevant) {
636
+ if (entry.state !== "queued" ||
637
+ (entry.queueKind !== "prompt" && entry.queueKind !== "control") ||
638
+ !entry.queueReceiptId || !entry.queueOwner || entry.queueHandoff) {
639
+ return { kind: "blocked", reason: "unsupported-custody" };
640
+ }
641
+ if (seen.has(entry.queueReceiptId)) continue;
642
+ const receiptEntries = source.entries.filter(
643
+ (candidate) => candidate.queueReceiptId === entry.queueReceiptId,
644
+ );
645
+ if (!receiptEntries.length || receiptEntries.some((candidate) => {
646
+ const target = getJournalUpdateTarget(candidate.update);
647
+ return candidate.state !== "queued" ||
648
+ candidate.queueKind !== entry.queueKind ||
649
+ !candidate.queueOwner || candidate.queueHandoff !== undefined ||
650
+ !isDeepStrictEqual(candidate.queueOwner, entry.queueOwner) ||
651
+ !target || !sameTarget(target, binding.target);
652
+ })) return { kind: "blocked", reason: "unsupported-custody" };
653
+ seen.add(entry.queueReceiptId);
654
+ plans.push({
655
+ source: source.binding,
656
+ recovery: {
657
+ queueKind: entry.queueKind,
658
+ receiptId: entry.queueReceiptId,
659
+ sourceUpdateIds: receiptEntries.map((candidate) => candidate.updateId).sort((a, b) => a - b),
660
+ deadOwner: entry.queueOwner,
661
+ recoveryOwner: deps.getRecoveryOwner(),
662
+ },
663
+ });
664
+ }
665
+ }
666
+ if (!plans.length) return { kind: "blocked", reason: "protection-retained" };
667
+ for (const plan of plans) {
668
+ let liveness: "alive" | "dead" | "unverifiable" = "unverifiable";
669
+ try { liveness = deps.getQueueOwnerLiveness(plan.recovery.deadOwner); }
670
+ catch { /* Unknown process evidence is never destructive authority. */ }
671
+ if (liveness === "alive") return { kind: "blocked", reason: "owner-alive" };
672
+ if (liveness !== "dead") return { kind: "blocked", reason: "owner-unverifiable" };
673
+ }
674
+
675
+ const recoveredIds: number[] = [];
676
+ for (const plan of plans) {
677
+ if (!isCurrent() || !deps.isBindingCurrent(binding)) {
678
+ return { kind: "blocked", reason: "authority-changed" };
679
+ }
680
+ const current = deps.getExternalProtection(binding);
681
+ if (current.liveOwner !== "clear" || current.deliveryAuthority !== "clear") {
682
+ return { kind: "blocked", reason: "live-owner" };
683
+ }
684
+ if (localProtection(binding) !== "clear") {
685
+ return { kind: "blocked", reason: "local-work" };
686
+ }
687
+ let result: TelegramUpdateJournalDeadQueueOwnerRecoveryResult;
688
+ try {
689
+ result = deps.withJournalReference
690
+ ? deps.withJournalReference(plan.source, () =>
691
+ plan.source.journal.recoverDeadQueueOwner(plan.recovery))
692
+ : plan.source.journal.recoverDeadQueueOwner(plan.recovery);
693
+ } catch (error) {
694
+ try { deps.onMutationError?.(error); } catch { /* Diagnostics are fail-open. */ }
695
+ return { kind: "blocked", reason: "mutation-refused" };
696
+ }
697
+ if (result.status === "owner-alive") {
698
+ return { kind: "blocked", reason: "owner-alive" };
699
+ }
700
+ if (result.status === "owner-unverifiable") {
701
+ return { kind: "blocked", reason: "owner-unverifiable" };
702
+ }
703
+ recoveredIds.push(...result.recoveredUpdateIds);
704
+ }
705
+ if (!isCurrent() || !deps.isBindingCurrent(binding)) {
706
+ return { kind: "blocked", reason: "authority-changed" };
707
+ }
708
+ const after = deps.getExternalProtection(binding);
709
+ if (after.liveOwner !== "clear" || after.acceptedWork !== "clear" ||
710
+ after.deliveryAuthority !== "clear") {
711
+ return { kind: "blocked", reason: "protection-retained" };
712
+ }
713
+ return {
714
+ kind: "recovered",
715
+ receipts: plans.length,
716
+ updateIds: recoveredIds.sort((a, b) => a - b),
717
+ };
718
+ };
719
+ }
720
+
469
721
  export type TelegramWorkspaceRetirementAdoption =
470
722
  | { kind: "adopted"; intent: TelegramWorkspaceRetirementIntent }
471
723
  | {
@@ -537,7 +789,7 @@ function isTelegramWorkspaceDeletionConfirmedAbsent(error: unknown): boolean {
537
789
  if (!(error instanceof Error)) return false;
538
790
  const status = "status" in error && typeof error.status === "number"
539
791
  ? error.status : undefined;
540
- if (status !== undefined && status !== 400) return false;
792
+ if (status !== 400) return false;
541
793
  const message = error.message.toLowerCase();
542
794
  return message.includes("topic_id_invalid") ||
543
795
  message.includes("message thread not found") ||
@@ -548,6 +800,7 @@ function isTelegramWorkspaceDeletionConfirmedAbsent(error: unknown): boolean {
548
800
 
549
801
  export type TelegramWorkspaceRetirementExecution =
550
802
  | { kind: "retired"; bindingKey: string; slot: string }
803
+ | { kind: "cancelled"; reason: "delete-rejected" }
551
804
  | {
552
805
  kind: "retained";
553
806
  reason:
@@ -556,6 +809,7 @@ export type TelegramWorkspaceRetirementExecution =
556
809
  | "admission-active"
557
810
  | "fence-conflict"
558
811
  | "delete-unconfirmed"
812
+ | "delete-rejected"
559
813
  | "authority-changed"
560
814
  | "commit-rejected"
561
815
  | "fence-release-unconfirmed";
@@ -581,6 +835,47 @@ function matchesTelegramWorkspaceRetirementFence(
581
835
  );
582
836
  }
583
837
 
838
+ async function cancelRejectedTelegramWorkspaceRetirement(
839
+ input: Pick<Parameters<typeof executeTelegramWorkspaceRetirement>[0],
840
+ "store" | "admission" | "getLeaderEpoch" | "getProfileKey" | "isCurrent">,
841
+ retained: TelegramWorkspaceRetirementFence,
842
+ ): Promise<Exclude<TelegramWorkspaceRetirementExecution, { kind: "retired" }>> {
843
+ const epoch = input.getLeaderEpoch();
844
+ const isCurrent = () => epoch !== undefined && input.getLeaderEpoch() === epoch &&
845
+ input.getProfileKey() === retained.profileKey && input.isCurrent?.() !== false;
846
+ if (!isCurrent()) return { kind: "retained", reason: "authority-changed" };
847
+ if (retained.phase !== "deletion-rejected" ||
848
+ (retained.destructiveKind ?? "pressure-retirement") !== "pressure-retirement") {
849
+ return { kind: "retained", reason: "fence-conflict" };
850
+ }
851
+ const owner = input.admission.getOwner();
852
+ const fence = retained.leaderEpoch === epoch && isDeepStrictEqual(retained.owner, owner)
853
+ ? retained : input.admission.adoptRetirementFence(retained, { owner, leaderEpoch: epoch! });
854
+ if (fence.phase !== "deletion-rejected") return { kind: "retained", reason: "fence-conflict" };
855
+ const bindingRetained = () => input.store.listWorkspaceBindings().some(binding =>
856
+ binding.bindingKey === fence.bindingKey && binding.slot === fence.slot && sameTarget(binding.target, fence.target));
857
+ const intents = input.store.listWorkspaceRetirementIntents();
858
+ if (!bindingRetained() || intents.length > 1 ||
859
+ (intents[0] && !matchesTelegramWorkspaceRetirementFence(fence, intents[0]))) {
860
+ return { kind: "retained", reason: "stale-intent" };
861
+ }
862
+ if (intents[0] && !input.store.removeWorkspaceRetirementIntent(intents[0])) {
863
+ return { kind: "retained", reason: "commit-rejected" };
864
+ }
865
+ // Publish even after a same-store retry whose failed write left only a dirty
866
+ // in-memory withdrawal. The rejection fence protects both commit prefixes.
867
+ await input.store.persist();
868
+ if (!isCurrent()) return { kind: "retained", reason: "authority-changed" };
869
+ if (input.store.listWorkspaceRetirementIntents().length || !bindingRetained()) {
870
+ return { kind: "retained", reason: "commit-rejected" };
871
+ }
872
+ try { input.admission.completeRejectedRetirementFence(fence); }
873
+ catch {
874
+ if (input.admission.read().fence) return { kind: "retained", reason: "fence-release-unconfirmed" };
875
+ }
876
+ return { kind: "cancelled", reason: "delete-rejected" };
877
+ }
878
+
584
879
  export async function executeTelegramWorkspaceRetirement(input: {
585
880
  store: Pick<
586
881
  TelegramTopicTargetStore,
@@ -588,6 +883,8 @@ export async function executeTelegramWorkspaceRetirement(input: {
588
883
  | "listWorkspaceBindings"
589
884
  | "listWorkspaceRetirementIntents"
590
885
  | "commitWorkspaceRetirement"
886
+ | "removeWorkspaceRetirementIntent"
887
+ | "persist"
591
888
  >;
592
889
  admission: Pick<
593
890
  TelegramWorkspaceAdmissionLedger,
@@ -597,6 +894,8 @@ export async function executeTelegramWorkspaceRetirement(input: {
597
894
  | "adoptRetirementFence"
598
895
  | "issueDeletionPermit"
599
896
  | "confirmRetirementAbsence"
897
+ | "confirmRetirementRejection"
898
+ | "completeRejectedRetirementFence"
600
899
  | "releaseUnissuedRetirementFence"
601
900
  | "completeRetirementFence"
602
901
  >;
@@ -646,6 +945,9 @@ export async function executeTelegramWorkspaceRetirement(input: {
646
945
  leaderEpoch: input.intent.leaderEpoch,
647
946
  });
648
947
  }
948
+ if (fence?.phase === "deletion-rejected") {
949
+ return cancelRejectedTelegramWorkspaceRetirement({ ...input, isCurrent }, fence);
950
+ }
649
951
  const intent = input.store.listWorkspaceRetirementIntents().find((candidate) =>
650
952
  isDeepStrictEqual(candidate, input.intent),
651
953
  );
@@ -678,7 +980,8 @@ export async function executeTelegramWorkspaceRetirement(input: {
678
980
  if (!eligible()) return { kind: "retained", reason: "protection-changed" };
679
981
  if (!fence) {
680
982
  const acquired = input.admission.acquireRetirementFence({
681
- operationId: `workspace-retirement:${input.intent.id}`,
983
+ // A fresh attempt after proven rejection must not revive an old permit.
984
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
682
985
  retirementIntentId: input.intent.id,
683
986
  bindingKey: binding.bindingKey,
684
987
  slot: binding.slot!,
@@ -719,6 +1022,12 @@ export async function executeTelegramWorkspaceRetirement(input: {
719
1022
  );
720
1023
  } catch (error) {
721
1024
  if (!isTelegramWorkspaceDeletionConfirmedAbsent(error)) {
1025
+ const target = getTelegramApiErrorRequestTarget(error);
1026
+ if (target && sameTarget(target, binding.target) && isTelegramApiRequestRejected(error, "deleteForumTopic")) {
1027
+ fence = input.admission.confirmRetirementRejection(fence);
1028
+ const cancellation = await cancelRejectedTelegramWorkspaceRetirement({ ...input, isCurrent }, fence);
1029
+ return cancellation.kind === "cancelled" ? { kind: "retained", reason: "delete-rejected" } : cancellation;
1030
+ }
722
1031
  return { kind: "retained", reason: "delete-unconfirmed" };
723
1032
  }
724
1033
  }
@@ -922,6 +1231,31 @@ export async function runTelegramWorkspaceRetirementLifecycle(input: {
922
1231
  >[0]["confirmTargetAbsent"];
923
1232
  }): Promise<TelegramWorkspaceRetirementLifecycleResult> {
924
1233
  let intent = input.store.listWorkspaceRetirementIntents()[0];
1234
+ const retainedFence = input.admission.read().fence;
1235
+ if (retainedFence && isTelegramWorkspaceRetirementFence(retainedFence) &&
1236
+ retainedFence.phase === "deletion-rejected") {
1237
+ return input.runExclusive(() => cancelRejectedTelegramWorkspaceRetirement(input, retainedFence));
1238
+ }
1239
+ if (!intent && retainedFence && isTelegramWorkspaceRetirementFence(retainedFence) &&
1240
+ (retainedFence.destructiveKind ?? "pressure-retirement") === "pressure-retirement" &&
1241
+ retainedFence.phase === "commit-ready") {
1242
+ return input.runExclusive(async () => {
1243
+ const epoch = input.getLeaderEpoch();
1244
+ if (epoch === undefined || input.getProfileKey() !== retainedFence.profileKey ||
1245
+ input.isCurrent?.() === false) return { kind: "retained", reason: "authority-changed" };
1246
+ if (input.store.listWorkspaceRetirementIntents().length ||
1247
+ input.store.listWorkspaceBindings().some((binding) =>
1248
+ binding.bindingKey === retainedFence.bindingKey)) {
1249
+ return { kind: "retained", reason: "stale-intent" };
1250
+ }
1251
+ const owner = input.admission.getOwner();
1252
+ const fence = retainedFence.leaderEpoch === epoch &&
1253
+ isDeepStrictEqual(retainedFence.owner, owner) ? retainedFence :
1254
+ input.admission.adoptRetirementFence(retainedFence, { owner, leaderEpoch: epoch });
1255
+ input.admission.completeRetirementFence(fence);
1256
+ return { kind: "retired", bindingKey: fence.bindingKey, slot: fence.slot };
1257
+ });
1258
+ }
925
1259
  if (intent) {
926
1260
  const adoption = await adoptTelegramWorkspaceRetirementIntent({
927
1261
  store: input.store,
@@ -966,3 +1300,127 @@ export async function runTelegramWorkspaceRetirementLifecycle(input: {
966
1300
  confirmTargetAbsent: input.confirmTargetAbsent,
967
1301
  });
968
1302
  }
1303
+
1304
+ export type TelegramWorkspaceCapacityRunner = <T>(operation: () => Promise<T>) => Promise<T>;
1305
+
1306
+ export interface TelegramWorkspaceSlotRotationPorts extends TelegramWorkspaceOperationGate {
1307
+ getAdmission: () => TelegramWorkspaceAdmissionLedger | undefined;
1308
+ deleteThread: TelegramWorkspaceThreadDeletionTransport;
1309
+ reclaimDeadOwnerQueuedWork?: (
1310
+ binding: TelegramWorkspaceThreadBinding,
1311
+ isCurrent: () => boolean,
1312
+ ) => Promise<TelegramWorkspaceDeadQueueReclamation>;
1313
+ }
1314
+
1315
+ /** Retry allocation once, only after the failed operation released all ordinary leases. */
1316
+ export function createTelegramWorkspaceSlotRotation(input: TelegramWorkspaceSlotRotationPorts & {
1317
+ store: TelegramTopicTargetStore;
1318
+ getLeaderEpoch: () => number | string | undefined;
1319
+ getExternalProtection: (binding: TelegramWorkspaceThreadBinding) => TelegramWorkspaceExternalProtectionEvidence;
1320
+ recordEvent: (message: string, details: Record<string, unknown>) => void;
1321
+ }): TelegramWorkspaceCapacityRunner {
1322
+ const requests = createTelegramWorkspaceOperationGate();
1323
+ return async (operation) => {
1324
+ const admission = input.getAdmission();
1325
+ const epoch = input.getLeaderEpoch();
1326
+ if (!admission || epoch === undefined) return operation();
1327
+ const profileKey = admission.getProfileKey();
1328
+ const isCurrent = () => input.getLeaderEpoch() === epoch &&
1329
+ input.getAdmission()?.getProfileKey() === profileKey;
1330
+ return requests.runExclusive(async () => {
1331
+ if (!isCurrent()) throw new Error("Telegram Workspace allocation lost leader authority.");
1332
+ const rotate = async () => {
1333
+ const result = await input.runExclusive(async () => {
1334
+ if (!isCurrent()) throw new Error("Telegram Workspace rotation lost leader authority.");
1335
+ await input.store.load();
1336
+ if (!isCurrent()) throw new Error("Telegram Workspace rotation lost leader authority.");
1337
+ return runTelegramWorkspaceRetirementLifecycle({
1338
+ store: input.store, admission, getExternalProtection: input.getExternalProtection,
1339
+ getLeaderEpoch: input.getLeaderEpoch, getProfileKey: () => profileKey, isCurrent,
1340
+ // This entire lifecycle already owns the shared gate, without an admission lease.
1341
+ runExclusive: async (action) => action(),
1342
+ deleteForumTopic(permit, body) {
1343
+ return input.deleteThread(() => {
1344
+ const fence = admission.read().fence;
1345
+ if (!isCurrent() || !fence || !isTelegramWorkspaceRetirementFence(fence) ||
1346
+ (fence.destructiveKind ?? "pressure-retirement") !== "pressure-retirement" ||
1347
+ (permit.destructiveKind ?? "pressure-retirement") !== "pressure-retirement" ||
1348
+ fence.phase !== "deletion-issued" ||
1349
+ !isDeepStrictEqual(fence.owner, admission.getOwner()) ||
1350
+ fence.profileKey !== permit.profileKey || fence.leaderEpoch !== permit.leaderEpoch ||
1351
+ fence.operationId !== permit.operationId || fence.retirementIntentId !== permit.retirementIntentId ||
1352
+ fence.bindingKey !== permit.bindingKey || fence.slot !== permit.slot ||
1353
+ fence.deletionIssuedAtMs !== permit.issuedAtMs ||
1354
+ !sameTarget(fence.target, permit.target) ||
1355
+ body.chat_id !== permit.target.chatId || body.message_thread_id !== permit.target.threadId) {
1356
+ throw new Error("Telegram Workspace deletion permit is stale.");
1357
+ }
1358
+ return { ...permit.target };
1359
+ });
1360
+ },
1361
+ });
1362
+ });
1363
+ if (result.kind !== "retired" && result.kind !== "not-needed" && result.kind !== "cancelled") {
1364
+ throw new Error(`Telegram Workspace slots A-Z are unavailable; rotation blocked (${result.reason}).`);
1365
+ }
1366
+ if (result.kind === "retired") {
1367
+ try { input.recordEvent("Telegram Workspace slot rotated.", { slot: result.slot }); }
1368
+ catch { /* Diagnostics cannot turn completed retirement into another attempt. */ }
1369
+ }
1370
+ if (!isCurrent()) throw new Error("Telegram Workspace rotation lost leader authority.");
1371
+ return result;
1372
+ };
1373
+ const pending = await input.runExclusive(async () => {
1374
+ await input.store.load();
1375
+ if (!isCurrent()) throw new Error("Telegram Workspace allocation lost leader authority.");
1376
+ return input.store.listWorkspaceRetirementIntents().length > 0;
1377
+ });
1378
+ const fence = admission.read().fence;
1379
+ if (pending || (fence && isTelegramWorkspaceRetirementFence(fence) &&
1380
+ (fence.destructiveKind ?? "pressure-retirement") === "pressure-retirement")) {
1381
+ const recovered = await rotate();
1382
+ if (recovered.kind !== "cancelled") return operation();
1383
+ }
1384
+ try { return await operation(); }
1385
+ catch (error) {
1386
+ if (!(error instanceof TelegramWorkspaceSlotUnavailableError)) throw error;
1387
+ if (input.reclaimDeadOwnerQueuedWork) {
1388
+ const candidates = await input.runExclusive(async () => {
1389
+ await input.store.load();
1390
+ if (!isCurrent()) throw new Error("Telegram Workspace reclamation lost leader authority.");
1391
+ const snapshot = input.store.captureWorkspaceSlotOccupancy(input.getExternalProtection);
1392
+ const allocation = planTelegramWorkspaceSlotAllocation({ ...snapshot, nowMs: Date.now() });
1393
+ if (allocation.kind !== "blocked" || allocation.reason !== "protected-capacity") return [];
1394
+ return input.store.listWorkspaceBindings().filter((binding) =>
1395
+ typeof binding.inactiveSinceMs === "number" &&
1396
+ Number.isFinite(binding.inactiveSinceMs) &&
1397
+ binding.inactiveSinceMs >= 0,
1398
+ ).sort((left, right) =>
1399
+ left.inactiveSinceMs! - right.inactiveSinceMs! ||
1400
+ (left.slot ?? "").localeCompare(right.slot ?? ""),
1401
+ );
1402
+ });
1403
+ for (const binding of candidates) {
1404
+ const evidence = input.getExternalProtection(binding);
1405
+ if (evidence.liveOwner !== "clear" ||
1406
+ evidence.acceptedWork !== "protected" ||
1407
+ evidence.deliveryAuthority !== "clear") continue;
1408
+ const reclaimed = await input.reclaimDeadOwnerQueuedWork(binding, isCurrent);
1409
+ if (reclaimed.kind === "recovered") {
1410
+ try {
1411
+ input.recordEvent("Telegram Workspace dead-owner queue reclaimed.", {
1412
+ slot: binding.slot,
1413
+ receipts: reclaimed.receipts,
1414
+ updateCount: reclaimed.updateIds.length,
1415
+ });
1416
+ } catch { /* Diagnostics cannot revoke journal-owned recovery. */ }
1417
+ break;
1418
+ }
1419
+ }
1420
+ }
1421
+ await rotate();
1422
+ return operation();
1423
+ }
1424
+ });
1425
+ };
1426
+ }
@@ -8,6 +8,13 @@
8
8
 
9
9
  export const TELEGRAM_WORKSPACE_SLOTS = "abcdefghijklmnopqrstuvwxyz";
10
10
 
11
+ export class TelegramWorkspaceSlotUnavailableError extends Error {
12
+ constructor() {
13
+ super("Telegram Workspace slot reservation is unavailable.");
14
+ this.name = "TelegramWorkspaceSlotUnavailableError";
15
+ }
16
+ }
17
+
11
18
  export interface TelegramWorkspaceSlotOccupancy {
12
19
  bindingKey: string;
13
20
  slot: string;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.50.1",
3
+ "version": "0.51.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.19.2",
3
+ "version": "0.21.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -44,8 +44,8 @@
44
44
  "@llblab/pi-clean-room": "0.1.1",
45
45
  "@llblab/pi-codex-usage": "0.10.0",
46
46
  "@llblab/pi-grow-loop": "0.8.1",
47
- "@llblab/pi-state-flow": "0.17.4",
48
- "@llblab/pi-telegram": "0.50.1",
47
+ "@llblab/pi-state-flow": "0.18.0",
48
+ "@llblab/pi-telegram": "0.51.0",
49
49
  "@llblab/skills": "1.15.0"
50
50
  },
51
51
  "bundledDependencies": [