@llblab/pi-kit 0.19.2 → 0.20.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 (66) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-telegram/AGENTS.md +9 -7
  4. package/node_modules/@llblab/pi-telegram/BACKLOG.md +13 -32
  5. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
  6. package/node_modules/@llblab/pi-telegram/README.md +4 -3
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -2
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +3 -5
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +1 -1
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +93 -9
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +9 -3
  12. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +231 -71
  13. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +4 -0
  14. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +34 -7
  15. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +63 -2
  16. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +3 -0
  17. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +13 -0
  18. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -0
  19. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +25 -4
  20. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +1 -0
  21. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +32 -12
  22. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +10 -0
  23. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +22 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +2 -2
  25. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +1 -1
  26. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +1 -2
  27. package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +9 -0
  28. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +41 -1
  29. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +9 -1
  30. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +23 -3
  31. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +7 -2
  32. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +2 -0
  33. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +68 -37
  34. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +6 -4
  35. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +134 -61
  36. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +9 -0
  37. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +49 -3
  38. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +77 -6
  39. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +384 -8
  40. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +3 -0
  41. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +6 -0
  42. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  43. package/node_modules/@llblab/pi-telegram/docs/architecture.md +21 -14
  44. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +31 -10
  45. package/node_modules/@llblab/pi-telegram/docs/updates.md +2 -0
  46. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +5 -7
  47. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +69 -12
  48. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +236 -94
  49. package/node_modules/@llblab/pi-telegram/lib/bus.ts +39 -9
  50. package/node_modules/@llblab/pi-telegram/lib/extension.ts +69 -2
  51. package/node_modules/@llblab/pi-telegram/lib/journal.ts +14 -0
  52. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +23 -4
  53. package/node_modules/@llblab/pi-telegram/lib/locks.ts +32 -13
  54. package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -1
  55. package/node_modules/@llblab/pi-telegram/lib/polling.ts +2 -2
  56. package/node_modules/@llblab/pi-telegram/lib/queue.ts +2 -3
  57. package/node_modules/@llblab/pi-telegram/lib/sync.ts +45 -1
  58. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +30 -2
  59. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +11 -3
  60. package/node_modules/@llblab/pi-telegram/lib/threads.ts +73 -36
  61. package/node_modules/@llblab/pi-telegram/lib/updates.ts +145 -78
  62. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +67 -4
  63. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +465 -7
  64. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +7 -0
  65. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  66. package/package.json +2 -2
@@ -1,11 +1,12 @@
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
  import { isDeepStrictEqual } from "node:util";
8
- import { planTelegramWorkspaceSlotAllocation, } from "./workspace-slots.js";
8
+ import { getTelegramApiErrorRequestTarget, isTelegramApiRequestRejected, } from "./telegram-api.js";
9
+ import { planTelegramWorkspaceSlotAllocation, TelegramWorkspaceSlotUnavailableError, } from "./workspace-slots.js";
9
10
  import { createTelegramWorkspaceAdmissionOperationId, isTelegramWorkspaceRetirementFence, runWithTelegramWorkspaceAdmissionsAsync, } from "./workspace-admission.js";
10
11
  export function createTelegramWorkspaceOperationGate() {
11
12
  let tail = Promise.resolve();
@@ -50,9 +51,10 @@ export function captureTelegramWorkspaceJournalProtectionSources(input) {
50
51
  sources.push({ kind: "unknown", scope });
51
52
  return;
52
53
  }
54
+ const read = binding.readForProtection ?? binding.journal.read;
53
55
  const snapshot = input.withJournalReference
54
- ? input.withJournalReference(binding, binding.journal.read)
55
- : binding.journal.read();
56
+ ? input.withJournalReference(binding, read)
57
+ : read();
56
58
  sources.push({ kind: "available", scope, entries: snapshot.entries });
57
59
  }
58
60
  catch {
@@ -279,6 +281,185 @@ export function createTelegramWorkspaceExternalProtectionCapture(deps) {
279
281
  });
280
282
  };
281
283
  }
284
+ export function isCurrentTelegramWorkspaceBinding(store, expected) {
285
+ return store.listWorkspaceBindings().some((binding) => isDeepStrictEqual(binding, expected));
286
+ }
287
+ /**
288
+ * Demand-only preparation for pressure retirement. Every removed group is still
289
+ * owned by the journal's exact dead-owner CAS; this function never clears local
290
+ * queue memory or turns unknown evidence into deletion authority.
291
+ */
292
+ export function createTelegramWorkspaceDeadOwnerQueueReclaimer(deps) {
293
+ const localProtection = (binding) => {
294
+ const active = deps.getActiveTurnTarget();
295
+ if (active && sameTarget(active, binding.target))
296
+ return "protected";
297
+ let unknown = false;
298
+ for (const item of deps.getQueuedItems()) {
299
+ if (item.target && sameTarget(item.target, binding.target))
300
+ return "protected";
301
+ if (!item.target && item.chatId === binding.target.chatId)
302
+ unknown = true;
303
+ }
304
+ return unknown ? "unknown" : "clear";
305
+ };
306
+ return async (binding, isCurrent) => {
307
+ if (!isCurrent() || !deps.isBindingCurrent(binding)) {
308
+ return { kind: "blocked", reason: "authority-changed" };
309
+ }
310
+ const initial = deps.getExternalProtection(binding);
311
+ if (initial.liveOwner !== "clear" || initial.deliveryAuthority !== "clear") {
312
+ return { kind: "blocked", reason: "live-owner" };
313
+ }
314
+ if (localProtection(binding) !== "clear") {
315
+ return { kind: "blocked", reason: "local-work" };
316
+ }
317
+ if (initial.acceptedWork === "clear")
318
+ return { kind: "not-needed" };
319
+ const discovery = binding.journalBindingsComplete === true
320
+ ? undefined
321
+ : deps.discoverFollowerJournals?.();
322
+ let complete = binding.journalBindingsComplete === true || discovery?.complete === true;
323
+ const sources = [];
324
+ const recoveryKeys = new Set();
325
+ const capture = (scope, resolve) => {
326
+ try {
327
+ const source = resolve();
328
+ if (!source?.recoveryKey || !source.readForProtection) {
329
+ complete = false;
330
+ return;
331
+ }
332
+ if (recoveryKeys.has(source.recoveryKey))
333
+ return;
334
+ const snapshot = deps.withJournalReference
335
+ ? deps.withJournalReference(source, source.readForProtection)
336
+ : source.readForProtection();
337
+ recoveryKeys.add(source.recoveryKey);
338
+ sources.push({ scope, binding: source, entries: snapshot.entries });
339
+ }
340
+ catch {
341
+ complete = false;
342
+ }
343
+ };
344
+ capture("shared", deps.resolveLeaderJournal);
345
+ for (const key of binding.journalBindingKeys ?? []) {
346
+ capture("binding", deps.createFollowerJournalResolver(key));
347
+ }
348
+ for (const path of discovery?.paths ?? []) {
349
+ if (!deps.createJournalPathResolver) {
350
+ complete = false;
351
+ break;
352
+ }
353
+ capture("discovered", deps.createJournalPathResolver(path));
354
+ }
355
+ if (!complete)
356
+ return { kind: "blocked", reason: "incomplete-source" };
357
+ const plans = [];
358
+ for (const source of sources) {
359
+ const relevant = source.scope === "binding"
360
+ ? [...source.entries]
361
+ : source.entries.filter((entry) => {
362
+ const target = getJournalUpdateTarget(entry.update);
363
+ return !!target && sameTarget(target, binding.target);
364
+ });
365
+ if (source.scope === "binding" && relevant.some((entry) => {
366
+ const target = getJournalUpdateTarget(entry.update);
367
+ return !target || !sameTarget(target, binding.target);
368
+ }))
369
+ return { kind: "blocked", reason: "unsupported-custody" };
370
+ const seen = new Set();
371
+ for (const entry of relevant) {
372
+ if (entry.state !== "queued" ||
373
+ (entry.queueKind !== "prompt" && entry.queueKind !== "control") ||
374
+ !entry.queueReceiptId || !entry.queueOwner || entry.queueHandoff) {
375
+ return { kind: "blocked", reason: "unsupported-custody" };
376
+ }
377
+ if (seen.has(entry.queueReceiptId))
378
+ continue;
379
+ const receiptEntries = source.entries.filter((candidate) => candidate.queueReceiptId === entry.queueReceiptId);
380
+ if (!receiptEntries.length || receiptEntries.some((candidate) => {
381
+ const target = getJournalUpdateTarget(candidate.update);
382
+ return candidate.state !== "queued" ||
383
+ candidate.queueKind !== entry.queueKind ||
384
+ !candidate.queueOwner || candidate.queueHandoff !== undefined ||
385
+ !isDeepStrictEqual(candidate.queueOwner, entry.queueOwner) ||
386
+ !target || !sameTarget(target, binding.target);
387
+ }))
388
+ return { kind: "blocked", reason: "unsupported-custody" };
389
+ seen.add(entry.queueReceiptId);
390
+ plans.push({
391
+ source: source.binding,
392
+ recovery: {
393
+ queueKind: entry.queueKind,
394
+ receiptId: entry.queueReceiptId,
395
+ sourceUpdateIds: receiptEntries.map((candidate) => candidate.updateId).sort((a, b) => a - b),
396
+ deadOwner: entry.queueOwner,
397
+ recoveryOwner: deps.getRecoveryOwner(),
398
+ },
399
+ });
400
+ }
401
+ }
402
+ if (!plans.length)
403
+ return { kind: "blocked", reason: "protection-retained" };
404
+ for (const plan of plans) {
405
+ let liveness = "unverifiable";
406
+ try {
407
+ liveness = deps.getQueueOwnerLiveness(plan.recovery.deadOwner);
408
+ }
409
+ catch { /* Unknown process evidence is never destructive authority. */ }
410
+ if (liveness === "alive")
411
+ return { kind: "blocked", reason: "owner-alive" };
412
+ if (liveness !== "dead")
413
+ return { kind: "blocked", reason: "owner-unverifiable" };
414
+ }
415
+ const recoveredIds = [];
416
+ for (const plan of plans) {
417
+ if (!isCurrent() || !deps.isBindingCurrent(binding)) {
418
+ return { kind: "blocked", reason: "authority-changed" };
419
+ }
420
+ const current = deps.getExternalProtection(binding);
421
+ if (current.liveOwner !== "clear" || current.deliveryAuthority !== "clear") {
422
+ return { kind: "blocked", reason: "live-owner" };
423
+ }
424
+ if (localProtection(binding) !== "clear") {
425
+ return { kind: "blocked", reason: "local-work" };
426
+ }
427
+ let result;
428
+ try {
429
+ result = deps.withJournalReference
430
+ ? deps.withJournalReference(plan.source, () => plan.source.journal.recoverDeadQueueOwner(plan.recovery))
431
+ : plan.source.journal.recoverDeadQueueOwner(plan.recovery);
432
+ }
433
+ catch (error) {
434
+ try {
435
+ deps.onMutationError?.(error);
436
+ }
437
+ catch { /* Diagnostics are fail-open. */ }
438
+ return { kind: "blocked", reason: "mutation-refused" };
439
+ }
440
+ if (result.status === "owner-alive") {
441
+ return { kind: "blocked", reason: "owner-alive" };
442
+ }
443
+ if (result.status === "owner-unverifiable") {
444
+ return { kind: "blocked", reason: "owner-unverifiable" };
445
+ }
446
+ recoveredIds.push(...result.recoveredUpdateIds);
447
+ }
448
+ if (!isCurrent() || !deps.isBindingCurrent(binding)) {
449
+ return { kind: "blocked", reason: "authority-changed" };
450
+ }
451
+ const after = deps.getExternalProtection(binding);
452
+ if (after.liveOwner !== "clear" || after.acceptedWork !== "clear" ||
453
+ after.deliveryAuthority !== "clear") {
454
+ return { kind: "blocked", reason: "protection-retained" };
455
+ }
456
+ return {
457
+ kind: "recovered",
458
+ receipts: plans.length,
459
+ updateIds: recoveredIds.sort((a, b) => a - b),
460
+ };
461
+ };
462
+ }
282
463
  export async function adoptTelegramWorkspaceRetirementIntent(input) {
283
464
  return input.runExclusive(async () => {
284
465
  const leaderEpoch = input.getLeaderEpoch();
@@ -314,7 +495,7 @@ function isTelegramWorkspaceDeletionConfirmedAbsent(error) {
314
495
  return false;
315
496
  const status = "status" in error && typeof error.status === "number"
316
497
  ? error.status : undefined;
317
- if (status !== undefined && status !== 400)
498
+ if (status !== 400)
318
499
  return false;
319
500
  const message = error.message.toLowerCase();
320
501
  return message.includes("topic_id_invalid") ||
@@ -332,6 +513,47 @@ function matchesTelegramWorkspaceRetirementFence(fence, intent) {
332
513
  fence.target.threadId === intent.binding.target.threadId &&
333
514
  fence.retirementRequestedAtMs === intent.requestedAtMs);
334
515
  }
516
+ async function cancelRejectedTelegramWorkspaceRetirement(input, retained) {
517
+ const epoch = input.getLeaderEpoch();
518
+ const isCurrent = () => epoch !== undefined && input.getLeaderEpoch() === epoch &&
519
+ input.getProfileKey() === retained.profileKey && input.isCurrent?.() !== false;
520
+ if (!isCurrent())
521
+ return { kind: "retained", reason: "authority-changed" };
522
+ if (retained.phase !== "deletion-rejected" ||
523
+ (retained.destructiveKind ?? "pressure-retirement") !== "pressure-retirement") {
524
+ return { kind: "retained", reason: "fence-conflict" };
525
+ }
526
+ const owner = input.admission.getOwner();
527
+ const fence = retained.leaderEpoch === epoch && isDeepStrictEqual(retained.owner, owner)
528
+ ? retained : input.admission.adoptRetirementFence(retained, { owner, leaderEpoch: epoch });
529
+ if (fence.phase !== "deletion-rejected")
530
+ return { kind: "retained", reason: "fence-conflict" };
531
+ const bindingRetained = () => input.store.listWorkspaceBindings().some(binding => binding.bindingKey === fence.bindingKey && binding.slot === fence.slot && sameTarget(binding.target, fence.target));
532
+ const intents = input.store.listWorkspaceRetirementIntents();
533
+ if (!bindingRetained() || intents.length > 1 ||
534
+ (intents[0] && !matchesTelegramWorkspaceRetirementFence(fence, intents[0]))) {
535
+ return { kind: "retained", reason: "stale-intent" };
536
+ }
537
+ if (intents[0] && !input.store.removeWorkspaceRetirementIntent(intents[0])) {
538
+ return { kind: "retained", reason: "commit-rejected" };
539
+ }
540
+ // Publish even after a same-store retry whose failed write left only a dirty
541
+ // in-memory withdrawal. The rejection fence protects both commit prefixes.
542
+ await input.store.persist();
543
+ if (!isCurrent())
544
+ return { kind: "retained", reason: "authority-changed" };
545
+ if (input.store.listWorkspaceRetirementIntents().length || !bindingRetained()) {
546
+ return { kind: "retained", reason: "commit-rejected" };
547
+ }
548
+ try {
549
+ input.admission.completeRejectedRetirementFence(fence);
550
+ }
551
+ catch {
552
+ if (input.admission.read().fence)
553
+ return { kind: "retained", reason: "fence-release-unconfirmed" };
554
+ }
555
+ return { kind: "cancelled", reason: "delete-rejected" };
556
+ }
335
557
  export async function executeTelegramWorkspaceRetirement(input) {
336
558
  return input.runExclusive(async () => {
337
559
  const isCurrent = () => input.getLeaderEpoch() === input.intent.leaderEpoch &&
@@ -360,6 +582,9 @@ export async function executeTelegramWorkspaceRetirement(input) {
360
582
  leaderEpoch: input.intent.leaderEpoch,
361
583
  });
362
584
  }
585
+ if (fence?.phase === "deletion-rejected") {
586
+ return cancelRejectedTelegramWorkspaceRetirement({ ...input, isCurrent }, fence);
587
+ }
363
588
  const intent = input.store.listWorkspaceRetirementIntents().find((candidate) => isDeepStrictEqual(candidate, input.intent));
364
589
  const binding = input.store.listWorkspaceBindings().find((candidate) => candidate.bindingKey === input.intent.binding.bindingKey);
365
590
  if (!intent || !binding || !isDeepStrictEqual(binding, input.intent.binding)) {
@@ -385,7 +610,8 @@ export async function executeTelegramWorkspaceRetirement(input) {
385
610
  return { kind: "retained", reason: "protection-changed" };
386
611
  if (!fence) {
387
612
  const acquired = input.admission.acquireRetirementFence({
388
- operationId: `workspace-retirement:${input.intent.id}`,
613
+ // A fresh attempt after proven rejection must not revive an old permit.
614
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
389
615
  retirementIntentId: input.intent.id,
390
616
  bindingKey: binding.bindingKey,
391
617
  slot: binding.slot,
@@ -423,6 +649,12 @@ export async function executeTelegramWorkspaceRetirement(input) {
423
649
  }
424
650
  catch (error) {
425
651
  if (!isTelegramWorkspaceDeletionConfirmedAbsent(error)) {
652
+ const target = getTelegramApiErrorRequestTarget(error);
653
+ if (target && sameTarget(target, binding.target) && isTelegramApiRequestRejected(error, "deleteForumTopic")) {
654
+ fence = input.admission.confirmRetirementRejection(fence);
655
+ const cancellation = await cancelRejectedTelegramWorkspaceRetirement({ ...input, isCurrent }, fence);
656
+ return cancellation.kind === "cancelled" ? { kind: "retained", reason: "delete-rejected" } : cancellation;
657
+ }
426
658
  return { kind: "retained", reason: "delete-unconfirmed" };
427
659
  }
428
660
  }
@@ -541,6 +773,31 @@ export async function prepareTelegramWorkspaceRetirement(deps) {
541
773
  }
542
774
  export async function runTelegramWorkspaceRetirementLifecycle(input) {
543
775
  let intent = input.store.listWorkspaceRetirementIntents()[0];
776
+ const retainedFence = input.admission.read().fence;
777
+ if (retainedFence && isTelegramWorkspaceRetirementFence(retainedFence) &&
778
+ retainedFence.phase === "deletion-rejected") {
779
+ return input.runExclusive(() => cancelRejectedTelegramWorkspaceRetirement(input, retainedFence));
780
+ }
781
+ if (!intent && retainedFence && isTelegramWorkspaceRetirementFence(retainedFence) &&
782
+ (retainedFence.destructiveKind ?? "pressure-retirement") === "pressure-retirement" &&
783
+ retainedFence.phase === "commit-ready") {
784
+ return input.runExclusive(async () => {
785
+ const epoch = input.getLeaderEpoch();
786
+ if (epoch === undefined || input.getProfileKey() !== retainedFence.profileKey ||
787
+ input.isCurrent?.() === false)
788
+ return { kind: "retained", reason: "authority-changed" };
789
+ if (input.store.listWorkspaceRetirementIntents().length ||
790
+ input.store.listWorkspaceBindings().some((binding) => binding.bindingKey === retainedFence.bindingKey)) {
791
+ return { kind: "retained", reason: "stale-intent" };
792
+ }
793
+ const owner = input.admission.getOwner();
794
+ const fence = retainedFence.leaderEpoch === epoch &&
795
+ isDeepStrictEqual(retainedFence.owner, owner) ? retainedFence :
796
+ input.admission.adoptRetirementFence(retainedFence, { owner, leaderEpoch: epoch });
797
+ input.admission.completeRetirementFence(fence);
798
+ return { kind: "retired", bindingKey: fence.bindingKey, slot: fence.slot };
799
+ });
800
+ }
544
801
  if (intent) {
545
802
  const adoption = await adoptTelegramWorkspaceRetirementIntent({
546
803
  store: input.store,
@@ -585,3 +842,122 @@ export async function runTelegramWorkspaceRetirementLifecycle(input) {
585
842
  confirmTargetAbsent: input.confirmTargetAbsent,
586
843
  });
587
844
  }
845
+ /** Retry allocation once, only after the failed operation released all ordinary leases. */
846
+ export function createTelegramWorkspaceSlotRotation(input) {
847
+ const requests = createTelegramWorkspaceOperationGate();
848
+ return async (operation) => {
849
+ const admission = input.getAdmission();
850
+ const epoch = input.getLeaderEpoch();
851
+ if (!admission || epoch === undefined)
852
+ return operation();
853
+ const profileKey = admission.getProfileKey();
854
+ const isCurrent = () => input.getLeaderEpoch() === epoch &&
855
+ input.getAdmission()?.getProfileKey() === profileKey;
856
+ return requests.runExclusive(async () => {
857
+ if (!isCurrent())
858
+ throw new Error("Telegram Workspace allocation lost leader authority.");
859
+ const rotate = async () => {
860
+ const result = await input.runExclusive(async () => {
861
+ if (!isCurrent())
862
+ throw new Error("Telegram Workspace rotation lost leader authority.");
863
+ await input.store.load();
864
+ if (!isCurrent())
865
+ throw new Error("Telegram Workspace rotation lost leader authority.");
866
+ return runTelegramWorkspaceRetirementLifecycle({
867
+ store: input.store, admission, getExternalProtection: input.getExternalProtection,
868
+ getLeaderEpoch: input.getLeaderEpoch, getProfileKey: () => profileKey, isCurrent,
869
+ // This entire lifecycle already owns the shared gate, without an admission lease.
870
+ runExclusive: async (action) => action(),
871
+ deleteForumTopic(permit, body) {
872
+ return input.deleteThread(() => {
873
+ const fence = admission.read().fence;
874
+ if (!isCurrent() || !fence || !isTelegramWorkspaceRetirementFence(fence) ||
875
+ (fence.destructiveKind ?? "pressure-retirement") !== "pressure-retirement" ||
876
+ (permit.destructiveKind ?? "pressure-retirement") !== "pressure-retirement" ||
877
+ fence.phase !== "deletion-issued" ||
878
+ !isDeepStrictEqual(fence.owner, admission.getOwner()) ||
879
+ fence.profileKey !== permit.profileKey || fence.leaderEpoch !== permit.leaderEpoch ||
880
+ fence.operationId !== permit.operationId || fence.retirementIntentId !== permit.retirementIntentId ||
881
+ fence.bindingKey !== permit.bindingKey || fence.slot !== permit.slot ||
882
+ fence.deletionIssuedAtMs !== permit.issuedAtMs ||
883
+ !sameTarget(fence.target, permit.target) ||
884
+ body.chat_id !== permit.target.chatId || body.message_thread_id !== permit.target.threadId) {
885
+ throw new Error("Telegram Workspace deletion permit is stale.");
886
+ }
887
+ return { ...permit.target };
888
+ });
889
+ },
890
+ });
891
+ });
892
+ if (result.kind !== "retired" && result.kind !== "not-needed" && result.kind !== "cancelled") {
893
+ throw new Error(`Telegram Workspace slots A-Z are unavailable; rotation blocked (${result.reason}).`);
894
+ }
895
+ if (result.kind === "retired") {
896
+ try {
897
+ input.recordEvent("Telegram Workspace slot rotated.", { slot: result.slot });
898
+ }
899
+ catch { /* Diagnostics cannot turn completed retirement into another attempt. */ }
900
+ }
901
+ if (!isCurrent())
902
+ throw new Error("Telegram Workspace rotation lost leader authority.");
903
+ return result;
904
+ };
905
+ const pending = await input.runExclusive(async () => {
906
+ await input.store.load();
907
+ if (!isCurrent())
908
+ throw new Error("Telegram Workspace allocation lost leader authority.");
909
+ return input.store.listWorkspaceRetirementIntents().length > 0;
910
+ });
911
+ const fence = admission.read().fence;
912
+ if (pending || (fence && isTelegramWorkspaceRetirementFence(fence) &&
913
+ (fence.destructiveKind ?? "pressure-retirement") === "pressure-retirement")) {
914
+ const recovered = await rotate();
915
+ if (recovered.kind !== "cancelled")
916
+ return operation();
917
+ }
918
+ try {
919
+ return await operation();
920
+ }
921
+ catch (error) {
922
+ if (!(error instanceof TelegramWorkspaceSlotUnavailableError))
923
+ throw error;
924
+ if (input.reclaimDeadOwnerQueuedWork) {
925
+ const candidates = await input.runExclusive(async () => {
926
+ await input.store.load();
927
+ if (!isCurrent())
928
+ throw new Error("Telegram Workspace reclamation lost leader authority.");
929
+ const snapshot = input.store.captureWorkspaceSlotOccupancy(input.getExternalProtection);
930
+ const allocation = planTelegramWorkspaceSlotAllocation({ ...snapshot, nowMs: Date.now() });
931
+ if (allocation.kind !== "blocked" || allocation.reason !== "protected-capacity")
932
+ return [];
933
+ return input.store.listWorkspaceBindings().filter((binding) => typeof binding.inactiveSinceMs === "number" &&
934
+ Number.isFinite(binding.inactiveSinceMs) &&
935
+ binding.inactiveSinceMs >= 0).sort((left, right) => left.inactiveSinceMs - right.inactiveSinceMs ||
936
+ (left.slot ?? "").localeCompare(right.slot ?? ""));
937
+ });
938
+ for (const binding of candidates) {
939
+ const evidence = input.getExternalProtection(binding);
940
+ if (evidence.liveOwner !== "clear" ||
941
+ evidence.acceptedWork !== "protected" ||
942
+ evidence.deliveryAuthority !== "clear")
943
+ continue;
944
+ const reclaimed = await input.reclaimDeadOwnerQueuedWork(binding, isCurrent);
945
+ if (reclaimed.kind === "recovered") {
946
+ try {
947
+ input.recordEvent("Telegram Workspace dead-owner queue reclaimed.", {
948
+ slot: binding.slot,
949
+ receipts: reclaimed.receipts,
950
+ updateCount: reclaimed.updateIds.length,
951
+ });
952
+ }
953
+ catch { /* Diagnostics cannot revoke journal-owned recovery. */ }
954
+ break;
955
+ }
956
+ }
957
+ }
958
+ await rotate();
959
+ return operation();
960
+ }
961
+ });
962
+ };
963
+ }
@@ -6,6 +6,9 @@
6
6
  * a selection is a proposal, never authority to retire a binding.
7
7
  */
8
8
  export declare const TELEGRAM_WORKSPACE_SLOTS = "abcdefghijklmnopqrstuvwxyz";
9
+ export declare class TelegramWorkspaceSlotUnavailableError extends Error {
10
+ constructor();
11
+ }
9
12
  export interface TelegramWorkspaceSlotOccupancy {
10
13
  bindingKey: string;
11
14
  slot: string;
@@ -6,6 +6,12 @@
6
6
  * a selection is a proposal, never authority to retire a binding.
7
7
  */
8
8
  export const TELEGRAM_WORKSPACE_SLOTS = "abcdefghijklmnopqrstuvwxyz";
9
+ export class TelegramWorkspaceSlotUnavailableError extends Error {
10
+ constructor() {
11
+ super("Telegram Workspace slot reservation is unavailable.");
12
+ this.name = "TelegramWorkspaceSlotUnavailableError";
13
+ }
14
+ }
9
15
  function isSlot(slot) {
10
16
  return /^[a-z]$/u.test(slot);
11
17
  }
@@ -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"