@llblab/pi-telegram 0.50.1 → 0.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/AGENTS.md +10 -8
  2. package/BACKLOG.md +13 -32
  3. package/CHANGELOG.md +16 -0
  4. package/README.md +4 -3
  5. package/dist/lib/bindings.d.ts +4 -2
  6. package/dist/lib/bindings.js +5 -5
  7. package/dist/lib/bus-follower.d.ts +4 -2
  8. package/dist/lib/bus-follower.js +99 -10
  9. package/dist/lib/bus-leader.d.ts +9 -3
  10. package/dist/lib/bus-leader.js +231 -71
  11. package/dist/lib/bus.d.ts +4 -0
  12. package/dist/lib/bus.js +34 -7
  13. package/dist/lib/commands.d.ts +5 -0
  14. package/dist/lib/commands.js +9 -0
  15. package/dist/lib/extension.js +68 -3
  16. package/dist/lib/journal.d.ts +3 -0
  17. package/dist/lib/journal.js +13 -0
  18. package/dist/lib/lifecycle.d.ts +2 -0
  19. package/dist/lib/lifecycle.js +25 -4
  20. package/dist/lib/locks.d.ts +1 -0
  21. package/dist/lib/locks.js +32 -12
  22. package/dist/lib/paths.d.ts +10 -0
  23. package/dist/lib/paths.js +22 -1
  24. package/dist/lib/polling.js +2 -2
  25. package/dist/lib/queue.d.ts +9 -1
  26. package/dist/lib/queue.js +64 -13
  27. package/dist/lib/replies.d.ts +4 -0
  28. package/dist/lib/replies.js +14 -0
  29. package/dist/lib/routing.d.ts +1 -0
  30. package/dist/lib/routing.js +5 -0
  31. package/dist/lib/sync.d.ts +9 -0
  32. package/dist/lib/sync.js +41 -1
  33. package/dist/lib/telegram-api.d.ts +9 -1
  34. package/dist/lib/telegram-api.js +23 -3
  35. package/dist/lib/thread-cleanup-manager.js +7 -2
  36. package/dist/lib/threads.d.ts +2 -0
  37. package/dist/lib/threads.js +68 -37
  38. package/dist/lib/updates.d.ts +6 -4
  39. package/dist/lib/updates.js +134 -61
  40. package/dist/lib/workspace-admission.d.ts +9 -0
  41. package/dist/lib/workspace-admission.js +49 -3
  42. package/dist/lib/workspace-retirement.d.ts +77 -6
  43. package/dist/lib/workspace-retirement.js +384 -8
  44. package/dist/lib/workspace-slots.d.ts +3 -0
  45. package/dist/lib/workspace-slots.js +6 -0
  46. package/dist/package.json +1 -1
  47. package/docs/architecture.md +22 -15
  48. package/docs/multi-instance-bus.md +32 -11
  49. package/docs/public-api.md +1 -1
  50. package/docs/updates.md +2 -0
  51. package/lib/bindings.ts +12 -7
  52. package/lib/bus-follower.ts +82 -14
  53. package/lib/bus-leader.ts +236 -94
  54. package/lib/bus.ts +39 -9
  55. package/lib/commands.ts +19 -0
  56. package/lib/extension.ts +78 -2
  57. package/lib/journal.ts +14 -0
  58. package/lib/lifecycle.ts +23 -4
  59. package/lib/locks.ts +32 -13
  60. package/lib/paths.ts +29 -1
  61. package/lib/polling.ts +2 -2
  62. package/lib/queue.ts +77 -19
  63. package/lib/replies.ts +18 -0
  64. package/lib/routing.ts +7 -0
  65. package/lib/sync.ts +45 -1
  66. package/lib/telegram-api.ts +30 -2
  67. package/lib/thread-cleanup-manager.ts +11 -3
  68. package/lib/threads.ts +73 -36
  69. package/lib/updates.ts +145 -78
  70. package/lib/workspace-admission.ts +67 -4
  71. package/lib/workspace-retirement.ts +465 -7
  72. package/lib/workspace-slots.ts +7 -0
  73. package/package.json +1 -1
@@ -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);
package/dist/lib/bus.d.ts CHANGED
@@ -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;
package/dist/lib/bus.js CHANGED
@@ -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()) {
@@ -210,6 +210,7 @@ export interface TelegramCommandActionDeps<TMessage, TContext> {
210
210
  export interface TelegramStopCommandDeps {
211
211
  hasAbortHandler: () => boolean;
212
212
  clearPendingModelSwitch: () => void;
213
+ cancelNextTransitionAnnouncements?: () => void;
213
214
  clearQueuedTelegramItems: () => number;
214
215
  setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
215
216
  abortCurrentTurn: () => void;
@@ -392,6 +393,8 @@ export interface TelegramCommandRuntimeDeps<TMessage extends TelegramCommandRunt
392
393
  isContextActive?: (ctx: TContext) => boolean;
393
394
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
394
395
  requestNextDispatchAnnouncement?: () => void;
396
+ markActiveTurnNextAbortAnnouncement?: () => boolean;
397
+ cancelNextTransitionAnnouncements?: () => void;
395
398
  requestDeferredDispatchNextQueuedTelegramTurn?: (dispatch: (ctx: TContext) => void) => void;
396
399
  startTypingLoop?: (ctx: TContext, chatId?: number, options?: {
397
400
  target?: {
@@ -504,6 +507,7 @@ export declare function handleTelegramAbortCommand(deps: {
504
507
  hasAbortHandler: () => boolean;
505
508
  hasActiveTelegramTurn: () => boolean;
506
509
  clearPendingModelSwitch: () => void;
510
+ cancelNextTransitionAnnouncements?: () => void;
507
511
  abortCurrentTurn: () => void;
508
512
  setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
509
513
  updateStatus: () => void;
@@ -519,6 +523,7 @@ export declare function handleTelegramNextCommand(deps: {
519
523
  abortCurrentTurn: () => void;
520
524
  dispatchNextQueuedTurn: () => void;
521
525
  requestNextDispatchAnnouncement?: () => void;
526
+ markActiveTurnNextAbortAnnouncement?: () => boolean;
522
527
  clearFoldForDispatch: () => void;
523
528
  updateStatus: () => void;
524
529
  sendTextReply: (text: string, options?: {
@@ -631,6 +631,7 @@ function formatTelegramQueuedTurnCount(count) {
631
631
  }
632
632
  export async function handleTelegramStopCommand(deps) {
633
633
  deps.clearPendingModelSwitch();
634
+ deps.cancelNextTransitionAnnouncements?.();
634
635
  const clearedCount = deps.clearQueuedTelegramItems();
635
636
  deps.setFoldQueuedPromptsIntoHistory(false);
636
637
  if (!deps.hasAbortHandler()) {
@@ -651,6 +652,7 @@ export async function handleTelegramStopCommand(deps) {
651
652
  }
652
653
  export async function handleTelegramAbortCommand(deps) {
653
654
  deps.clearPendingModelSwitch();
655
+ deps.cancelNextTransitionAnnouncements?.();
654
656
  if (!deps.hasAbortHandler()) {
655
657
  await deps.sendTextReply(formatTelegramInformationHeading("💤", "No active turn."), { parseMode: "HTML" });
656
658
  return;
@@ -669,6 +671,7 @@ export async function handleTelegramNextCommand(deps) {
669
671
  if (!deps.isIdle() && deps.hasAbortHandler()) {
670
672
  deps.clearFoldForDispatch();
671
673
  deps.requestNextDispatchAnnouncement?.();
674
+ deps.markActiveTurnNextAbortAnnouncement?.();
672
675
  deps.abortCurrentTurn();
673
676
  deps.updateStatus();
674
677
  return;
@@ -934,6 +937,9 @@ export function createTelegramCommandHandlerTargetRuntime(deps) {
934
937
  updateStatus: deps.updateStatus,
935
938
  isContextActive: deps.isContextActive,
936
939
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
940
+ requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
941
+ markActiveTurnNextAbortAnnouncement: deps.markActiveTurnNextAbortAnnouncement,
942
+ cancelNextTransitionAnnouncements: deps.cancelNextTransitionAnnouncements,
937
943
  startTypingLoop: deps.startTypingLoop,
938
944
  stopTypingLoop: deps.stopTypingLoop,
939
945
  enqueueContinueTurn: deps.enqueueContinueTurn,
@@ -1039,6 +1045,7 @@ async function handleTelegramCommandRuntime(commandName, message, ctx, deps, com
1039
1045
  await handleTelegramStopCommand({
1040
1046
  hasAbortHandler: deps.hasAbortHandler,
1041
1047
  clearPendingModelSwitch: deps.clearPendingModelSwitch,
1048
+ cancelNextTransitionAnnouncements: deps.cancelNextTransitionAnnouncements,
1042
1049
  clearQueuedTelegramItems: () => deps.clearQueuedTelegramItems(commandCtx),
1043
1050
  setFoldQueuedPromptsIntoHistory: deps.setFoldQueuedPromptsIntoHistory,
1044
1051
  abortCurrentTurn: deps.abortCurrentTurn,
@@ -1085,6 +1092,7 @@ async function handleTelegramCommandRuntime(commandName, message, ctx, deps, com
1085
1092
  hasAbortHandler: deps.hasAbortHandler,
1086
1093
  hasActiveTelegramTurn: deps.hasActiveTelegramTurn,
1087
1094
  clearPendingModelSwitch: deps.clearPendingModelSwitch,
1095
+ cancelNextTransitionAnnouncements: deps.cancelNextTransitionAnnouncements,
1088
1096
  abortCurrentTurn: deps.abortCurrentTurn,
1089
1097
  setFoldQueuedPromptsIntoHistory: deps.setFoldQueuedPromptsIntoHistory,
1090
1098
  updateStatus: updateStatusFor(commandCtx),
@@ -1100,6 +1108,7 @@ async function handleTelegramCommandRuntime(commandName, message, ctx, deps, com
1100
1108
  abortCurrentTurn: deps.abortCurrentTurn,
1101
1109
  dispatchNextQueuedTurn: () => deps.dispatchNextQueuedTelegramTurn(commandCtx),
1102
1110
  requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
1111
+ markActiveTurnNextAbortAnnouncement: deps.markActiveTurnNextAbortAnnouncement,
1103
1112
  clearFoldForDispatch: () => deps.setFoldQueuedPromptsIntoHistory(false),
1104
1113
  updateStatus: updateStatusFor(commandCtx),
1105
1114
  sendTextReply: sendReplyFor(nextMessage),