@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
@@ -11,8 +11,9 @@ import { getTelegramApiErrorRequestTarget, isTelegramApiCommitUnknownError, } fr
11
11
  import * as Threads from "./threads.js";
12
12
  import { createTelegramBusLocalServer, createUnauthorizedBusAck, getTelegramBusEnvelopeTrafficClass, getTelegramBusProtocolCompatibility, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, getTelegramBusFollowerSocketPath, sendTelegramBusLocalEnvelope, stripTelegramBusApiMetadata, TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION, TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF, TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, } from "./bus.js";
13
13
  import { getTelegramBusTransportRetryPolicy } from "./bus-transport.js";
14
- import { createTelegramWorkspaceOperationRuntime, } from "./workspace-retirement.js";
14
+ import { createTelegramWorkspaceOperationRuntime, createTelegramWorkspaceSlotRotation, } from "./workspace-retirement.js";
15
15
  import { createTelegramWorkspaceAdmissionOperationId, runWithTelegramWorkspaceAdmissionsAsync, } from "./workspace-admission.js";
16
+ import { TELEGRAM_WORKSPACE_SLOTS, TelegramWorkspaceSlotUnavailableError } from "./workspace-slots.js";
16
17
  export const TELEGRAM_BUS_FOLLOWER_STALE_AFTER_MS = 15_000;
17
18
  function escapeHtml(text) {
18
19
  return text
@@ -56,6 +57,13 @@ export function createTelegramBusLeaderRuntimeAssembly(deps) {
56
57
  });
57
58
  },
58
59
  }).run;
60
+ const runWithWorkspaceCapacity = deps.workspaceRotation && deps.captureWorkspaceExternalProtection &&
61
+ deps.getCurrentLeaderEpoch ? createTelegramWorkspaceSlotRotation({
62
+ ...deps.workspaceRotation, store: deps.topicTargetStore,
63
+ getLeaderEpoch: deps.getCurrentLeaderEpoch,
64
+ getExternalProtection: deps.captureWorkspaceExternalProtection,
65
+ recordEvent(message, details) { deps.recordRuntimeEvent("bus", message, details); },
66
+ }) : undefined;
59
67
  const captureLiveBindingKeys = (bindings) => resolveTelegramLiveWorkspaceBindingKeys(bindings, deps.topicTargetStore.getActiveByInstanceId(deps.instanceId)?.target, deps.runtime.followerRegistry.list());
60
68
  const provisionerPorts = {
61
69
  getAllowedUserId: deps.getAllowedUserId,
@@ -114,13 +122,40 @@ export function createTelegramBusLeaderRuntimeAssembly(deps) {
114
122
  operationKind: "workspace.reconcile-display",
115
123
  scopes: [{ kind: "profile" }],
116
124
  }, reconcileThreadDisplayOperation);
125
+ let startupDisplayTimer;
126
+ let startupDisplayGeneration = 0;
127
+ let startupDisplaySettled = true;
128
+ const cancelStartupDisplayStabilization = () => {
129
+ startupDisplayGeneration += 1;
130
+ startupDisplaySettled = true;
131
+ if (startupDisplayTimer)
132
+ clearTimeout(startupDisplayTimer);
133
+ startupDisplayTimer = undefined;
134
+ };
117
135
  const scheduleDisplay = () => {
118
- if (!display)
136
+ if (!display || !startupDisplaySettled)
119
137
  return;
120
138
  void reconcileThreadDisplay().catch((error) => {
121
139
  deps.recordRuntimeEvent("bus", error, { phase: "thread-display-reconcile" });
122
140
  });
123
141
  };
142
+ const beginStartupDisplayStabilization = () => {
143
+ cancelStartupDisplayStabilization();
144
+ startupDisplaySettled = false;
145
+ };
146
+ const armStartupDisplayReconciliation = () => {
147
+ const generation = startupDisplayGeneration;
148
+ const delayMs = deps.runtime.followerStaleAfterMs ??
149
+ TELEGRAM_BUS_FOLLOWER_STALE_AFTER_MS;
150
+ startupDisplayTimer = setTimeout(() => {
151
+ if (startupDisplayGeneration !== generation)
152
+ return;
153
+ startupDisplayTimer = undefined;
154
+ startupDisplaySettled = true;
155
+ scheduleDisplay();
156
+ }, delayMs);
157
+ startupDisplayTimer.unref?.();
158
+ };
124
159
  let modeTail = Promise.resolve();
125
160
  const applyThreadDisplayMode = (mode, isCurrent) => {
126
161
  const epoch = deps.getCurrentLeaderEpoch?.();
@@ -299,7 +334,7 @@ export function createTelegramBusLeaderRuntimeAssembly(deps) {
299
334
  onFollowerRegistered: scheduleDisplay,
300
335
  provisionLeaderTarget: (ctx) => {
301
336
  const chatId = deps.getAllowedUserId();
302
- return runWorkspaceOperation({
337
+ const provision = () => runWorkspaceOperation({
303
338
  operationId: `leader-provision:${deps.instanceId}`,
304
339
  operationKind: "workspace.provision-leader",
305
340
  scopes: [
@@ -308,6 +343,7 @@ export function createTelegramBusLeaderRuntimeAssembly(deps) {
308
343
  : { kind: "profile" },
309
344
  ],
310
345
  }, () => provisionLeaderTarget(ctx));
346
+ return runWithWorkspaceCapacity ? runWithWorkspaceCapacity(provision) : provision();
311
347
  },
312
348
  getFollowerDisplayTitle(follower) {
313
349
  const binding = deps.topicTargetStore.listWorkspaceBindings().find((binding) => binding.target.chatId === follower.target?.chatId &&
@@ -399,13 +435,50 @@ export function createTelegramBusLeaderRuntimeAssembly(deps) {
399
435
  }, () => cleanupConfirmedDeadFollower(follower));
400
436
  scheduleDisplay();
401
437
  },
438
+ async onFollowerConfirmedDeadPreserved(follower, isDetached, operationId) {
439
+ const epoch = deps.getCurrentLeaderEpoch?.();
440
+ const profile = deps.getTelegramProfile?.();
441
+ if (epoch === undefined || !follower.registrationGeneration ||
442
+ typeof follower.target?.threadId !== "number")
443
+ return false;
444
+ const isCurrent = () => isDetached() && deps.getCurrentLeaderEpoch?.() === epoch &&
445
+ deps.getTelegramProfile?.() === profile;
446
+ if (!isCurrent())
447
+ return false;
448
+ const settled = await runWorkspaceOperation({
449
+ operationId,
450
+ operationKind: "workspace.preserve-dead-follower",
451
+ scopes: [{ kind: "profile" }],
452
+ }, async () => {
453
+ if (!isCurrent())
454
+ return false;
455
+ await deps.topicTargetStore.load();
456
+ if (!isCurrent())
457
+ return false;
458
+ const record = deps.topicTargetStore.getActiveByInstanceId(follower.instanceId);
459
+ if (!record || (follower.profileKey && record.profileKey !== follower.profileKey) ||
460
+ record.target.chatId !== follower.target.chatId ||
461
+ record.target.threadId !== follower.target.threadId)
462
+ return true;
463
+ const committed = await deps.topicTargetStore.detachTargetOwner(record, isCurrent);
464
+ if (!committed && isCurrent()) {
465
+ throw new Error("Telegram preserved follower detachment was not committed.");
466
+ }
467
+ return committed;
468
+ });
469
+ if (settled && isCurrent())
470
+ scheduleDisplay();
471
+ return settled;
472
+ },
402
473
  provisionFollowerTarget: (registration, options) => runWorkspaceOperation({
403
474
  operationId: `follower-provision:${registration.instanceId}:${registration.registrationGeneration}`,
404
475
  operationKind: "workspace.provision-follower",
405
476
  scopes: [{ kind: "profile" }],
406
477
  }, () => provisionFollowerTarget(registration, options)),
407
478
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
479
+ getTelegramProfile: deps.getTelegramProfile,
408
480
  runWorkspaceAdmission,
481
+ runWithWorkspaceCapacity,
409
482
  callApi: createTelegramBusLeaderApiProxy({
410
483
  call: deps.callApi,
411
484
  callMultipart: deps.callMultipart,
@@ -423,9 +496,21 @@ export function createTelegramBusLeaderRuntimeAssembly(deps) {
423
496
  resetThreadNameAdmitted,
424
497
  captureWorkspaceExternalProtection: deps.captureWorkspaceExternalProtection,
425
498
  async startPolling(ctx) {
426
- await runtime.startPolling(ctx);
427
499
  if (display)
428
- scheduleDisplay();
500
+ beginStartupDisplayStabilization();
501
+ try {
502
+ await runtime.startPolling(ctx);
503
+ }
504
+ catch (error) {
505
+ cancelStartupDisplayStabilization();
506
+ throw error;
507
+ }
508
+ if (display)
509
+ armStartupDisplayReconciliation();
510
+ },
511
+ async stopPolling() {
512
+ cancelStartupDisplayStabilization();
513
+ await runtime.stopPolling();
429
514
  },
430
515
  };
431
516
  if (!display)
@@ -495,7 +580,7 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
495
580
  if (options?.existingWorkspaceBindingOnly)
496
581
  return undefined;
497
582
  if (capacityUnavailable) {
498
- throw new Error("Telegram Workspace slot reservation is unavailable.");
583
+ throw new TelegramWorkspaceSlotUnavailableError();
499
584
  }
500
585
  throw new Error("Telegram Workspace identity is already claimed.");
501
586
  }
@@ -591,7 +676,7 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
591
676
  const requestedSlot = workspaceIdentity?.slot ?? workspaceBinding?.slot ?? registration.slot ?? recoveryHint?.slot;
592
677
  const recoveredSlot = deps.topicTargetStore.allocateSlot(followerProfileKey, requestedSlot, workspaceIdentity?.bindingKey);
593
678
  if (!recoveredSlot) {
594
- throw new Error("Telegram Workspace slot reservation is unavailable.");
679
+ throw new TelegramWorkspaceSlotUnavailableError();
595
680
  }
596
681
  const recoveredRecord = {
597
682
  profileKey: followerProfileKey,
@@ -945,9 +1030,7 @@ function createTelegramBusFollowerCleanupHandler(deps, trigger) {
945
1030
  isCleanupTargetProtected,
946
1031
  callApi: deps.callApi,
947
1032
  markStaleByTarget(target, syncStatus, lastSyncError) {
948
- const stale = deps.topicTargetStore.markStaleByTarget(target, syncStatus, lastSyncError);
949
- const inactive = deps.topicTargetStore.markWorkspaceBindingInactiveByTarget(target, (deps.getNowMs ?? Date.now)());
950
- return stale || inactive;
1033
+ return deps.topicTargetStore.markStaleByTarget(target, syncStatus, lastSyncError);
951
1034
  },
952
1035
  removeCleanupIntentById: deps.topicTargetStore.removePendingCleanup,
953
1036
  persist: deps.topicTargetStore.persist,
@@ -1440,11 +1523,15 @@ export function createTelegramBusLeaderEnvelopeHandler(deps) {
1440
1523
  : "missing-capability")}.`,
1441
1524
  };
1442
1525
  }
1526
+ if (!envelope.registration.registrationGeneration) {
1527
+ return {
1528
+ kind: "bus.ack", requestId: envelope.requestId, ok: false,
1529
+ protocol: deps.protocolIdentity,
1530
+ message: "Telegram follower registration requires an exact generation.",
1531
+ };
1532
+ }
1443
1533
  const registrationOperation = () => runFollowerMutation(envelope.registration, async () => {
1444
1534
  try {
1445
- if (!envelope.registration.registrationGeneration) {
1446
- throw new Error("Telegram follower registration requires an exact generation.");
1447
- }
1448
1535
  const leaderEpoch = deps.getCurrentLeaderEpoch?.();
1449
1536
  if (deps.getCurrentLeaderEpoch && leaderEpoch === undefined) {
1450
1537
  throw new Error("Telegram follower registration requires leader ownership.");
@@ -1502,6 +1589,9 @@ export function createTelegramBusLeaderEnvelopeHandler(deps) {
1502
1589
  };
1503
1590
  }
1504
1591
  catch (error) {
1592
+ if (!restoringWorkspace && deps.runWithWorkspaceCapacity &&
1593
+ error instanceof TelegramWorkspaceSlotUnavailableError)
1594
+ throw error;
1505
1595
  return {
1506
1596
  kind: "bus.ack",
1507
1597
  requestId: envelope.requestId,
@@ -1513,14 +1603,14 @@ export function createTelegramBusLeaderEnvelopeHandler(deps) {
1513
1603
  };
1514
1604
  }
1515
1605
  });
1516
- if (!deps.runWorkspaceAdmission)
1517
- return registrationOperation();
1606
+ const register = () => deps.runWorkspaceAdmission ? deps.runWorkspaceAdmission({
1607
+ operationId: `follower-registration:${envelope.requestId}`,
1608
+ operationKind: "workspace.register-follower",
1609
+ scopes: [{ kind: "profile" }],
1610
+ }, registrationOperation) : registrationOperation();
1518
1611
  try {
1519
- return await deps.runWorkspaceAdmission({
1520
- operationId: `follower-registration:${envelope.requestId}`,
1521
- operationKind: "workspace.register-follower",
1522
- scopes: [{ kind: "profile" }],
1523
- }, registrationOperation);
1612
+ return await (!restoringWorkspace && deps.runWithWorkspaceCapacity
1613
+ ? deps.runWithWorkspaceCapacity(register) : register());
1524
1614
  }
1525
1615
  catch (error) {
1526
1616
  return {
@@ -1981,8 +2071,19 @@ export function createTelegramBusLeaderRuntime(deps) {
1981
2071
  let pruneInterval;
1982
2072
  let pruneGeneration = 0;
1983
2073
  let prunePromise;
2074
+ const pendingPreservations = new Map();
2075
+ const forgetPreservation = (observation) => {
2076
+ if (!observation)
2077
+ return;
2078
+ if (pendingPreservations.get(observation.follower.instanceId) === observation) {
2079
+ pendingPreservations.delete(observation.follower.instanceId);
2080
+ }
2081
+ observation.registration.release();
2082
+ };
1984
2083
  const stopPruning = () => {
1985
2084
  pruneGeneration += 1;
2085
+ for (const observation of pendingPreservations.values())
2086
+ forgetPreservation(observation);
1986
2087
  if (pruneInterval)
1987
2088
  clearInterval(pruneInterval);
1988
2089
  pruneInterval = undefined;
@@ -1997,92 +2098,146 @@ export function createTelegramBusLeaderRuntime(deps) {
1997
2098
  }
1998
2099
  };
1999
2100
  const pruneFollowers = async (expectedGeneration) => {
2000
- const isCurrent = () => pruneGeneration === expectedGeneration;
2101
+ const epoch = deps.getCurrentLeaderEpoch?.();
2102
+ const profile = deps.getTelegramProfile?.();
2103
+ const isCurrent = () => pruneGeneration === expectedGeneration &&
2104
+ deps.getTelegramProfile?.() === profile &&
2105
+ (!deps.getCurrentLeaderEpoch || (epoch !== undefined && deps.getCurrentLeaderEpoch() === epoch));
2001
2106
  try {
2002
2107
  await localServer.ensureEndpoint();
2003
2108
  }
2004
2109
  catch (error) {
2005
- recordPruneEvent(error, {
2006
- phase: "leader-endpoint-recovery",
2007
- });
2110
+ recordPruneEvent(error, { phase: "leader-endpoint-recovery" });
2111
+ }
2112
+ if (pruneGeneration !== expectedGeneration)
2113
+ return;
2114
+ for (const observation of pendingPreservations.values()) {
2115
+ if (!observation.isCurrent())
2116
+ forgetPreservation(observation);
2008
2117
  }
2009
2118
  if (!isCurrent())
2010
2119
  return;
2120
+ const attempts = [...pendingPreservations.values()].map((observation) => ({
2121
+ follower: observation.follower, observation, fresh: false,
2122
+ }));
2011
2123
  const removed = deps.followerRegistry.pruneStale(getNowMs(), followerStaleAfterMs);
2012
2124
  for (const follower of removed) {
2125
+ forgetPreservation(pendingPreservations.get(follower.instanceId));
2126
+ let observation;
2127
+ if (deps.onFollowerConfirmedDeadPreserved && epoch !== undefined &&
2128
+ follower.registrationGeneration && Number.isSafeInteger(follower.pid) && follower.pid > 0 &&
2129
+ Number.isSafeInteger(follower.target?.threadId) && follower.target.threadId > 0) {
2130
+ if (pendingPreservations.size < TELEGRAM_WORKSPACE_SLOTS.length) {
2131
+ const registration = deps.followerRegistry.observeUnregistered(follower);
2132
+ let current = true;
2133
+ observation = {
2134
+ follower, registration,
2135
+ // Reuse exact admission authority after a lost acquisition acknowledgement.
2136
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
2137
+ isCurrent() {
2138
+ current = current && isCurrent() && registration.isCurrent();
2139
+ return current;
2140
+ },
2141
+ };
2142
+ pendingPreservations.set(follower.instanceId, observation);
2143
+ }
2144
+ else {
2145
+ recordPruneEvent("Telegram follower preservation observation capacity exhausted; retaining binding", {
2146
+ phase: "follower-preservation-capacity", instanceId: follower.instanceId,
2147
+ });
2148
+ }
2149
+ }
2150
+ attempts.push({ follower, observation, fresh: true });
2151
+ }
2152
+ for (const { follower, observation, fresh } of attempts) {
2153
+ if (!isCurrent())
2154
+ return;
2155
+ if (observation && !observation.isCurrent()) {
2156
+ forgetPreservation(observation);
2157
+ continue;
2158
+ }
2013
2159
  let processConfirmedDead = false;
2014
- if (follower.pid !== undefined && deps.isFollowerProcessAlive) {
2160
+ if (follower.pid !== undefined && Number.isSafeInteger(follower.pid) &&
2161
+ follower.pid > 0 && deps.isFollowerProcessAlive) {
2015
2162
  try {
2016
- processConfirmedDead = !deps.isFollowerProcessAlive(follower.pid);
2163
+ processConfirmedDead = deps.isFollowerProcessAlive(follower.pid) === false;
2017
2164
  }
2018
2165
  catch (error) {
2019
- recordPruneEvent(error, {
2020
- phase: "follower-process-liveness",
2021
- instanceId: follower.instanceId,
2022
- pid: follower.pid,
2023
- });
2166
+ if (fresh)
2167
+ recordPruneEvent(error, {
2168
+ phase: "follower-process-liveness", instanceId: follower.instanceId, pid: follower.pid,
2169
+ });
2024
2170
  }
2025
2171
  }
2026
2172
  if (!processConfirmedDead) {
2027
- recordPruneEvent("Telegram bus follower heartbeat stale; preserving thread binding", {
2028
- phase: "follower-pruned",
2029
- instanceId: follower.instanceId,
2030
- processLiveness: follower.pid === undefined || !deps.isFollowerProcessAlive
2031
- ? "unknown"
2032
- : "alive-or-unknown",
2033
- });
2173
+ if (fresh)
2174
+ recordPruneEvent("Telegram bus follower heartbeat stale; preserving thread binding", {
2175
+ phase: "follower-pruned", instanceId: follower.instanceId,
2176
+ processLiveness: follower.pid === undefined || !deps.isFollowerProcessAlive ? "unknown" : "alive-or-unknown",
2177
+ });
2034
2178
  continue;
2035
2179
  }
2036
- let cleanupEnabled = false;
2180
+ let cleanupEnabled;
2037
2181
  try {
2038
- cleanupEnabled =
2039
- (await deps.shouldCleanupConfirmedDeadFollower?.()) ?? false;
2182
+ cleanupEnabled = await deps.shouldCleanupConfirmedDeadFollower?.();
2040
2183
  }
2041
2184
  catch (error) {
2042
2185
  recordPruneEvent(error, {
2043
- phase: "follower-confirmed-dead-cleanup-policy",
2044
- instanceId: follower.instanceId,
2045
- pid: follower.pid,
2186
+ phase: "follower-confirmed-dead-cleanup-policy", instanceId: follower.instanceId, pid: follower.pid,
2046
2187
  });
2047
2188
  }
2048
- if (!isCurrent())
2189
+ if (!isCurrent()) {
2190
+ forgetPreservation(observation);
2049
2191
  return;
2050
- if (!cleanupEnabled || !deps.onFollowerConfirmedDead) {
2051
- recordPruneEvent("Telegram bus follower process confirmed dead; preserving thread binding", {
2052
- phase: "follower-confirmed-dead-preserved",
2053
- instanceId: follower.instanceId,
2054
- pid: follower.pid,
2055
- cleanupEnabled,
2056
- });
2192
+ }
2193
+ const recordPreserved = () => recordPruneEvent("Telegram bus follower process confirmed dead; preserving thread binding", { phase: "follower-confirmed-dead-preserved", instanceId: follower.instanceId,
2194
+ pid: follower.pid, cleanupEnabled });
2195
+ // Deferred observations authorize only non-destructive publication, never deletion retries.
2196
+ if (cleanupEnabled === true && !fresh) {
2197
+ forgetPreservation(observation);
2198
+ recordPreserved();
2057
2199
  continue;
2058
2200
  }
2201
+ const onConfirmedDead = cleanupEnabled === true ? deps.onFollowerConfirmedDead :
2202
+ cleanupEnabled === false && observation ? deps.onFollowerConfirmedDeadPreserved : undefined;
2203
+ if (!onConfirmedDead) {
2204
+ if (cleanupEnabled === true)
2205
+ forgetPreservation(observation);
2206
+ if (fresh)
2207
+ recordPreserved();
2208
+ continue;
2209
+ }
2210
+ const findReplacement = () => deps.followerRegistry.list().find((candidate) => candidate.instanceId === follower.instanceId ||
2211
+ (!!follower.profileKey && candidate.profileKey === follower.profileKey) ||
2212
+ (!!follower.target && candidate.target?.chatId === follower.target.chatId &&
2213
+ candidate.target?.threadId === follower.target.threadId));
2214
+ let settled = false;
2059
2215
  try {
2060
2216
  await runFollowerMutation(follower, async () => {
2061
- if (!isCurrent())
2062
- return;
2063
- const replacement = deps.followerRegistry.list().find((candidate) => follower.profileKey
2064
- ? candidate.profileKey === follower.profileKey
2065
- : candidate.instanceId === follower.instanceId);
2066
- if (replacement) {
2067
- recordPruneEvent("Telegram bus follower replaced before confirmed-dead cleanup; preserving thread binding", {
2068
- phase: "follower-confirmed-dead-replaced",
2069
- instanceId: follower.instanceId,
2070
- replacementInstanceId: replacement.instanceId,
2071
- });
2217
+ const isDetached = () => isCurrent() && (!observation || observation.isCurrent()) &&
2218
+ !findReplacement() && deps.isFollowerProcessAlive?.(follower.pid) === false;
2219
+ if (!isDetached())
2072
2220
  return;
2073
- }
2074
- await deps.onFollowerConfirmedDead(follower);
2221
+ if (cleanupEnabled === true)
2222
+ await deps.onFollowerConfirmedDead(follower);
2223
+ else
2224
+ settled = await deps.onFollowerConfirmedDeadPreserved(follower, isDetached, observation.operationId);
2075
2225
  });
2226
+ if (!cleanupEnabled)
2227
+ recordPreserved();
2076
2228
  }
2077
2229
  catch (error) {
2078
2230
  recordPruneEvent(error, {
2079
- phase: "follower-confirmed-dead-cleanup",
2080
- instanceId: follower.instanceId,
2081
- pid: follower.pid,
2082
- chatId: follower.target?.chatId,
2083
- threadId: follower.target?.threadId,
2231
+ phase: cleanupEnabled ? "follower-confirmed-dead-cleanup" : "follower-confirmed-dead-preserve",
2232
+ instanceId: follower.instanceId, pid: follower.pid,
2233
+ chatId: follower.target?.chatId, threadId: follower.target?.threadId,
2084
2234
  });
2085
2235
  }
2236
+ finally {
2237
+ if (settled || cleanupEnabled === true || (observation && !observation.isCurrent())) {
2238
+ forgetPreservation(observation);
2239
+ }
2240
+ }
2086
2241
  }
2087
2242
  };
2088
2243
  const requestPrune = () => {
@@ -2121,7 +2276,11 @@ export function createTelegramBusLeaderRuntime(deps) {
2121
2276
  resolveAgentTarget: deps.resolveAgentTarget,
2122
2277
  routeAgentMessage: deps.routeAgentMessage,
2123
2278
  routeQueueHandoff: deps.routeQueueHandoff,
2124
- provisionFollowerTarget: deps.provisionFollowerTarget,
2279
+ provisionFollowerTarget: deps.provisionFollowerTarget ? (registration, options) => {
2280
+ // Same-instance ownership can change in the store before registry publication.
2281
+ forgetPreservation(pendingPreservations.get(registration.instanceId));
2282
+ return deps.provisionFollowerTarget(registration, options);
2283
+ } : undefined,
2125
2284
  onFollowerDisconnected: deps.onFollowerDisconnected,
2126
2285
  renameFollowerThread: deps.renameFollowerThread,
2127
2286
  resetFollowerThreadName: deps.resetFollowerThreadName,
@@ -2132,6 +2291,7 @@ export function createTelegramBusLeaderRuntime(deps) {
2132
2291
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
2133
2292
  runFollowerMutation,
2134
2293
  runWorkspaceAdmission: deps.runWorkspaceAdmission,
2294
+ runWithWorkspaceCapacity: deps.runWithWorkspaceCapacity,
2135
2295
  });
2136
2296
  const routeQueueHandoffEnvelope = async (input) => {
2137
2297
  const recipient = deps.followerRegistry.get(input.recipientInstanceId);
@@ -512,6 +512,10 @@ export interface TelegramBusFollowerRegistry {
512
512
  list: () => TelegramBusFollowerView[];
513
513
  remove: (instanceId: string) => boolean;
514
514
  clear: () => void;
515
+ observeUnregistered: (follower: TelegramBusFollowerView) => {
516
+ isCurrent: () => boolean;
517
+ release: () => void;
518
+ };
515
519
  pruneStale: (nowMs: number, staleAfterMs: number) => TelegramBusFollowerView[];
516
520
  }
517
521
  export declare function createTelegramBusForwardOwnershipValidator(registry: Pick<TelegramBusFollowerRegistry, "get">): (ownership: TelegramBusForwardOwnership) => boolean;
@@ -1317,8 +1317,15 @@ export function createTelegramBusForwardOwnershipValidator(registry) {
1317
1317
  } : undefined);
1318
1318
  };
1319
1319
  }
1320
+ function hasTelegramBusFollowerIdentityOverlap(first, second) {
1321
+ return first.instanceId === second.instanceId ||
1322
+ (first.profileKey !== undefined && first.profileKey === second.profileKey) ||
1323
+ (first.target !== undefined && first.target.chatId === second.target?.chatId &&
1324
+ first.target.threadId === second.target.threadId);
1325
+ }
1320
1326
  export function createTelegramBusFollowerRegistry() {
1321
1327
  const followers = new Map();
1328
+ const observations = new Set();
1322
1329
  const clone = (follower) => ({
1323
1330
  ...follower,
1324
1331
  target: follower.target ? { ...follower.target } : undefined,
@@ -1337,12 +1344,7 @@ export function createTelegramBusFollowerRegistry() {
1337
1344
  for (const [instanceId, follower] of followers.entries()) {
1338
1345
  if (instanceId === registration.instanceId)
1339
1346
  continue;
1340
- const sameProfile = registration.profileKey !== undefined &&
1341
- registration.profileKey === follower.profileKey;
1342
- const sameTarget = registration.target !== undefined &&
1343
- follower.target?.chatId === registration.target.chatId &&
1344
- follower.target.threadId === registration.target.threadId;
1345
- if (sameProfile || sameTarget)
1347
+ if (hasTelegramBusFollowerIdentityOverlap(registration, follower))
1346
1348
  followers.delete(instanceId);
1347
1349
  }
1348
1350
  const next = {
@@ -1359,6 +1361,12 @@ export function createTelegramBusFollowerRegistry() {
1359
1361
  lastHeartbeatMs: existing?.lastHeartbeatMs ?? registration.connectedAtMs,
1360
1362
  };
1361
1363
  followers.set(registration.instanceId, next);
1364
+ for (const observation of observations) {
1365
+ if (!hasTelegramBusFollowerIdentityOverlap(next, observation.follower))
1366
+ continue;
1367
+ observation.current = false;
1368
+ observations.delete(observation);
1369
+ }
1362
1370
  return clone(next);
1363
1371
  },
1364
1372
  heartbeat: (instanceId, nowMs) => {
@@ -1384,7 +1392,26 @@ export function createTelegramBusFollowerRegistry() {
1384
1392
  },
1385
1393
  list: () => [...followers.values()].map(clone),
1386
1394
  remove: (instanceId) => followers.delete(instanceId),
1387
- clear: () => followers.clear(),
1395
+ clear: () => {
1396
+ followers.clear();
1397
+ for (const observation of observations)
1398
+ observation.current = false;
1399
+ observations.clear();
1400
+ },
1401
+ observeUnregistered: (follower) => {
1402
+ // This watches replacement, not process death or delivery authority.
1403
+ const observation = { follower: clone(follower), current: ![...followers.values()]
1404
+ .some((current) => hasTelegramBusFollowerIdentityOverlap(current, follower)) };
1405
+ if (observation.current)
1406
+ observations.add(observation);
1407
+ return {
1408
+ isCurrent: () => observation.current,
1409
+ release() {
1410
+ observation.current = false;
1411
+ observations.delete(observation);
1412
+ },
1413
+ };
1414
+ },
1388
1415
  pruneStale: (nowMs, staleAfterMs) => {
1389
1416
  const removed = [];
1390
1417
  for (const [instanceId, follower] of followers.entries()) {
@@ -79,7 +79,7 @@ export default function (pi) {
79
79
  const workspaceAdmissionRuntime = WorkspaceAdmission.createTelegramWorkspaceAdmissionRuntimeBinding({
80
80
  getProfileName: configStore.getActiveProfileName,
81
81
  getBotToken: configStore.getBotToken,
82
- getPath: Paths.resolveTelegramWorkspaceAdmissionPath,
82
+ getPath: Paths.resolveTelegramWorkspaceAdmissionPathForProfile,
83
83
  owner: {
84
84
  processId: telegramProcessId,
85
85
  processBirthId: telegramQueueProcessBirthId,
@@ -146,7 +146,7 @@ export default function (pi) {
146
146
  },
147
147
  getWorkspaceAdmission: workspaceAdmissionRuntime.resolve,
148
148
  },
149
- getLeaderJournalPath: Paths.resolveTelegramUpdateJournalPath,
149
+ getLeaderJournalPath: Paths.resolveTelegramUpdateJournalPathForProfile,
150
150
  getFollowerJournalPath(bindingKey, profileName) {
151
151
  return Paths.resolveTelegramFollowerJournalPath(bindingKey, undefined, profileName);
152
152
  },
@@ -295,6 +295,45 @@ export default function (pi) {
295
295
  : "clear";
296
296
  },
297
297
  });
298
+ const reclaimTelegramWorkspaceDeadOwnerQueue = WorkspaceRetirement.createTelegramWorkspaceDeadOwnerQueueReclaimer({
299
+ getExternalProtection: captureWorkspaceExternalProtection,
300
+ getActiveTurnTarget: activeTurnRuntime.getTarget,
301
+ getQueuedItems: telegramQueueStore.getQueuedItems,
302
+ resolveLeaderJournal: resolveTelegramUpdateJournalBinding,
303
+ createFollowerJournalResolver: telegramJournalBindingRuntime.createRecipientResolver,
304
+ discoverFollowerJournals() {
305
+ return Journal.discoverTelegramFollowerJournalPaths({
306
+ directory: Paths.resolveTelegramTempDir(),
307
+ profileName: configStore.getActiveProfileName(),
308
+ });
309
+ },
310
+ createJournalPathResolver: telegramJournalBindingRuntime.createPathResolver,
311
+ withJournalReference(binding, operation) {
312
+ if (!binding.recoveryKey)
313
+ throw new Error("Telegram workspace journal reference identity is unavailable.");
314
+ return telegramJournalReferenceRegistry.withReference({
315
+ referenceClass: "workspace-retirement",
316
+ recoveryKey: binding.recoveryKey,
317
+ }, operation);
318
+ },
319
+ getRecoveryOwner() {
320
+ return {
321
+ instanceId: telegramInstanceId,
322
+ processId: telegramProcessId,
323
+ processBirthId: telegramQueueProcessBirthId,
324
+ sessionGeneration: telegramSessionContextStore.getGeneration(),
325
+ };
326
+ },
327
+ getQueueOwnerLiveness: Bus.getTelegramProcessLiveness,
328
+ onMutationError(error) {
329
+ recordRuntimeEvent("Telegram dead-owner queue reclamation was refused.", {
330
+ error: error instanceof Error ? error.message : String(error),
331
+ });
332
+ },
333
+ isBindingCurrent(binding) {
334
+ return WorkspaceRetirement.isCurrentTelegramWorkspaceBinding(threadStore, binding);
335
+ },
336
+ });
298
337
  const inactiveThreadCleanupReviewRuntime = ThreadCleanupManager.createTelegramInactiveThreadCleanupReviewRuntime({
299
338
  getProfileName() { return configStore.getActiveProfileName() ?? "default"; },
300
339
  listBindings: threadStore.listWorkspaceBindings,
@@ -1169,6 +1208,18 @@ export default function (pi) {
1169
1208
  getWorkspaceAdmission: workspaceAdmissionRuntime.resolve,
1170
1209
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1171
1210
  captureWorkspaceExternalProtection,
1211
+ workspaceRotation: {
1212
+ getAdmission: workspaceAdmissionRuntime.resolve,
1213
+ runExclusive: telegramWorkspaceOperationRuntime.runExclusive,
1214
+ deleteThread: directTelegramApiRuntime.deleteWorkspaceThread,
1215
+ reclaimDeadOwnerQueuedWork(binding, isCurrent) {
1216
+ return telegramWorkspaceOperationRuntime.run({
1217
+ operationId: WorkspaceAdmission.createTelegramWorkspaceAdmissionOperationId(),
1218
+ operationKind: "workspace.reclaim-dead-owner-queue",
1219
+ scopes: [{ kind: "target", target: binding.target }],
1220
+ }, reclaimTelegramWorkspaceDeadOwnerQueue.bind(undefined, binding, isCurrent));
1221
+ },
1222
+ },
1172
1223
  callApi(method, body, options) {
1173
1224
  return directTelegramApiRuntime.call(method, body, options);
1174
1225
  },
@@ -1282,6 +1333,15 @@ export default function (pi) {
1282
1333
  recordRuntimeEvent,
1283
1334
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1284
1335
  });
1336
+ const prepareThreadPreservationOnQuit = Sync.createTelegramPreservedLeaderQuitHandler({
1337
+ instanceId: telegramInstanceId,
1338
+ topicTargetStore: threadStore,
1339
+ getCurrentLeaderEpoch,
1340
+ getProfileName: configStore.getActiveProfileName,
1341
+ isPollingSuspended: lockedPollingRuntime.isSuspended,
1342
+ resolveAutomaticThreadCleanupEnabled: configControls.resolveAutomaticThreadCleanupEnabled,
1343
+ runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1344
+ });
1285
1345
  const telegramBridgeSessionLifecycleDeps = Lifecycle.createTelegramBridgeSessionLifecycleDeps({
1286
1346
  contextStore: telegramSessionContextStore,
1287
1347
  queue: {
@@ -1335,6 +1395,7 @@ export default function (pi) {
1335
1395
  capabilityMonitor: telegramThreadCapabilityMonitor,
1336
1396
  queueWatchdog: queueDispatchWatchdogRuntime,
1337
1397
  guestPlaceholder: { stopAll: guestPlaceholderRuntime.stopAll },
1398
+ prepareThreadPreservationOnQuit,
1338
1399
  },
1339
1400
  });
1340
1401
  const sessionLifecycleRuntime = Lifecycle.createTelegramBridgeSessionLifecycleAssembly(telegramBridgeSessionLifecycleDeps);