@llblab/pi-telegram 0.54.4 → 0.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/AGENTS.md +4 -2
  2. package/BACKLOG.md +1 -1
  3. package/CHANGELOG.md +16 -0
  4. package/README.md +5 -3
  5. package/dist/lib/activity.d.ts +5 -1
  6. package/dist/lib/activity.js +31 -2
  7. package/dist/lib/bindings.d.ts +3 -1
  8. package/dist/lib/bindings.js +8 -2
  9. package/dist/lib/bus-api.d.ts +2 -2
  10. package/dist/lib/bus-api.js +7 -2
  11. package/dist/lib/bus-follower.d.ts +50 -3
  12. package/dist/lib/bus-follower.js +313 -146
  13. package/dist/lib/bus-leader.d.ts +3 -0
  14. package/dist/lib/bus-leader.js +99 -18
  15. package/dist/lib/bus.d.ts +7 -3
  16. package/dist/lib/bus.js +24 -16
  17. package/dist/lib/delivery.d.ts +4 -0
  18. package/dist/lib/delivery.js +48 -15
  19. package/dist/lib/extension.js +48 -6
  20. package/dist/lib/lifecycle.d.ts +68 -0
  21. package/dist/lib/lifecycle.js +178 -58
  22. package/dist/lib/machine.d.ts +23 -0
  23. package/dist/lib/machine.js +37 -0
  24. package/dist/lib/media.d.ts +114 -28
  25. package/dist/lib/media.js +292 -119
  26. package/dist/lib/model.d.ts +54 -15
  27. package/dist/lib/model.js +98 -53
  28. package/dist/lib/polling.js +2 -0
  29. package/dist/lib/preview.d.ts +128 -113
  30. package/dist/lib/preview.js +448 -366
  31. package/dist/lib/queue.d.ts +2 -2
  32. package/dist/lib/queue.js +3 -3
  33. package/dist/lib/routing.d.ts +7 -2
  34. package/dist/lib/routing.js +585 -229
  35. package/dist/lib/runtime.d.ts +77 -11
  36. package/dist/lib/runtime.js +174 -92
  37. package/dist/lib/sections.js +0 -1
  38. package/dist/lib/telegram-api.d.ts +11 -9
  39. package/dist/lib/telegram-api.js +53 -4
  40. package/dist/lib/text-groups.d.ts +0 -26
  41. package/dist/lib/text-groups.js +51 -98
  42. package/dist/lib/thread-display.js +14 -6
  43. package/dist/lib/threads.d.ts +18 -5
  44. package/dist/lib/threads.js +107 -30
  45. package/dist/lib/turns.d.ts +6 -0
  46. package/dist/lib/turns.js +12 -1
  47. package/dist/lib/updates.d.ts +13 -4
  48. package/dist/lib/updates.js +54 -19
  49. package/dist/package.json +1 -1
  50. package/docs/activity.md +1 -1
  51. package/docs/architecture.md +14 -11
  52. package/docs/delivery.md +7 -5
  53. package/docs/multi-instance-bus.md +20 -8
  54. package/docs/public-api.md +2 -2
  55. package/docs/ui-style.md +2 -2
  56. package/lib/activity.ts +29 -5
  57. package/lib/bindings.ts +9 -1
  58. package/lib/bus-api.ts +11 -3
  59. package/lib/bus-follower.ts +417 -203
  60. package/lib/bus-leader.ts +88 -14
  61. package/lib/bus.ts +30 -18
  62. package/lib/delivery.ts +47 -19
  63. package/lib/extension.ts +51 -9
  64. package/lib/lifecycle.ts +229 -55
  65. package/lib/machine.ts +60 -0
  66. package/lib/media.ts +438 -172
  67. package/lib/model.ts +169 -74
  68. package/lib/polling.ts +2 -0
  69. package/lib/preview.ts +715 -566
  70. package/lib/queue.ts +5 -5
  71. package/lib/routing.ts +614 -257
  72. package/lib/runtime.ts +260 -104
  73. package/lib/sections.ts +0 -2
  74. package/lib/telegram-api.ts +61 -8
  75. package/lib/text-groups.ts +75 -136
  76. package/lib/thread-display.ts +15 -4
  77. package/lib/threads.ts +120 -36
  78. package/lib/turns.ts +15 -1
  79. package/lib/updates.ts +60 -21
  80. package/package.json +1 -1
@@ -9,12 +9,13 @@ import { createHash } from "node:crypto";
9
9
  import { basename } from "node:path";
10
10
  import { isDeepStrictEqual } from "node:util";
11
11
  import { getTelegramBusTransportRetryPolicy, TELEGRAM_BUS_REGISTRATION_RETRY, } from "./bus-transport.js";
12
- import { createTelegramBusFollowerDeliveryIdentity, createTelegramBusForeignOwnedUpdateForwarder, createTelegramBusLocalServer, createTelegramBusRequestIdFactory, createUnauthorizedBusAck, rejectTelegramBusRequest, getTelegramBusProtocolCompatibility, getTelegramBusSocketPath, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, parseTelegramBusEnvelope, resolveTelegramBusSocketPath, sendTelegramBusLocalEnvelope, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY, TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_COMMAND_SET, TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SAVE, TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE, TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES, TELEGRAM_BUS_LIVE_REBIND_CAPABILITIES, TelegramBusLocalAuthorityError, } from "./bus.js";
12
+ import { createTelegramBusFollowerDeliveryIdentity, createTelegramBusForeignOwnedUpdateForwarder, createTelegramBusLocalServer, createTelegramBusRequestIdFactory, createUnauthorizedBusAck, rejectTelegramBusRequest, getTelegramBusProtocolCompatibility, getTelegramBusSocketPath, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, parseTelegramBusEnvelope, resolveTelegramBusSocketPath, sendTelegramBusLocalEnvelope, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY, TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_COMMAND_SET, TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SAVE, TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE, TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION, TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_HELD_COMMAND_CAPABILITIES, TELEGRAM_BUS_LIVE_REBIND_CAPABILITIES, TelegramBusLocalAuthorityError, } from "./bus.js";
13
13
  import { parseTelegramUpdateJournalQueueOwner, } from "./journal.js";
14
14
  import { TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS, } from "./locks.js";
15
+ import * as Machine from "./machine.js";
15
16
  import { isPiStaleContextError } from "./pi.js";
16
17
  import { parseTelegramTarget as parseTarget, } from "./target.js";
17
- import { isTelegramApiMethodRetrySafe, TelegramApiAuthorityError, TelegramApiCommitUnknownError, TelegramApiStaleTargetError, } from "./telegram-api.js";
18
+ import { isTelegramApiMethodRetrySafe, assertTelegramApiCallAuthority, TelegramApiAuthorityError, TelegramApiCommitUnknownError, TelegramApiStaleTargetError, } from "./telegram-api.js";
18
19
  import * as Threads from "./threads.js";
19
20
  import { isWireRecord as isRecord } from "./wire.js";
20
21
  import { createTelegramWorkspaceAdmissionOperationId, runWithTelegramWorkspaceAdmissionsAsync, } from "./workspace-admission.js";
@@ -147,8 +148,8 @@ export function createTelegramBusFollowerPromotionHandler(input) {
147
148
  export function createTelegramBusFollowerRuntimeAssembly(ports) {
148
149
  // Binding startup needs registration identity before it can grant inbound authority.
149
150
  let readyContext;
150
- const isReadyContext = (ctx) => Boolean(readyContext &&
151
- (ctx === undefined || readyContext.ctx === ctx) &&
151
+ const isReadyContext = () => Boolean(readyContext &&
152
+ ports.receiver.getContext() === readyContext.ctx &&
152
153
  ports.registrationState.getGeneration() === readyContext.generation &&
153
154
  ports.registration.getSessionGeneration?.() ===
154
155
  readyContext.sessionGeneration &&
@@ -159,18 +160,36 @@ export function createTelegramBusFollowerRuntimeAssembly(ports) {
159
160
  const generation = ports.registrationState.getGeneration();
160
161
  const sessionGeneration = ports.registration.getSessionGeneration?.();
161
162
  const sessionId = ports.registration.getSessionId?.(ctx);
163
+ const executionContext = ports.receiver.getContext();
162
164
  readyContext = undefined;
163
165
  if (!generation ||
166
+ executionContext === undefined ||
167
+ ports.registration.isContextActive?.(ctx) === false ||
168
+ ports.registration.isContextActive?.(executionContext) === false ||
169
+ ports.registration.getSessionId?.(executionContext) !== sessionId ||
170
+ (executionContext !== ctx &&
171
+ (sessionId === undefined ||
172
+ !ports.registration.isContextActive ||
173
+ !ports.registration.getSessionGeneration)) ||
164
174
  (sessionId !== undefined &&
165
175
  ports.registrationState.getSessionId(sessionId) !== sessionId))
166
- throw new Error("Telegram follower session change requires acknowledged leader registration.");
167
- await ports.registration.onRegistered?.(ctx);
176
+ throw new Error("Telegram follower session change requires acknowledged leader registration and a current canonical context.");
177
+ // Connect has a command context; inbound work uses the session's canonical object.
178
+ await ports.registration.onRegistered?.(executionContext);
168
179
  if (generation &&
169
180
  ports.registrationState.getGeneration() === generation &&
170
181
  ports.registration.getSessionGeneration?.() === sessionGeneration &&
182
+ ports.receiver.getContext() === executionContext &&
171
183
  ports.registration.getSessionId?.(ctx) === sessionId &&
172
- ports.registration.isContextActive?.(ctx) !== false)
173
- readyContext = { generation, ctx, sessionGeneration, sessionId };
184
+ ports.registration.getSessionId?.(executionContext) === sessionId &&
185
+ ports.registration.isContextActive?.(ctx) !== false &&
186
+ ports.registration.isContextActive?.(executionContext) !== false)
187
+ readyContext = {
188
+ generation,
189
+ ctx: executionContext,
190
+ sessionGeneration,
191
+ sessionId,
192
+ };
174
193
  };
175
194
  const sharedRuntimeDeps = {
176
195
  instanceId: ports.instanceId,
@@ -220,7 +239,7 @@ export function createTelegramBusFollowerRuntimeAssembly(ports) {
220
239
  if (ports.registration.getSessionGeneration?.() !== sessionGeneration ||
221
240
  ports.registration.isContextActive?.(ctx) === false)
222
241
  return;
223
- if (!isReadyContext(ctx))
242
+ if (!isReadyContext())
224
243
  await prepareContext(ctx);
225
244
  },
226
245
  };
@@ -290,6 +309,7 @@ export function createTelegramBusFollowerWorkspaceRestoreHandler(deps) {
290
309
  const captured = deps.getContextAuthority(ctx);
291
310
  if (!captured)
292
311
  throw new Error("Stale Telegram follower Restore context.");
312
+ const snapshotStore = deps.topicTargetStore, liveSnapshot = snapshotStore.withWorkspaceLiveRebindSnapshot, restoreSnapshot = snapshotStore.withWorkspaceRestoreSnapshot;
293
313
  const authority = structuredClone(captured);
294
314
  const request = structuredClone({
295
315
  operationId: input.operationId,
@@ -331,6 +351,10 @@ export function createTelegramBusFollowerWorkspaceRestoreHandler(deps) {
331
351
  throw new Error("Invalid Telegram follower Restore authority.");
332
352
  const assertCurrent = () => {
333
353
  if (!isDeepStrictEqual(deps.getContextAuthority(ctx), authority) ||
354
+ deps.topicTargetStore !== snapshotStore ||
355
+ (liveCurrent
356
+ ? snapshotStore.withWorkspaceLiveRebindSnapshot !== liveSnapshot
357
+ : snapshotStore.withWorkspaceRestoreSnapshot !== restoreSnapshot) ||
334
358
  liveCurrent?.() === false ||
335
359
  (scope &&
336
360
  (input.liveRebind !== scope ||
@@ -351,6 +375,7 @@ export function createTelegramBusFollowerWorkspaceRestoreHandler(deps) {
351
375
  (liveCurrent
352
376
  ? !("kind" in intent) ||
353
377
  intent.kind !== "live-rebind" ||
378
+ (intent.inputRelease !== undefined && !hasTelegramBusCapability(authority.leaderProtocol, TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION)) ||
354
379
  !isDeepStrictEqual([...intent.request.source.updateIds].sort((a, b) => a - b), sourceUpdateIds) ||
355
380
  (request.mode === "apply"
356
381
  ? intent.phase !== "rebound"
@@ -374,7 +399,7 @@ export function createTelegramBusFollowerWorkspaceRestoreHandler(deps) {
374
399
  WorkspaceIdentity.normalizeTelegramWorkspacePath(authority.cwd))
375
400
  throw new Error("Telegram follower Restore intent does not match this recipient.");
376
401
  const expected = structuredClone(intent);
377
- await deps.topicTargetStore.load();
402
+ await snapshotStore.load();
378
403
  assertCurrent();
379
404
  const observe = (canonical) => {
380
405
  if (!isDeepStrictEqual(readIntent(), expected))
@@ -440,8 +465,19 @@ export function createTelegramBusFollowerWorkspaceRestoreHandler(deps) {
440
465
  ready,
441
466
  };
442
467
  };
468
+ const withSnapshot = (observer) => {
469
+ assertCurrent();
470
+ if ("kind" in expected) {
471
+ if (!liveSnapshot)
472
+ throw new Error("Live-rebind read-only Workspace snapshot is unavailable.");
473
+ liveSnapshot.call(snapshotStore, expected, observer);
474
+ }
475
+ else
476
+ restoreSnapshot.call(snapshotStore, expected, observer);
477
+ assertCurrent();
478
+ };
443
479
  let result;
444
- deps.topicTargetStore.withWorkspaceRestoreSnapshot(expected, (snapshot) => {
480
+ withSnapshot((snapshot) => {
445
481
  result = observe(snapshot);
446
482
  });
447
483
  if (!result)
@@ -449,14 +485,15 @@ export function createTelegramBusFollowerWorkspaceRestoreHandler(deps) {
449
485
  if (release || workObserver) {
450
486
  if (!("kind" in expected) ||
451
487
  expected.kind !== "live-rebind" ||
452
- expected.phase !== "released" ||
488
+ (release ? !Threads.isTelegramLiveRebindInputAuthorized(expected)
489
+ : !(Threads.isTelegramLiveRebindInputAuthorized(expected) || expected.phase === "released" && expected.inputRelease === "held" && !expected.cleanup)) ||
453
490
  !result.ready ||
454
491
  request.mode !== "inspect")
455
492
  throw new Error("Live-rebind dispatch has no canonical released/local-applied grant.");
456
- // Journal reads stay outside the canonical transaction; each barrier check reobserves only Workspace authority.
493
+ // Live barriers reobserve read-only Workspace evidence; journal reads never borrow publication authority.
457
494
  if (workObserver)
458
495
  sampleWork = () => {
459
- deps.topicTargetStore.withWorkspaceRestoreSnapshot(expected, (snapshot) => {
496
+ withSnapshot((snapshot) => {
460
497
  if (!observe(snapshot).ready)
461
498
  throw new Error("Live-rebind work observation lost local target application.");
462
499
  workObserver(expected.request.binding.target);
@@ -466,12 +503,12 @@ export function createTelegramBusFollowerWorkspaceRestoreHandler(deps) {
466
503
  release?.(() => {
467
504
  assertCurrent();
468
505
  let ready = false;
469
- deps.topicTargetStore.withWorkspaceRestoreSnapshot(expected, (snapshot) => {
506
+ withSnapshot((snapshot) => {
470
507
  ready = observe(snapshot).ready;
471
508
  });
472
509
  assertCurrent();
473
510
  return ready;
474
- });
511
+ }, { ...expected.request.target });
475
512
  }
476
513
  return result;
477
514
  });
@@ -566,8 +603,7 @@ export function createTelegramBusFollowerWorkspaceRestoreHandler(deps) {
566
603
  const value = read.call(deps, expected.operationId, authority.profileBindingKey);
567
604
  if (!value ||
568
605
  value.kind !== "live-rebind" ||
569
- value.phase !== "released" ||
570
- value.cleanup ||
606
+ !Threads.isTelegramLiveRebindInputAuthorized(value) ||
571
607
  value.request.operationId !== expected.operationId ||
572
608
  value.recipient.kind !== "follower" ||
573
609
  value.recipient.instanceId !== instanceId ||
@@ -911,8 +947,12 @@ export function createTelegramBusFollowerSelectedMenuCaller(deps) {
911
947
  export function createTelegramBusFollowerApiCaller(deps) {
912
948
  const getNowMs = deps.getNowMs ?? Date.now;
913
949
  const timeoutMs = deps.timeoutMs ?? TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS;
914
- return async (method, args) => {
950
+ return async (method, args, localOptions) => {
951
+ const assertion = localOptions?.assertAuthority;
952
+ const assertCurrent = () => assertTelegramApiCallAuthority(assertion, false);
953
+ assertCurrent();
915
954
  const registration = await resolveTelegramBusFollowerRegistration(deps, timeoutMs);
955
+ assertCurrent();
916
956
  const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
917
957
  let response;
918
958
  try {
@@ -925,9 +965,11 @@ export function createTelegramBusFollowerApiCaller(deps) {
925
965
  method,
926
966
  args,
927
967
  sentAtMs: getNowMs(),
928
- });
968
+ }, assertion ? assertCurrent : undefined);
929
969
  }
930
970
  catch (error) {
971
+ if (error instanceof TelegramBusLocalAuthorityError)
972
+ throw new TelegramApiAuthorityError(error.requestIssued);
931
973
  const apiMethod = (method === "call" || method === "callMultipart") &&
932
974
  typeof args[0] === "string"
933
975
  ? args[0]
@@ -1222,141 +1264,250 @@ export function createTelegramBusFollowerRegistrationState(options = {}) {
1222
1264
  },
1223
1265
  };
1224
1266
  }
1267
+ export function createTelegramBusFollowerRecoveryState() {
1268
+ return {
1269
+ epoch: 0,
1270
+ halted: false,
1271
+ recovering: false,
1272
+ retryId: null,
1273
+ nextRetryId: 0,
1274
+ };
1275
+ }
1276
+ /**
1277
+ * Pure follower-recovery lifecycle. An operator stop halts it and a connect resumes it, each in a new epoch: work
1278
+ * from the old epoch, a recovery still awaiting or a retry still armed, can no longer register or promote.
1279
+ */
1280
+ export function transitionTelegramBusFollowerRecovery(state, event) {
1281
+ const cancelRetry = state.retryId === null
1282
+ ? []
1283
+ : [{ type: "cancelRetry", retryId: state.retryId }];
1284
+ const start = (from) => from.halted || from.recovering
1285
+ ? { state: from, effects: [] }
1286
+ : {
1287
+ state: { ...from, recovering: true },
1288
+ effects: [{ type: "runRecovery", epoch: from.epoch }],
1289
+ };
1290
+ switch (event.type) {
1291
+ case "recoverRequested":
1292
+ return start(state);
1293
+ case "recoverySettled":
1294
+ return event.epoch === state.epoch
1295
+ ? { state: { ...state, recovering: false }, effects: [] }
1296
+ : { state, effects: [] };
1297
+ case "retryRequested": {
1298
+ if (event.epoch !== state.epoch || state.halted)
1299
+ return { state, effects: [] };
1300
+ const retryId = state.nextRetryId + 1;
1301
+ return {
1302
+ state: { ...state, retryId, nextRetryId: retryId },
1303
+ effects: [...cancelRetry, { type: "armRetry", retryId }],
1304
+ };
1305
+ }
1306
+ case "retryFired":
1307
+ if (state.retryId !== event.retryId)
1308
+ return { state, effects: [] };
1309
+ return start({ ...state, retryId: null });
1310
+ case "halted":
1311
+ case "resumed":
1312
+ return {
1313
+ state: {
1314
+ ...state,
1315
+ epoch: state.epoch + 1,
1316
+ halted: event.type === "halted",
1317
+ recovering: false,
1318
+ retryId: null,
1319
+ },
1320
+ effects: cancelRetry,
1321
+ };
1322
+ }
1323
+ }
1225
1324
  export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
1226
1325
  // Promotion stops registration internally and must keep retrying; only an operator stop or a confirmed
1227
1326
  // switch to classic mode halts recovery, so it never re-registers behind a disconnect.
1228
- let halted = false;
1229
1327
  const promotionGraceMs = deps.promotionGraceMs ?? TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS;
1230
1328
  const sleep = deps.sleep ??
1231
1329
  ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1330
+ // A cancelled retry still fires through an injected scheduler; its id no longer matches and it does nothing.
1232
1331
  const scheduleRetry = deps.scheduleRetry ??
1233
1332
  ((retry, delayMs) => {
1234
1333
  const timer = setTimeout(retry, delayMs);
1235
1334
  timer.unref?.();
1236
1335
  });
1237
- let promotionPending = false;
1238
- const safeUpdateStatus = (ctx) => {
1336
+ const retryRequests = new Map();
1337
+ let pendingRetry;
1338
+ let request;
1339
+ let running = Promise.resolve();
1340
+ const machine = Machine.createTelegramMachineRunner({
1341
+ initial: createTelegramBusFollowerRecoveryState(),
1342
+ transition: transitionTelegramBusFollowerRecovery,
1343
+ run(effect) {
1344
+ switch (effect.type) {
1345
+ case "runRecovery": {
1346
+ const started = request;
1347
+ running = runRecovery(started, effect.epoch).finally(() => machine.dispatch({ type: "recoverySettled", epoch: effect.epoch }));
1348
+ return;
1349
+ }
1350
+ case "armRetry": {
1351
+ const retryId = effect.retryId;
1352
+ retryRequests.set(retryId, pendingRetry);
1353
+ const fire = () => {
1354
+ const carried = retryRequests.get(retryId);
1355
+ if (!carried || machine.state.retryId !== retryId)
1356
+ return;
1357
+ const activeCtx = deps.getActiveContext
1358
+ ? deps.getActiveContext()
1359
+ : carried.ctx;
1360
+ if (!activeCtx) {
1361
+ scheduleRetry(fire, promotionGraceMs);
1362
+ return;
1363
+ }
1364
+ retryRequests.delete(retryId);
1365
+ void requestRecovery({ ...carried, ctx: activeCtx }, {
1366
+ type: "retryFired",
1367
+ retryId,
1368
+ });
1369
+ };
1370
+ scheduleRetry(fire, promotionGraceMs);
1371
+ return;
1372
+ }
1373
+ case "cancelRetry":
1374
+ retryRequests.delete(effect.retryId);
1375
+ return;
1376
+ }
1377
+ },
1378
+ });
1379
+ const requestRecovery = (next, event) => {
1380
+ const before = running;
1381
+ request = next;
1239
1382
  try {
1240
- deps.updateStatus(ctx);
1383
+ machine.dispatch(event);
1241
1384
  }
1242
- catch (error) {
1243
- if (!isPiStaleContextError(error))
1244
- throw error;
1245
- deps.recordRuntimeEvent("bus", error, {
1246
- phase: "follower-stale-context-status",
1247
- });
1385
+ finally {
1386
+ request = undefined;
1248
1387
  }
1388
+ return running === before ? Promise.resolve() : running;
1249
1389
  };
1250
- const clearRegisteredState = (ctx) => {
1251
- deps.registrationState.setRegistered(false);
1252
- safeUpdateStatus(ctx);
1253
- };
1254
- const tryRegisterWithLeader = async (ctx, leader, phase, binding) => {
1255
- try {
1256
- const restored = await deps
1257
- .getRegistrationRuntime()
1258
- .registerWithLeader(ctx, leader, binding?.target ? { target: binding.target } : undefined);
1259
- if (!restored)
1390
+ const runRecovery = async ({ reason: error, ctx, binding: carriedBinding }, epoch) => {
1391
+ const isCurrent = () => machine.state.epoch === epoch && !machine.state.halted;
1392
+ const scheduleRecovery = (reason, fallbackCtx, binding) => {
1393
+ pendingRetry = { reason, ctx: fallbackCtx, binding };
1394
+ try {
1395
+ machine.dispatch({ type: "retryRequested", epoch });
1396
+ }
1397
+ finally {
1398
+ pendingRetry = undefined;
1399
+ }
1400
+ };
1401
+ const safeUpdateStatus = (ctx) => {
1402
+ try {
1403
+ deps.updateStatus(ctx);
1404
+ }
1405
+ catch (error) {
1406
+ if (!isPiStaleContextError(error))
1407
+ throw error;
1408
+ deps.recordRuntimeEvent("bus", error, {
1409
+ phase: "follower-stale-context-status",
1410
+ });
1411
+ }
1412
+ };
1413
+ const clearRegisteredState = (ctx) => {
1414
+ deps.registrationState.setRegistered(false);
1415
+ safeUpdateStatus(ctx);
1416
+ };
1417
+ const tryRegisterWithLeader = async (ctx, leader, phase, binding) => {
1418
+ if (!isCurrent())
1260
1419
  return false;
1261
- if (halted) {
1262
- deps.getRegistrationRuntime().stop();
1420
+ try {
1421
+ const restored = await deps
1422
+ .getRegistrationRuntime()
1423
+ .registerWithLeader(ctx, leader, binding?.target ? { target: binding.target } : undefined);
1424
+ // Registration owns its attempt cleanup; an obsolete recovery cannot stop the shared replacement runtime.
1425
+ if (!restored || !isCurrent())
1426
+ return false;
1427
+ deps.setLifecyclePhase(undefined);
1428
+ safeUpdateStatus(ctx);
1429
+ deps.recordRuntimeEvent("bus", "Telegram follower registration restored", {
1430
+ phase,
1431
+ });
1432
+ return true;
1433
+ }
1434
+ catch (error) {
1435
+ if (!isCurrent())
1436
+ return false;
1437
+ clearRegisteredState(ctx);
1438
+ deps.recordRuntimeEvent("bus", error, { phase });
1263
1439
  return false;
1264
1440
  }
1265
- deps.setLifecyclePhase(undefined);
1266
- safeUpdateStatus(ctx);
1267
- deps.recordRuntimeEvent("bus", "Telegram follower registration restored", {
1268
- phase,
1269
- });
1270
- return true;
1271
- }
1272
- catch (error) {
1273
- clearRegisteredState(ctx);
1274
- deps.recordRuntimeEvent("bus", error, { phase });
1275
- return false;
1276
- }
1277
- };
1278
- const snapshotBinding = () => ({
1279
- target: deps.registrationState.getTarget(),
1280
- slot: deps.registrationState.getSlot(),
1281
- threadName: deps.registrationState.getThreadName(),
1282
- });
1283
- const scheduleRecovery = (reason, fallbackCtx, binding) => {
1284
- const retry = () => {
1285
- if (halted)
1441
+ };
1442
+ const snapshotBinding = () => ({
1443
+ target: deps.registrationState.getTarget(),
1444
+ slot: deps.registrationState.getSlot(),
1445
+ threadName: deps.registrationState.getThreadName(),
1446
+ });
1447
+ const promoteToLeader = async (reason, ctx, binding, election) => {
1448
+ if (!isCurrent())
1286
1449
  return;
1287
- const activeCtx = deps.getActiveContext
1288
- ? deps.getActiveContext()
1289
- : fallbackCtx;
1290
- if (!activeCtx) {
1291
- scheduleRetry(retry, promotionGraceMs);
1450
+ const activeCtx = deps.getActiveContext?.();
1451
+ if (deps.getActiveContext && activeCtx !== ctx) {
1452
+ scheduleRecovery(reason, ctx, binding);
1292
1453
  return;
1293
1454
  }
1294
- void recover(reason, activeCtx, binding);
1295
- };
1296
- scheduleRetry(retry, promotionGraceMs);
1297
- };
1298
- const promoteToLeader = async (reason, ctx, binding, election) => {
1299
- const activeCtx = deps.getActiveContext?.();
1300
- if (deps.getActiveContext && activeCtx !== ctx) {
1301
- scheduleRecovery(reason, ctx, binding);
1302
- return;
1303
- }
1304
- deps.setLifecyclePhase("electing");
1305
- safeUpdateStatus(ctx);
1306
- deps.recordRuntimeEvent("bus", reason, {
1307
- phase: "follower-promotion-electing",
1308
- });
1309
- deps.getRegistrationRuntime().stop();
1310
- deps.setLifecyclePhase("electing");
1311
- safeUpdateStatus(ctx);
1312
- deps.recordRuntimeEvent("bus", "Telegram follower attempting promotion", {
1313
- phase: "follower-promotion-electing",
1314
- });
1315
- const promoted = await deps.promoteToLeader(ctx, binding, election);
1316
- deps.setLifecyclePhase(undefined);
1317
- safeUpdateStatus(ctx);
1318
- deps.recordRuntimeEvent("bus", promoted
1319
- ? "Telegram follower promotion completed"
1320
- : "Telegram follower promotion lost election", {
1321
- phase: promoted
1322
- ? "follower-promotion-complete"
1323
- : "follower-promotion-lost",
1324
- });
1325
- if (!promoted)
1326
- scheduleRecovery(reason, ctx, binding);
1327
- };
1328
- const attemptPreferredPromotion = async (reason, ctx, binding, candidateState) => {
1329
- const slot = binding.slot;
1330
- const lowerEligibleSlot = slot
1331
- ? deps.registrationState
1332
- .getEligibleElectionSlots()
1333
- .find((candidate) => candidate < slot)
1334
- : undefined;
1335
- if (lowerEligibleSlot) {
1336
- deps.recordRuntimeEvent("bus", "Telegram follower deferring to a lower-slot election candidate", {
1337
- phase: "follower-promotion-slot-priority",
1338
- slot,
1339
- lowerEligibleSlot,
1455
+ deps.setLifecyclePhase("electing");
1456
+ safeUpdateStatus(ctx);
1457
+ deps.recordRuntimeEvent("bus", reason, {
1458
+ phase: "follower-promotion-electing",
1340
1459
  });
1341
- await sleep(promotionGraceMs);
1342
- candidateState = deps.getLeaderState();
1343
- if (candidateState.kind === "active-elsewhere") {
1344
- if (!(await tryRegisterWithLeader(ctx, candidateState.lock, "follower-register-preferred-successor", binding))) {
1345
- scheduleRecovery(reason, ctx, binding);
1346
- }
1460
+ deps.getRegistrationRuntime().stop();
1461
+ deps.setLifecyclePhase("electing");
1462
+ safeUpdateStatus(ctx);
1463
+ deps.recordRuntimeEvent("bus", "Telegram follower attempting promotion", {
1464
+ phase: "follower-promotion-electing",
1465
+ });
1466
+ const promoted = await deps.promoteToLeader(ctx, binding, election);
1467
+ if (!isCurrent())
1347
1468
  return;
1469
+ deps.setLifecyclePhase(undefined);
1470
+ safeUpdateStatus(ctx);
1471
+ deps.recordRuntimeEvent("bus", promoted
1472
+ ? "Telegram follower promotion completed"
1473
+ : "Telegram follower promotion lost election", {
1474
+ phase: promoted
1475
+ ? "follower-promotion-complete"
1476
+ : "follower-promotion-lost",
1477
+ });
1478
+ if (!promoted)
1479
+ scheduleRecovery(reason, ctx, binding);
1480
+ };
1481
+ const attemptPreferredPromotion = async (reason, ctx, binding, candidateState) => {
1482
+ const slot = binding.slot;
1483
+ const lowerEligibleSlot = slot
1484
+ ? deps.registrationState
1485
+ .getEligibleElectionSlots()
1486
+ .find((candidate) => candidate < slot)
1487
+ : undefined;
1488
+ if (lowerEligibleSlot) {
1489
+ deps.recordRuntimeEvent("bus", "Telegram follower deferring to a lower-slot election candidate", {
1490
+ phase: "follower-promotion-slot-priority",
1491
+ slot,
1492
+ lowerEligibleSlot,
1493
+ });
1494
+ await sleep(promotionGraceMs);
1495
+ if (!isCurrent())
1496
+ return;
1497
+ candidateState = deps.getLeaderState();
1498
+ if (candidateState.kind === "active-elsewhere") {
1499
+ if (!(await tryRegisterWithLeader(ctx, candidateState.lock, "follower-register-preferred-successor", binding))) {
1500
+ scheduleRecovery(reason, ctx, binding);
1501
+ }
1502
+ return;
1503
+ }
1348
1504
  }
1349
- }
1350
- if (candidateState.kind !== "stale" && candidateState.kind !== "inactive")
1351
- return;
1352
- await promoteToLeader(reason, ctx, binding, {
1353
- expectedOwner: candidateState.kind === "stale" ? candidateState.lock : undefined,
1354
- });
1355
- };
1356
- const recover = async (error, ctx, carriedBinding) => {
1357
- if (promotionPending || halted)
1358
- return;
1359
- promotionPending = true;
1505
+ if (candidateState.kind !== "stale" && candidateState.kind !== "inactive")
1506
+ return;
1507
+ await promoteToLeader(reason, ctx, binding, {
1508
+ expectedOwner: candidateState.kind === "stale" ? candidateState.lock : undefined,
1509
+ });
1510
+ };
1360
1511
  deps.registrationState.beginRecovery();
1361
1512
  const initialBinding = carriedBinding ?? snapshotBinding();
1362
1513
  try {
@@ -1369,8 +1520,10 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
1369
1520
  phase: "follower-thread-mode-read",
1370
1521
  });
1371
1522
  }
1523
+ if (!isCurrent())
1524
+ return;
1372
1525
  if (threadModeDisabled) {
1373
- halted = true;
1526
+ machine.dispatch({ type: "halted" });
1374
1527
  deps.getRegistrationRuntime().stop();
1375
1528
  deps.setLifecyclePhase(undefined);
1376
1529
  safeUpdateStatus(ctx);
@@ -1383,16 +1536,23 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
1383
1536
  if (await tryRegisterWithLeader(ctx, state.lock, "follower-register-restore", initialBinding)) {
1384
1537
  return;
1385
1538
  }
1539
+ if (!isCurrent())
1540
+ return;
1386
1541
  deps.setLifecyclePhase("electing");
1387
1542
  safeUpdateStatus(ctx);
1388
1543
  deps.recordRuntimeEvent("bus", "Telegram follower waiting for leader reload recovery", { phase: "follower-promotion-grace" });
1389
1544
  await sleep(promotionGraceMs);
1545
+ if (!isCurrent())
1546
+ return;
1390
1547
  const graceState = deps.getLeaderState();
1391
1548
  if (graceState.kind === "active-elsewhere") {
1392
1549
  if (await tryRegisterWithLeader(ctx, graceState.lock, "follower-register-restore-grace", initialBinding)) {
1393
1550
  return;
1394
1551
  }
1395
- if (await deps.proveLeaderUnresponsive?.(graceState.lock)) {
1552
+ const unresponsive = await deps.proveLeaderUnresponsive?.(graceState.lock);
1553
+ if (!isCurrent())
1554
+ return;
1555
+ if (unresponsive) {
1396
1556
  await promoteToLeader(error, ctx, initialBinding, {
1397
1557
  expectedOwner: graceState.lock,
1398
1558
  unresponsive: true,
@@ -1415,6 +1575,8 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
1415
1575
  await attemptPreferredPromotion(error, ctx, initialBinding, state);
1416
1576
  }
1417
1577
  catch (promotionError) {
1578
+ if (!isCurrent())
1579
+ return;
1418
1580
  deps.setLifecyclePhase(undefined);
1419
1581
  safeUpdateStatus(ctx);
1420
1582
  if (isPiStaleContextError(promotionError)) {
@@ -1428,16 +1590,13 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
1428
1590
  });
1429
1591
  scheduleRecovery(promotionError, ctx, initialBinding);
1430
1592
  }
1431
- finally {
1432
- promotionPending = false;
1433
- }
1434
1593
  };
1435
- return Object.assign(recover, {
1594
+ return Object.assign((error, ctx) => requestRecovery({ reason: error, ctx }, { type: "recoverRequested" }), {
1436
1595
  halt() {
1437
- halted = true;
1596
+ machine.dispatch({ type: "halted" });
1438
1597
  },
1439
1598
  resume() {
1440
- halted = false;
1599
+ machine.dispatch({ type: "resumed" });
1441
1600
  },
1442
1601
  });
1443
1602
  }
@@ -1504,6 +1663,7 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
1504
1663
  const leaderSocketPath = activeLeaderSocketPath;
1505
1664
  const registrationGeneration = activeRegistrationGeneration;
1506
1665
  const heartbeatContext = activeContext;
1666
+ const heartbeatTarget = deps.registrationState?.getTarget();
1507
1667
  if (!leaderSocketPath || !registrationGeneration)
1508
1668
  return;
1509
1669
  const isCurrentHeartbeat = () => activeLeaderSocketPath === leaderSocketPath &&
@@ -1529,6 +1689,7 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
1529
1689
  : [];
1530
1690
  deps.registrationState?.setEligibleElectionSlots(slots);
1531
1691
  if (typeof heartbeatResult?.displayTitle === "string" &&
1692
+ isDeepStrictEqual(deps.registrationState?.getTarget(), heartbeatTarget) &&
1532
1693
  deps.registrationState?.setDisplayTitle(heartbeatResult.displayTitle, registrationGeneration) &&
1533
1694
  heartbeatContext) {
1534
1695
  try {
@@ -2040,12 +2201,13 @@ export function createTelegramBusFollowerPairedAdmission(deps) {
2040
2201
  };
2041
2202
  }
2042
2203
  /** One authenticated local operation request with the shared operation retry policy; no replay semantics. */
2043
- function sendTelegramBusOperation(socketPath, timeoutMs, envelope) {
2204
+ function sendTelegramBusOperation(socketPath, timeoutMs, envelope, assertAuthority) {
2044
2205
  return sendTelegramBusLocalEnvelope({
2045
2206
  socketPath,
2046
2207
  timeoutMs,
2047
2208
  envelope,
2048
- retry: getTelegramBusTransportRetryPolicy({
2209
+ assertAuthority,
2210
+ retry: assertAuthority ? { attempts: 1, delayMs: 0 } : getTelegramBusTransportRetryPolicy({
2049
2211
  endpoint: socketPath,
2050
2212
  operation: "operation",
2051
2213
  }),
@@ -2358,8 +2520,9 @@ export function createTelegramBusFollowerLiveRebindRuntime(deps) {
2358
2520
  record.settling ||
2359
2521
  !matchesLiveRebindCarrier(record.observation, envelope))
2360
2522
  throw new Error("Live-rebind settlement has no exact current carrier.");
2361
- if (envelope.mode === "observe") {
2362
- if (record.outcome?.status !== "released" ||
2523
+ if (envelope.mode === "observe" || envelope.mode === "observe-held") {
2524
+ const beforeRelease = envelope.mode === "observe-held";
2525
+ if ((beforeRelease ? !!record.outcome || !record.applyIssued || !record.held.confirmSaved() : record.outcome?.status !== "released") ||
2363
2526
  !deps.applyTarget ||
2364
2527
  !deps.observeWork ||
2365
2528
  !envelope.oldTarget)
@@ -2390,7 +2553,7 @@ export function createTelegramBusFollowerLiveRebindRuntime(deps) {
2390
2553
  if (!current() || !work)
2391
2554
  throw new Error("Live-rebind work observation is unconfirmed.");
2392
2555
  // Unfinished or unconfirmed selected execution stays unknown; later settled delivery cannot clear it.
2393
- if (commandWork && status.result !== "completed")
2556
+ if (!beforeRelease && commandWork && status.result !== "completed")
2394
2557
  work.unknown = true;
2395
2558
  if (!current())
2396
2559
  throw new Error("Live-rebind work observer authority changed.");
@@ -2439,9 +2602,11 @@ export function createTelegramBusFollowerLiveRebindRuntime(deps) {
2439
2602
  sourceUpdateIds: record.observation.sourceUpdateIds,
2440
2603
  isCurrent: current,
2441
2604
  ...copyLiveRebindCommandTarget(record.observation),
2442
- release(canRelease) {
2605
+ release(canRelease, target) {
2606
+ if (!target)
2607
+ throw new Error("Live-rebind release target is unconfirmed.");
2443
2608
  record.outcome = observation("unknown");
2444
- record.outcome = record.held.release(() => current() && canRelease())
2609
+ record.outcome = record.held.release(() => current() && canRelease(), target)
2445
2610
  ? observation("released")
2446
2611
  : undefined;
2447
2612
  },
@@ -2704,6 +2869,8 @@ export function createTelegramBusForwardedUpdateReceiverRuntime(deps) {
2704
2869
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SAVE,
2705
2870
  ...(apply ? [TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY] : []),
2706
2871
  ...(settle ? [TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE] : []),
2872
+ ...(envelope.kind === "leader.settleLiveRebind" && envelope.mode === "observe-held"
2873
+ ? [TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION] : []),
2707
2874
  ...(commandInput
2708
2875
  ? [
2709
2876
  TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY,