@llblab/pi-kit 0.5.0 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.5.1 - 2026-09-06
6
+
7
+ - `Telegram Continuity Hotfix`: Advances the exact Telegram pin to `0.43.1`, bringing bounded persistent polling-conflict stand-down with accepted queue preservation, missing/null system-prompt normalization, and authority-fenced preview cleanup without changing the resource inventory.
8
+
5
9
  ## 0.5.0 - 2026-09-05
6
10
 
7
11
  - `Telegram Native Controls`: Advances the exact Telegram pin to `0.43.0`, bringing in-body button blocks, shared JSON/CML disabled controls, HTML footer fallback, and updated agent Skills while preserving package-owned implementations and the existing resource inventory.
package/README.md CHANGED
@@ -15,7 +15,7 @@ Package links lead to the owning repositories for usage, documentation, issues,
15
15
  | [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.9.4` | Compact Codex/Spark subscription-limit status |
16
16
  | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.7.4` | Visible continuation scheduling and bounded worker Skills |
17
17
  | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.3.0` | Opt-in validated state handoffs between agent runs |
18
- | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.43.0` | Telegram companion, queues, files, voice, controls, and Generative Apps guidance |
18
+ | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.43.1` | Telegram companion, queues, files, voice, controls, and Generative Apps guidance |
19
19
  | [`@llblab/skills`](https://github.com/llblab/skills) | `1.14.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
20
20
 
21
21
  Versions are exact by design. An upstream release does not change an installed kit until this repository explicitly advances the dependency and publishes a new kit version. Runtime defects and package-specific feature requests belong in the linked repository; package selection and kit installation issues belong here.
@@ -2,6 +2,11 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.43.1: Transport And Preview Continuity
6
+
7
+ - `Conflict Stand-Down`: Stops transport after ten consecutive competing getUpdates conflicts, including ownership checks, heartbeat, monitoring, and bus teardown. Releases only the exact local lock and revokes direct authority even if release fails, while preserving accepted local queue work. A persistent terminal status and one diagnostic distinguish lost ownership from a competing external client; cancelled reconnects and stale admission cannot supersede current lifecycle work.
8
+ - `Prompt And Preview Continuity`: Treats absent/null host system prompts as empty while retaining Telegram guidance, clears completed text from preview state after successful delivery, and rejects stale session/transport completions before they can clear a replacement preview. Receipt-conflict diagnostics now include the receipt and source update ids; durable handoff verification remains fail-closed.
9
+
5
10
  ## 0.43.0: Native Button Blocks
6
11
 
7
12
  - `In-Body Controls`: Renders standalone `telegram_button` fences as native button rows between paragraphs using the same singleton/mixed JSON/CML grammar and callback ownership as footer comments. Invalid/incomplete blocks register nothing, previews hide action payloads, literal examples remain inert, and HTML mode moves controls to the footer. In-body clicks acknowledge without recoloring; operator-confirmed current-client smoke passed; cross-client/follower checks remain pending.
@@ -59,6 +59,8 @@ The connected Pi instance owns Telegram polling. Use `/telegram-connect <name>`
59
59
 
60
60
  After an unclean computer shutdown, `/telegram-connect` detects truncated or structurally invalid temporary ownership/routing files, quarantines only the damaged files under `tmp/telegram/recovery/`, and retries once. A journal snapshot removed by older broad temp cleanup is rebuilt when its complete segment history proves an empty result, while a revisionless snapshot is repaired from the first surviving segment's exact predecessor when the reconstructed tail validates. Otherwise the snapshot and segments are quarantined as recovery evidence, a fresh journal is published, and startup continues with an informational diagnostic instead of requiring manual JSON repair. Saved `telegram.json` configuration and runtime diagnostics remain intact. Recovery never replaces a verifiable live owner; if safe automatic recovery cannot complete, the command gives one explicit Pi-restart instruction instead of requiring deletion of the whole `tmp/` directory.
61
61
 
62
+ Persistent competing `getUpdates` clients cause a bounded transport stand-down rather than endless retries. Accepted local work remains queued/executable, but Telegram delivery stops. Inspect `/telegram-status --debug`, stop the competing client, then reconnect. See [Runtime Ownership](./docs/architecture.md#runtime-ownership).
63
+
62
64
  ### 4. Pair your Telegram account
63
65
 
64
66
  Open the bot DM and send:
@@ -92,7 +92,7 @@ The repository uses a **Flat Domain DAG**:
92
92
 
93
93
  Pi is the primary and only officially supported host. `pi-telegram` may still accept narrow, host-neutral representation differences at its existing Pi-facing boundary when they preserve native Pi behavior and do not create a second runtime policy layer:
94
94
 
95
- - `prompts` preserves either Pi's plain system-prompt string or an ordered block array supplied by a compatible host, appending Telegram guidance without collapsing host-owned blocks.
95
+ - `prompts` preserves either Pi's plain system-prompt string or an ordered block array supplied by a compatible host, appending Telegram guidance without collapsing host-owned blocks. An absent/null host system prompt is treated as empty, including the disconnected metadata-stripping path.
96
96
  - `pi` normalizes settings-manager construction that is either synchronous or asynchronous, then adapts either Pi's legacy enabled-model methods or a generic `get` / `set` settings service before model-menu reads and scoped-model persistence use it. Hosts without an explicit reload method rely on fresh asynchronous construction; durable writes still require `flush`.
97
97
  - `lifecycle` continues to require Pi's semantic `agent_settled` boundary. It does not infer terminal settlement from host-specific `agent_end`, retry, or stop events; a compatibility shim must reproduce that contract before it can safely support activity identity and unrecovered-error finalization.
98
98
 
@@ -135,6 +135,8 @@ Telegram configuration lives in `~/.pi/agent/telegram.json`. Bot/session identit
135
135
  - `/telegram-connect` acquires or moves the active profile's owner slot before polling starts. `/telegram-disconnect` keeps its destructive confirmation, then stops polling and releases only that exact slot. In Threaded Mode it tears down the disconnecting instance's bound Telegram thread: leaders delete their own thread directly, and followers send an authenticated exact-generation disconnect envelope and wait for confirmed leader cleanup before unregistering. Graceful Pi `quit` always preserves the owner slot as restart intent, allowing a reopened same-`cwd` session to reclaim the stale lease. When `threads.automaticCleanup` is enabled (the default), quit also deletes the bound Telegram tab without releasing that restart intent; disabling it preserves the tab through replacement-style suspension. Failed automatic cleanup records diagnostics and falls back to safe suspension so remaining lifecycle cleanup still runs.
136
136
  - Session start schedules polling resume asynchronously only when the owner slot already points at the current `pid`/`cwd`, or when a stale same-`cwd` owner can be safely replaced after process restart. Startup and `/resume` do not wait on leader election, Bot API probes, poller handoff, or thread reconciliation before restoring the Pi session.
137
137
  - The polling owner alone bounds `getUpdates`: each request derives its cancellation budget from Telegram's declared long-poll timeout plus 10 seconds of transport grace (10 seconds for the zero-timeout initial sync and 40 seconds for the normal 30-second poll). The request-local controller inherits poller cancellation, rejects its owner at the budget, and fences any late transport result. Ordinary Bot API and media operations do not receive speculative blanket deadlines. Existing caller signals remain authoritative through API retry waits, only retry-safe methods replay explicit retryable responses, and non-idempotent sends preserve commit-unknown evidence instead of risking duplicate mutation.
138
+ - Ten consecutive `getUpdates` conflict responses, including initial cursor sync, terminate polling with `persistent-conflict`; a successful response or a different error resets the count. The controller detaches its inner promise before notifying the locked lifecycle, avoiding teardown waiting on itself. That lifecycle stops ownership checks, lease refresh, capability monitoring, typing, and classic or bus transport (including leader health, pruning, and IPC), withdraws local direct authority, and transactionally releases only its exact lock. A failed durable release leaves local authority revoked; explicit reacquisition mints a fresh epoch. Accepted queue receipts and local Pi dispatch survive. One terminal diagnostic distinguishes lost local ownership from a competing client despite an apparently owned lock and reports cleanup failures; ordinary status refreshes retain generic `error` until transport recovers. Remove the competing client, then use `/telegram-connect`; no automatic retry continues after the terminal threshold.
139
+ - Manual and automatic polling starts share a lifecycle generation. A later suspend, disconnect, persistent-conflict stop, or accepted start invalidates older startup continuations; reconnect captures its generation before waiting for transport teardown and rechecks it before acquiring ownership and after awaited startup work. Obsolete completion or failure cannot report a successful connection or roll back a replacement. Admission rejects stale/unauthorized contexts before advancing its own startup generation, so a rejected call cannot cancel valid in-flight initialization. After awaited bus startup, thread-aware completion checks its generation before starting leader health or changing fallback/startup-option state; teardown clears health independently of the current mode flag. Startup probes, capability-monitor transitions, and observed-target transitions share an orchestration lifecycle fence and check it inside their effect-owning helpers after awaited queries, persistence, or transport work. Monitor stop also invalidates pending observations. These checks suppress subsequent state changes, fallback, health, and status effects; already-issued API/persistence calls retain their own transport/storage fencing.
138
140
  - `pollingActive` reports only whether this runtime still owns an unresolved polling lifecycle; it is not health evidence. A separate observable state records `starting`, `long-poll`, `persisting-journal`, `persisting-offset`, `retrying`, or `stopped`, together with phase start, current update id, last successful response time/count, and terminal stop reason. This distinguishes a stuck HTTP poll from downstream update work without a wall-clock stale heuristic.
139
141
  - Built-in read-only menu commands return after required local mutation and schedule context-fenced rendering and command synchronization independently, so those effects cannot withhold the next inbound offset.
140
142
  - Pi `print`/`json` run modes stay passive. Inherited child sessions that share `telegram.json` but do not own the exact `pid`/`cwd` slot must not poll or call `getUpdates` unless the operator force-takes ownership.
@@ -339,7 +341,7 @@ Assistant delivery guarantees:
339
341
  - Quoted rich replies use Telegram `rich_message` blocks as the prompt-context source when available, so `[reply]` context receives rendered plain text instead of raw `InputRichMessage.markdown` fallback text. Replied media runs through the same inbound handlers and voice transcription providers as current-message media, with provenance-scoped `[outputs|from:…]` appended inside the reply block.
340
342
  - Long native Markdown replies are split only at Telegram Rich Message transport limits; oversized fenced code, display-math, and fully wrapped inline-formatting blocks are rewrapped per chunk so persisted Rich Markdown chunks remain structurally valid.
341
343
  - When Draft previews are enabled, streaming previews pass structurally closed assistant Markdown prefixes through to `sendRichMessageDraft` with ownership checks, voice suppression, and serialized flushes. Unclosed inline spans, links, fenced code, comments, and display-math blocks are held back until a safe boundary exists. Draft failures are recorded and the failing frame is skipped instead of degrading to raw plain-message previews, because partial Markdown can be invalid while the final message remains valid.
342
- - Preview flushes are serialized so older edits cannot race newer drafts; final delivery waits for active draft flushes and does not perform a post-final draft-clear call.
344
+ - Preview flushes are serialized so older edits cannot race newer drafts; final delivery waits for active draft flushes and does not perform a post-final draft-clear call. Successful final text delivery clears the local pending preview text only while the captured session and transport remain active, so a late delivery or Rich-attachment cleanup cannot erase replacement preview state.
343
345
 
344
346
  UI/compat rendering guarantees:
345
347
 
@@ -434,7 +434,7 @@ Follower Restore requires exact registration generation and expected old target
434
434
 
435
435
  - Two leaders calling `getUpdates` is the main safety failure.
436
436
  - Lock heartbeat/takeover must be atomic enough to prevent this under normal local concurrency.
437
- - If Telegram returns API conflict behavior, record diagnostics and force one leader to step down.
437
+ - Persistent competing `getUpdates` clients trigger a full transport stand-down even when the local lock still appears owned; the stopped runtime preserves accepted local work and releases only its exact ownership. See [Runtime Ownership](./architecture.md#runtime-ownership) for the threshold, diagnostic, and reconnect contract.
438
438
 
439
439
  ## Security Boundaries
440
440
 
@@ -1034,6 +1034,12 @@ export default function (pi: Pi.ExtensionAPI) {
1034
1034
  Pi.ExtensionContext
1035
1035
  >({
1036
1036
  state: pollingControllerState,
1037
+ canStart(ctx) {
1038
+ return telegramSessionContextStore.isCurrent(ctx) && lockRuntime.owns(ctx);
1039
+ },
1040
+ onPersistentConflict(ctx, count): Promise<void> {
1041
+ return lockedPollingRuntime.onPersistentConflict(ctx, count);
1042
+ },
1037
1043
  getConfig: configStore.get,
1038
1044
  hasBotToken: configStore.hasBotToken,
1039
1045
  deleteWebhook,
@@ -1205,8 +1211,10 @@ export default function (pi: Pi.ExtensionAPI) {
1205
1211
  const threadAwarePollingPorts = telegramThreadCapabilityRuntime.pollingPorts;
1206
1212
  const lockedPollingRuntime = Locks.createTelegramLockedPollingRuntime({
1207
1213
  lock: lockRuntime,
1214
+ transportMonitor: telegramThreadCapabilityMonitor,
1208
1215
  hasBotToken: configStore.hasBotToken,
1209
1216
  canStartPolling: Pi.canStartPollingInExtensionContext,
1217
+ isContextCurrent: telegramSessionContextStore.isCurrent,
1210
1218
  formatStartBlockedMessage: Pi.formatPollingStartBlockedByRunMode,
1211
1219
  startPolling: threadAwarePollingPorts.startPolling,
1212
1220
  stopPolling: threadAwarePollingPorts.stopPolling,
@@ -871,6 +871,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
871
871
  options.runtimeGeneration ?? allocateTelegramLockRuntimeGeneration();
872
872
  let ownedLockKey: string | undefined;
873
873
  let ownedLock: TelegramLockEntry | undefined;
874
+ let deliveryRevoked = false;
874
875
  const stateOptions = () => ({
875
876
  nowMs: getNowMs(),
876
877
  staleHeartbeatMs: options.staleHeartbeatMs,
@@ -917,7 +918,8 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
917
918
  );
918
919
  if (
919
920
  state.kind === "active-here" &&
920
- hasSameLockOwner(current, expectedOwned)
921
+ hasSameLockOwner(current, expectedOwned) &&
922
+ !deliveryRevoked
921
923
  ) {
922
924
  return {
923
925
  result: {
@@ -958,6 +960,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
958
960
  if (
959
961
  !acquireOptions.election &&
960
962
  (state.kind === "active-here" || state.kind === "active-elsewhere") &&
963
+ !(deliveryRevoked && hasSameLockOwner(current, expectedOwned)) &&
961
964
  (!acquireOptions.force ||
962
965
  !expectedReplacementMatches ||
963
966
  !canReplaceCurrent)
@@ -978,6 +981,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
978
981
  locks[effectiveKey] = lock;
979
982
  ownedLockKey = effectiveKey;
980
983
  ownedLock = lock;
984
+ deliveryRevoked = false;
981
985
  return {
982
986
  result: {
983
987
  ok: true,
@@ -987,8 +991,10 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
987
991
  changed: true,
988
992
  };
989
993
  }),
990
- release: () =>
991
- withLockTransaction(locksPath, (locks) => {
994
+ release: () => {
995
+ // Withdraw local send authority even if the durable release fails.
996
+ deliveryRevoked = true;
997
+ return withLockTransaction(locksPath, (locks) => {
992
998
  const effectiveKey = resolveEffectiveKey();
993
999
  const state = getLockState(
994
1000
  parseTelegramLockEntry(locks[effectiveKey]),
@@ -1008,17 +1014,20 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1008
1014
  ownedLock = undefined;
1009
1015
  }
1010
1016
  return { result: state, changed };
1011
- }),
1017
+ });
1018
+ },
1012
1019
  getState: () => getLockState(readLock(), pid, isAlive, stateOptions()),
1013
1020
  getStatusLabel: () =>
1014
1021
  formatLockState(getLockState(readLock(), pid, isAlive, stateOptions())),
1015
1022
  getOwnedLeaderEpoch: () => {
1023
+ if (deliveryRevoked) return undefined;
1016
1024
  const effectiveKey = resolveEffectiveKey();
1017
1025
  const lock = parseTelegramLockEntry(readLocks(locksPath)[effectiveKey]);
1018
1026
  const exactOwner = adoptCompatibleOwnedLock(effectiveKey, lock);
1019
1027
  return hasSameLockOwner(lock, exactOwner) ? lock?.leaderEpoch : undefined;
1020
1028
  },
1021
1029
  owns: (ctx) => {
1030
+ if (deliveryRevoked) return false;
1022
1031
  const effectiveKey = resolveEffectiveKey();
1023
1032
  const lock = parseTelegramLockEntry(readLocks(locksPath)[effectiveKey]);
1024
1033
  return hasSameLockOwner(
@@ -1027,7 +1036,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1027
1036
  );
1028
1037
  },
1029
1038
  commitIfOwned: (commit) =>
1030
- withLockTransaction(locksPath, (locks) => {
1039
+ !deliveryRevoked && withLockTransaction(locksPath, (locks) => {
1031
1040
  const effectiveKey = resolveEffectiveKey();
1032
1041
  const lock = parseTelegramLockEntry(locks[effectiveKey]);
1033
1042
  const exactOwner =
@@ -1043,7 +1052,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1043
1052
  return { result: true, changed: false };
1044
1053
  }),
1045
1054
  refresh: (ctx) =>
1046
- withLockTransaction(locksPath, (locks) => {
1055
+ !deliveryRevoked && withLockTransaction(locksPath, (locks) => {
1047
1056
  const effectiveKey = resolveEffectiveKey();
1048
1057
  const lock = parseTelegramLockEntry(locks[effectiveKey]);
1049
1058
  const expectedOwner = adoptCompatibleOwnedLock(effectiveKey, lock, ctx);
@@ -1116,6 +1125,7 @@ export interface TelegramLockedPollingRuntime<
1116
1125
  ) => Promise<TelegramLockedPollingStartResult>;
1117
1126
  stop: () => Promise<string>;
1118
1127
  suspend: () => Promise<void>;
1128
+ onPersistentConflict: (ctx: TContext, count: number) => Promise<void>;
1119
1129
  onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
1120
1130
  registerFollowerWithOwner?: (
1121
1131
  ctx: TContext,
@@ -1130,6 +1140,7 @@ export interface TelegramLockedPollingRuntimeDeps<
1130
1140
  lock: TelegramLockRuntime<TContext>;
1131
1141
  hasBotToken: () => boolean;
1132
1142
  canStartPolling?: (ctx: TContext) => boolean;
1143
+ isContextCurrent?: (ctx: TContext) => boolean;
1133
1144
  formatStartBlockedMessage?: (ctx: TContext) => string;
1134
1145
  startPolling: (
1135
1146
  ctx: TContext,
@@ -1142,6 +1153,7 @@ export interface TelegramLockedPollingRuntimeDeps<
1142
1153
  ) => boolean | undefined | Promise<boolean | undefined>;
1143
1154
  stopFollowerRegistration?: () => void;
1144
1155
  onTransportAvailabilityChanged?: () => void;
1156
+ transportMonitor?: { start: (ctx: TContext) => void; stop: () => void };
1145
1157
  updateStatus: (ctx: TContext) => void;
1146
1158
  recordRuntimeEvent?: (
1147
1159
  category: string,
@@ -1164,9 +1176,10 @@ export function createTelegramLockedPollingRuntime<
1164
1176
  let ownershipCheckInterval: ReturnType<typeof setInterval> | undefined;
1165
1177
  let ownershipRefreshInterval: ReturnType<typeof setInterval> | undefined;
1166
1178
  let ownershipStop: Promise<void> | undefined;
1179
+ let activeContext: TContext | undefined;
1167
1180
  let takeoverCandidate: TelegramLockEntry | undefined;
1168
1181
  let sessionAutoStartRun: Promise<void> | undefined;
1169
- let sessionAutoStartGeneration = 0;
1182
+ let pollingGeneration = 0;
1170
1183
  const ownershipCheckMs =
1171
1184
  deps.ownershipCheckMs ?? TELEGRAM_OWNERSHIP_CHECK_MS;
1172
1185
  const ownershipRefreshMs =
@@ -1178,7 +1191,9 @@ export function createTelegramLockedPollingRuntime<
1178
1191
  ownershipRefreshInterval = undefined;
1179
1192
  };
1180
1193
  const suspendPolling = async () => {
1181
- sessionAutoStartGeneration += 1;
1194
+ pollingGeneration += 1;
1195
+ activeContext = undefined;
1196
+ deps.transportMonitor?.stop();
1182
1197
  deps.stopFollowerRegistration?.();
1183
1198
  stopOwnershipWatcher();
1184
1199
  if (sessionAutoStartRun) {
@@ -1192,6 +1207,8 @@ export function createTelegramLockedPollingRuntime<
1192
1207
  };
1193
1208
  const stopAfterOwnershipLoss = () => {
1194
1209
  if (ownershipStop) return;
1210
+ activeContext = undefined;
1211
+ deps.transportMonitor?.stop();
1195
1212
  stopOwnershipWatcher();
1196
1213
  deps.onTransportAvailabilityChanged?.();
1197
1214
  ownershipStop = deps
@@ -1228,7 +1245,10 @@ export function createTelegramLockedPollingRuntime<
1228
1245
  const runOwnedPollingStart = async (
1229
1246
  ctx: TContext,
1230
1247
  options: TelegramLockedPollingStartOptions,
1248
+ isCurrent: () => boolean,
1231
1249
  ): Promise<boolean> => {
1250
+ if (!isCurrent()) return false;
1251
+ activeContext = ctx;
1232
1252
  startOwnershipWatcher(ctx);
1233
1253
  try {
1234
1254
  if (!deps.lock.refresh(snapshotLockContext(ctx))) {
@@ -1236,8 +1256,10 @@ export function createTelegramLockedPollingRuntime<
1236
1256
  return false;
1237
1257
  }
1238
1258
  await options.onAcquired?.();
1259
+ if (!isCurrent()) return false;
1239
1260
  await deps.startPolling(ctx, options);
1240
1261
  } catch (error) {
1262
+ if (!isCurrent()) return false;
1241
1263
  stopOwnershipWatcher();
1242
1264
  try {
1243
1265
  await deps.stopPolling();
@@ -1246,14 +1268,22 @@ export function createTelegramLockedPollingRuntime<
1246
1268
  phase: "startup-rollback",
1247
1269
  });
1248
1270
  }
1271
+ if (!isCurrent()) return false;
1249
1272
  deps.lock.release();
1250
1273
  deps.onTransportAvailabilityChanged?.();
1251
1274
  throw error;
1252
1275
  }
1253
- if (deps.lock.owns(ctx)) return true;
1276
+ if (!isCurrent()) return false;
1277
+ if (deps.lock.owns(ctx)) {
1278
+ if (activeContext !== ctx) return false;
1279
+ deps.transportMonitor?.start(ctx);
1280
+ return true;
1281
+ }
1254
1282
  stopOwnershipWatcher();
1255
1283
  if (ownershipStop) await ownershipStop;
1284
+ if (!isCurrent()) return false;
1256
1285
  await deps.stopPolling();
1286
+ if (!isCurrent()) return false;
1257
1287
  deps.onTransportAvailabilityChanged?.();
1258
1288
  return false;
1259
1289
  };
@@ -1270,6 +1300,17 @@ export function createTelegramLockedPollingRuntime<
1270
1300
  if (!canStartPolling(ctx)) {
1271
1301
  return { ok: false, message: formatStartBlockedMessage(ctx) };
1272
1302
  }
1303
+ const cancelled = {
1304
+ ok: false as const,
1305
+ canTakeover: false as const,
1306
+ message: "Telegram polling startup was cancelled or superseded.",
1307
+ };
1308
+ if (deps.isContextCurrent?.(ctx) === false) return cancelled;
1309
+ const generation = ++pollingGeneration;
1310
+ const isCurrent = () => generation === pollingGeneration &&
1311
+ (deps.isContextCurrent?.(ctx) ?? true);
1312
+ if (ownershipStop) await ownershipStop;
1313
+ if (!isCurrent()) return cancelled;
1273
1314
  let acquired = deps.lock.acquire(ctx, {
1274
1315
  force: options.force,
1275
1316
  expectedOwner:
@@ -1306,6 +1347,7 @@ export function createTelegramLockedPollingRuntime<
1306
1347
  ctx,
1307
1348
  acquired.lock,
1308
1349
  );
1350
+ if (!isCurrent()) return cancelled;
1309
1351
  if (registered) {
1310
1352
  deps.updateStatus(ctx);
1311
1353
  return { ok: true, canTakeover: false };
@@ -1337,13 +1379,15 @@ export function createTelegramLockedPollingRuntime<
1337
1379
  };
1338
1380
  }
1339
1381
  takeoverCandidate = undefined;
1340
- if (!(await runOwnedPollingStart(ctx, options))) {
1382
+ if (!(await runOwnedPollingStart(ctx, options, isCurrent))) {
1383
+ if (!isCurrent()) return cancelled;
1341
1384
  return {
1342
1385
  ok: false,
1343
1386
  canTakeover: false,
1344
1387
  message: "Telegram leadership changed during polling startup.",
1345
1388
  };
1346
1389
  }
1390
+ if (!isCurrent()) return cancelled;
1347
1391
  deps.onTransportAvailabilityChanged?.();
1348
1392
  deps.updateStatus(ctx);
1349
1393
  const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
@@ -1362,6 +1406,42 @@ export function createTelegramLockedPollingRuntime<
1362
1406
  return "Telegram bridge disconnected.";
1363
1407
  },
1364
1408
  suspend: suspendPolling,
1409
+ onPersistentConflict: async (ctx, count) => {
1410
+ if (activeContext === undefined || ownershipStop) return;
1411
+ if (!(deps.isContextCurrent?.(ctx) ?? activeContext === ctx)) return;
1412
+ activeContext = undefined;
1413
+ pollingGeneration += 1;
1414
+ stopOwnershipWatcher();
1415
+ deps.transportMonitor?.stop();
1416
+ let ownership = "unverifiable";
1417
+ const cleanupErrors: string[] = [];
1418
+ try {
1419
+ ownership = deps.lock.owns(snapshotLockContext(ctx)) ? "owned" : "lost";
1420
+ } catch (error) {
1421
+ cleanupErrors.push(String(error));
1422
+ }
1423
+ try {
1424
+ deps.lock.release();
1425
+ } catch (error) {
1426
+ ownership = "unverifiable";
1427
+ cleanupErrors.push(String(error));
1428
+ }
1429
+ ownershipStop = Promise.resolve()
1430
+ .then(() => deps.stopPolling())
1431
+ .catch((error) => { cleanupErrors.push(String(error)); })
1432
+ .finally(() => {
1433
+ ownershipStop = undefined;
1434
+ deps.recordRuntimeEvent?.("polling", ownership === "lost"
1435
+ ? "Telegram transport stopped: local ownership lost; check for another Pi instance."
1436
+ : "Telegram transport stopped: competing getUpdates client or ownership mismatch.", {
1437
+ phase: "persistent-conflict", count, ownership,
1438
+ ...(cleanupErrors.length ? { cleanupErrors } : {}),
1439
+ });
1440
+ deps.updateStatus(ctx);
1441
+ });
1442
+ deps.onTransportAvailabilityChanged?.();
1443
+ await ownershipStop;
1444
+ },
1365
1445
  onSessionStart: async (_event, ctx) => {
1366
1446
  if (!deps.hasBotToken()) return;
1367
1447
  if (!canStartPolling(ctx)) return;
@@ -1379,15 +1459,18 @@ export function createTelegramLockedPollingRuntime<
1379
1459
  ) {
1380
1460
  return;
1381
1461
  }
1382
- sessionAutoStartGeneration += 1;
1383
- const generation = sessionAutoStartGeneration;
1462
+ if (deps.isContextCurrent?.(ctx) === false) return;
1463
+ const generation = ++pollingGeneration;
1464
+ const isCurrent = () => generation === pollingGeneration &&
1465
+ (deps.isContextCurrent?.(ctx) ?? true);
1384
1466
  const startedAtMs = Date.now();
1385
1467
  deps.recordRuntimeEvent?.("lock", "Telegram auto-start scheduled", {
1386
1468
  phase: "auto-start-scheduled",
1387
1469
  });
1388
1470
  const run = (async () => {
1389
1471
  await new Promise((resolve) => setTimeout(resolve, 0));
1390
- if (generation !== sessionAutoStartGeneration) return;
1472
+ if (ownershipStop) await ownershipStop;
1473
+ if (!isCurrent()) return;
1391
1474
  if (canResumeStaleSameCwd || canHandoffSameProcess) {
1392
1475
  const acquired = deps.lock.acquire(
1393
1476
  ctx,
@@ -1397,9 +1480,9 @@ export function createTelegramLockedPollingRuntime<
1397
1480
  );
1398
1481
  if (!acquired.ok) return;
1399
1482
  }
1400
- if (generation !== sessionAutoStartGeneration) return;
1401
- if (!(await runOwnedPollingStart(ctx, {}))) return;
1402
- if (generation !== sessionAutoStartGeneration) return;
1483
+ if (!isCurrent()) return;
1484
+ if (!(await runOwnedPollingStart(ctx, {}, isCurrent))) return;
1485
+ if (!isCurrent()) return;
1403
1486
  deps.onTransportAvailabilityChanged?.();
1404
1487
  deps.updateStatus(ctx);
1405
1488
  deps.recordRuntimeEvent?.("lock", "Telegram auto-start completed", {
@@ -21,6 +21,7 @@ const TELEGRAM_LONG_POLL_LIMIT = 10;
21
21
  const TELEGRAM_LONG_POLL_TIMEOUT_SECONDS = 30;
22
22
  const TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS = 2_500;
23
23
  const TELEGRAM_THREAD_CAPABILITY_DISABLED_CONFIRMATION_PROBES = 2;
24
+ export const TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT = 10;
24
25
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
25
26
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
26
27
  const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
@@ -69,6 +70,15 @@ export function getLatestTelegramUpdateId(
69
70
  return updates.at(-1)?.update_id;
70
71
  }
71
72
 
73
+ export class TelegramPersistentGetUpdatesConflictError extends Error {
74
+ readonly count: number;
75
+ constructor(count: number) {
76
+ super(`Telegram polling stopped after ${count} consecutive getUpdates conflicts.`);
77
+ this.name = "TelegramPersistentGetUpdatesConflictError";
78
+ this.count = count;
79
+ }
80
+ }
81
+
72
82
  export class TelegramGetUpdatesTimeoutError extends Error {
73
83
  readonly timeoutMs: number;
74
84
 
@@ -126,7 +136,8 @@ export type TelegramPollingStopReason =
126
136
  | "not-started"
127
137
  | "requested"
128
138
  | "completed"
129
- | "failed";
139
+ | "failed"
140
+ | "persistent-conflict";
130
141
 
131
142
  export interface TelegramPollingStateSnapshot {
132
143
  phase: TelegramPollingPhase;
@@ -200,6 +211,7 @@ export interface TelegramPollingRuntimeDeps<
200
211
  createAbortController?: () => AbortController;
201
212
  getNowMs?: () => number;
202
213
  onPollingStateChange?: () => void;
214
+ onPersistentConflict?: (ctx: TContext, count: number) => MaybePromise<void>;
203
215
  onPollingStarted?: () => void;
204
216
  onPollingStopped?: (reason: TelegramPollingStopReason) => void;
205
217
  }
@@ -227,20 +239,31 @@ export interface TelegramPollingAdmissionRuntime<TContext> {
227
239
  export function createTelegramPollingAdmissionRuntime<TContext>(deps: {
228
240
  polling: TelegramPollingController<TContext>;
229
241
  prepareStart?: () => MaybePromise<void>;
242
+ canStart?: (ctx: TContext) => boolean;
230
243
  validateStart?: () => void;
231
244
  worker: {
232
245
  onSessionStart: (ctx: TContext) => Promise<void>;
233
246
  };
234
247
  }): TelegramPollingAdmissionRuntime<TContext> {
248
+ let generation = 0;
235
249
  return {
236
250
  isActive: deps.polling.isActive,
237
251
  async start(ctx) {
252
+ if (!(deps.canStart?.(ctx) ?? true)) return;
253
+ const expectedGeneration = ++generation;
254
+ const isCurrent = () => expectedGeneration === generation && (deps.canStart?.(ctx) ?? true);
255
+ if (!isCurrent()) return;
238
256
  await deps.prepareStart?.();
257
+ if (!isCurrent()) return;
239
258
  deps.validateStart?.();
240
259
  await deps.worker.onSessionStart(ctx);
241
- await deps.polling.start(ctx);
260
+ if (!isCurrent()) return;
261
+ deps.polling.start(ctx);
262
+ },
263
+ async stop() {
264
+ generation += 1;
265
+ await deps.polling.stop();
242
266
  },
243
- stop: deps.polling.stop,
244
267
  };
245
268
  }
246
269
 
@@ -256,6 +279,7 @@ export type TelegramDurablePollingRuntimeAssemblyDeps<
256
279
  TelegramPollingControllerRuntimeDeps<TUpdate, TContext>,
257
280
  "appendUpdateBatch" | "getJournalEntryCount" | "signalUpdateWorker"
258
281
  > & {
282
+ canStart?: (ctx: TContext) => boolean;
259
283
  journal: {
260
284
  appendBatch: (
261
285
  updates: readonly TUpdate[],
@@ -287,6 +311,7 @@ export function createTelegramDurablePollingRuntimeAssembly<
287
311
  const admission = createTelegramPollingAdmissionRuntime({
288
312
  polling: controller,
289
313
  prepareStart: deps.journal.prepareCursorCutover,
314
+ canStart: deps.canStart,
290
315
  validateStart() {
291
316
  if (deps.journal.getAcceptedThroughUpdateId() !== undefined) return;
292
317
  if (deps.journal.getBootstrapEntryCount() === 0) return;
@@ -312,6 +337,7 @@ export type TelegramPollingControllerRuntimeDeps<
312
337
  createAbortController?: () => AbortController;
313
338
  getNowMs?: () => number;
314
339
  onPollingStateChange?: () => void;
340
+ onPersistentConflict?: (ctx: TContext, count: number) => MaybePromise<void>;
315
341
  };
316
342
 
317
343
  function notifyTelegramPollingStateChange(
@@ -390,6 +416,7 @@ export function createTelegramPollingControllerRuntime<
390
416
  createAbortController: deps.createAbortController,
391
417
  getNowMs,
392
418
  onPollingStateChange: deps.onPollingStateChange,
419
+ onPersistentConflict: deps.onPersistentConflict,
393
420
  recordRuntimeEvent: deps.recordRuntimeEvent,
394
421
  });
395
422
  }
@@ -503,6 +530,7 @@ export function startTelegramPollingRuntime<TContext>(
503
530
  deps.setPollingController(controller);
504
531
  deps.onPollingStarted?.();
505
532
  let failed = false;
533
+ let persistentConflict: TelegramPersistentGetUpdatesConflictError | undefined;
506
534
  let runPromise: Promise<void>;
507
535
  try {
508
536
  runPromise = deps.runPollLoop(ctx, controller.signal);
@@ -513,24 +541,40 @@ export function startTelegramPollingRuntime<TContext>(
513
541
  promise = runPromise
514
542
  .catch((error) => {
515
543
  if (shouldStopTelegramPolling(controller.signal.aborted, error)) return;
544
+ if (error instanceof TelegramPersistentGetUpdatesConflictError) {
545
+ persistentConflict = error;
546
+ return;
547
+ }
516
548
  failed = true;
517
549
  deps.recordRuntimeEvent?.("polling", error, {
518
550
  phase: "controller",
519
551
  });
520
552
  })
521
- .finally(() => {
553
+ .finally(async () => {
522
554
  const ownsPromise = deps.getPollingPromise() === promise;
523
555
  const ownsController = deps.getPollingController() === controller;
524
556
  if (ownsPromise) deps.setPollingPromise(undefined);
525
557
  if (ownsController) deps.setPollingController(undefined);
526
- if (ownsPromise || ownsController) {
527
- deps.onPollingStopped?.(
528
- failed
529
- ? "failed"
530
- : controller.signal.aborted
531
- ? "requested"
532
- : "completed",
533
- );
558
+ if (!ownsPromise && !ownsController) return;
559
+ deps.onPollingStopped?.(
560
+ controller.signal.aborted ? "requested" : persistentConflict
561
+ ? "persistent-conflict" : failed ? "failed" : "completed",
562
+ );
563
+ // Detach the inner promise before outer teardown calls polling.stop().
564
+ if (persistentConflict && !controller.signal.aborted) {
565
+ try {
566
+ if (deps.onPersistentConflict) {
567
+ await deps.onPersistentConflict(ctx, persistentConflict.count);
568
+ } else {
569
+ deps.stopTypingLoop();
570
+ deps.recordRuntimeEvent?.("polling", persistentConflict, {
571
+ phase: "persistent-conflict", count: persistentConflict.count,
572
+ });
573
+ }
574
+ } catch (error) {
575
+ deps.recordRuntimeEvent?.("polling", error, { phase: "conflict-stand-down" });
576
+ }
577
+ if (deps.getPollingController() || deps.getPollingPromise()) return;
534
578
  }
535
579
  updateTelegramPollingStatusSafely(deps.updateStatus, ctx, {
536
580
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -581,6 +625,11 @@ export interface TelegramThreadCapabilityReaderDeps {
581
625
  ) => Promise<TResponse>;
582
626
  }
583
627
 
628
+ interface TelegramThreadCapabilityLifecycle {
629
+ capture: () => () => boolean;
630
+ invalidate: () => void;
631
+ }
632
+
584
633
  export interface TelegramStartupThreadCapabilityProbeDeps extends TelegramThreadCapabilityReaderDeps {
585
634
  topicTargetStore: TelegramThreadCapabilityStore;
586
635
  recordEvent: (
@@ -595,6 +644,7 @@ export interface TelegramStartupThreadCapabilityProbeDeps extends TelegramThread
595
644
  export interface TelegramThreadCapabilityRuntimeDeps<
596
645
  TContext,
597
646
  > extends TelegramThreadCapabilityReaderDeps {
647
+ lifecycle?: TelegramThreadCapabilityLifecycle;
598
648
  topicTargetStore: TelegramThreadCapabilityStore;
599
649
  ownsLock: (ctx: TContext) => boolean;
600
650
  isFollowerRegistered?: () => boolean;
@@ -672,6 +722,7 @@ export interface TelegramThreadAwarePollingDeps<
672
722
  TContext,
673
723
  TOwner,
674
724
  > extends TelegramStartupThreadCapabilityProbeDeps {
725
+ lifecycle?: TelegramThreadCapabilityLifecycle;
675
726
  isBusRuntimeEnabled: () => boolean;
676
727
  isTopicModeUnavailableError: (error: unknown) => boolean;
677
728
  getPollingStartedWithTelegramBus: () => boolean;
@@ -749,7 +800,16 @@ export function createTelegramThreadCapabilityStateRuntime(): TelegramThreadCapa
749
800
  export function createTelegramThreadCapabilityOrchestration<TContext, TOwner>(
750
801
  deps: TelegramThreadCapabilityOrchestrationDeps<TContext, TOwner>,
751
802
  ): TelegramThreadCapabilityOrchestration<TContext, TOwner> {
803
+ let generation = 0;
804
+ const lifecycle: TelegramThreadCapabilityLifecycle = {
805
+ capture() {
806
+ const expected = generation;
807
+ return () => expected === generation;
808
+ },
809
+ invalidate() { generation++; },
810
+ };
752
811
  const capabilityDeps: TelegramThreadCapabilityRuntimeDeps<TContext> = {
812
+ lifecycle,
753
813
  getAllowedUserId: deps.getAllowedUserId,
754
814
  callApi: deps.callApi,
755
815
  topicTargetStore: deps.topicTargetStore,
@@ -773,6 +833,7 @@ export function createTelegramThreadCapabilityOrchestration<TContext, TOwner>(
773
833
  monitor: createTelegramThreadCapabilityMonitor(capabilityDeps),
774
834
  observeTarget: createTelegramThreadTargetObservationHandler(capabilityDeps),
775
835
  pollingPorts: createTelegramThreadAwarePollingPorts({
836
+ lifecycle,
776
837
  getAllowedUserId: deps.getAllowedUserId,
777
838
  callApi: deps.callApi,
778
839
  topicTargetStore: deps.topicTargetStore,
@@ -807,8 +868,11 @@ export async function readTelegramThreadCapability(
807
868
 
808
869
  export async function probeTelegramStartupThreadCapability(
809
870
  deps: TelegramStartupThreadCapabilityProbeDeps,
871
+ isCurrent: () => boolean = () => true,
810
872
  ): Promise<boolean | undefined> {
873
+ if (!isCurrent()) return;
811
874
  const threadModeEnabled = await readTelegramThreadCapability(deps);
875
+ if (!isCurrent()) return;
812
876
  const nowMs = (deps.getNowMs ?? Date.now)();
813
877
  if (threadModeEnabled === false) {
814
878
  deps.topicTargetStore.setBotState({
@@ -817,6 +881,7 @@ export async function probeTelegramStartupThreadCapability(
817
881
  lastReconcileAction: "startup-bot-topics-disabled",
818
882
  });
819
883
  await deps.topicTargetStore.persist();
884
+ if (!isCurrent()) return;
820
885
  deps.recordEvent("bus", "Telegram Threaded Mode unavailable on startup", {
821
886
  phase: "startup-bot-topics-disabled",
822
887
  });
@@ -831,6 +896,7 @@ export async function probeTelegramStartupThreadCapability(
831
896
  lastReconcileAction: "startup-bot-topics-enabled",
832
897
  });
833
898
  await deps.topicTargetStore.persist();
899
+ if (!isCurrent()) return;
834
900
  deps.setTopicModeUnavailable(false);
835
901
  }
836
902
  return threadModeEnabled;
@@ -865,8 +931,11 @@ export async function applyTelegramThreadCapability<TContext>(
865
931
  threadModeEnabled: boolean,
866
932
  phase: string,
867
933
  deps: TelegramThreadCapabilityRuntimeDeps<TContext>,
934
+ isCurrent: () => boolean = deps.lifecycle?.capture() ?? (() => true),
868
935
  ): Promise<void> {
936
+ if (!isCurrent()) return;
869
937
  await deps.topicTargetStore.load();
938
+ if (!isCurrent()) return;
870
939
  const nowMs = (deps.getNowMs ?? Date.now)();
871
940
  const previousBotState = deps.topicTargetStore.getBotState();
872
941
  if (!threadModeEnabled) {
@@ -886,6 +955,7 @@ export async function applyTelegramThreadCapability<TContext>(
886
955
  lastReconcileAction: phase,
887
956
  });
888
957
  await deps.topicTargetStore.persist();
958
+ if (!isCurrent()) return;
889
959
  deps.setTopicModeUnavailable(true);
890
960
  deps.stopFollowerRegistration();
891
961
  if (
@@ -894,16 +964,20 @@ export async function applyTelegramThreadCapability<TContext>(
894
964
  ) {
895
965
  deps.stopLeaderHealth();
896
966
  await deps.stopBusPolling();
967
+ if (!isCurrent()) return;
897
968
  deps.setPollingStartedWithTelegramBus(false);
898
969
  try {
899
970
  await deps.startClassicPolling(ctx);
971
+ if (!isCurrent()) return;
900
972
  } catch (classicError) {
973
+ if (!isCurrent()) return;
901
974
  deps.topicTargetStore.setBotState({
902
975
  threadMode: "disabled",
903
976
  updatedAtMs: (deps.getNowMs ?? Date.now)(),
904
977
  lastReconcileAction: `${phase}-classic-restore-failed`,
905
978
  });
906
979
  await deps.topicTargetStore.persist();
980
+ if (!isCurrent()) return;
907
981
  deps.recordEvent("bus", classicError, {
908
982
  phase: `${phase}-classic-restore`,
909
983
  });
@@ -919,14 +993,18 @@ export async function applyTelegramThreadCapability<TContext>(
919
993
  lastReconcileAction: phase,
920
994
  });
921
995
  await deps.topicTargetStore.persist();
996
+ if (!isCurrent()) return;
922
997
  deps.setTopicModeUnavailable(false);
923
998
  if (!deps.getPollingStartedWithTelegramBus() && deps.ownsLock(ctx)) {
924
999
  await deps.stopClassicPolling();
1000
+ if (!isCurrent()) return;
925
1001
  deps.setPollingStartedWithTelegramBus(true);
926
1002
  try {
927
1003
  await deps.startBusPolling(ctx);
1004
+ if (!isCurrent()) return;
928
1005
  deps.startLeaderHealth();
929
1006
  } catch (error) {
1007
+ if (!isCurrent()) return;
930
1008
  deps.setPollingStartedWithTelegramBus(false);
931
1009
  const threadModeUnavailable =
932
1010
  deps.isTopicModeUnavailableError?.(error) === true;
@@ -937,17 +1015,21 @@ export async function applyTelegramThreadCapability<TContext>(
937
1015
  lastReconcileAction: `${phase}-unavailable`,
938
1016
  });
939
1017
  await deps.topicTargetStore.persist();
1018
+ if (!isCurrent()) return;
940
1019
  deps.setTopicModeUnavailable(true);
941
1020
  }
942
1021
  try {
943
1022
  await deps.startClassicPolling(ctx);
1023
+ if (!isCurrent()) return;
944
1024
  } catch (classicError) {
1025
+ if (!isCurrent()) return;
945
1026
  deps.topicTargetStore.setBotState({
946
1027
  threadMode: "disabled",
947
1028
  updatedAtMs: (deps.getNowMs ?? Date.now)(),
948
1029
  lastReconcileAction: `${phase}-classic-restore-failed`,
949
1030
  });
950
1031
  await deps.topicTargetStore.persist();
1032
+ if (!isCurrent()) return;
951
1033
  deps.recordEvent("bus", classicError, {
952
1034
  phase: `${phase}-classic-restore`,
953
1035
  });
@@ -963,17 +1045,25 @@ export async function applyTelegramThreadCapability<TContext>(
963
1045
  export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
964
1046
  deps: TelegramThreadAwarePollingDeps<TContext, TOwner>,
965
1047
  ): TelegramThreadAwarePollingPorts<TContext, TOwner> {
1048
+ let generation = 0;
966
1049
  const startPolling = async (
967
1050
  ctx: TContext,
968
1051
  options?: { forceFreshLeaderThread?: boolean },
969
1052
  ): Promise<void> => {
1053
+ const expectedGeneration = ++generation;
1054
+ deps.lifecycle?.invalidate();
1055
+ const isLifecycleCurrent = deps.lifecycle?.capture() ?? (() => true);
1056
+ const isCurrent = () => expectedGeneration === generation && isLifecycleCurrent();
970
1057
  await deps.topicTargetStore.load();
1058
+ if (!isCurrent()) return;
971
1059
  let startupThreadCapability: boolean | undefined;
972
1060
  try {
973
- startupThreadCapability = await probeTelegramStartupThreadCapability(deps);
1061
+ startupThreadCapability = await probeTelegramStartupThreadCapability(deps, isCurrent);
974
1062
  } catch (error) {
1063
+ if (!isCurrent()) return;
975
1064
  deps.recordEvent("bus", error, { phase: "startup-thread-mode-probe" });
976
1065
  }
1066
+ if (!isCurrent()) return;
977
1067
  deps.setTopicModeUnavailable(startupThreadCapability !== true);
978
1068
  if (deps.isBusRuntimeEnabled()) {
979
1069
  deps.setTopicModeUnavailable(false);
@@ -983,32 +1073,39 @@ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
983
1073
  !!options?.forceFreshLeaderThread,
984
1074
  );
985
1075
  await deps.startBusLeaderPolling(ctx);
1076
+ if (!isCurrent()) return;
986
1077
  deps.startLeaderHealth();
987
1078
  return;
988
1079
  } catch (error) {
1080
+ if (!isCurrent()) return;
989
1081
  deps.setPollingStartedWithTelegramBus(false);
990
1082
  if (!deps.isTopicModeUnavailableError(error)) throw error;
991
1083
  deps.setTopicModeUnavailable(true);
992
1084
  await deps.topicTargetStore.load();
1085
+ if (!isCurrent()) return;
993
1086
  deps.topicTargetStore.setBotState({
994
1087
  threadMode: "disabled",
995
1088
  updatedAtMs: Date.now(),
996
1089
  lastReconcileAction: "thread-mode-unavailable",
997
1090
  });
998
1091
  await deps.topicTargetStore.persist();
1092
+ if (!isCurrent()) return;
999
1093
  deps.recordEvent("bus", error, { phase: "thread-mode-unavailable" });
1000
1094
  } finally {
1001
- deps.setForceFreshLeaderThreadOnNextStart(false);
1095
+ if (isCurrent()) deps.setForceFreshLeaderThreadOnNextStart(false);
1002
1096
  }
1003
1097
  }
1004
1098
  deps.setPollingStartedWithTelegramBus(false);
1005
1099
  await deps.startClassicPolling(ctx);
1006
1100
  };
1007
1101
  const stopPolling = async (): Promise<void> => {
1102
+ const expectedGeneration = ++generation;
1103
+ deps.lifecycle?.invalidate();
1104
+ deps.setForceFreshLeaderThreadOnNextStart(false);
1105
+ deps.stopLeaderHealth();
1008
1106
  if (deps.getPollingStartedWithTelegramBus()) {
1009
- deps.stopLeaderHealth();
1010
1107
  await deps.stopBusLeaderPolling();
1011
- deps.setPollingStartedWithTelegramBus(false);
1108
+ if (expectedGeneration === generation) deps.setPollingStartedWithTelegramBus(false);
1012
1109
  return;
1013
1110
  }
1014
1111
  await deps.stopClassicPolling();
@@ -1043,14 +1140,17 @@ export function createTelegramThreadTargetObservationHandler<TContext>(
1043
1140
  if (transitionPending) return;
1044
1141
  if (deps.topicTargetStore.getBotState().threadMode === "enabled") return;
1045
1142
  transitionPending = true;
1143
+ const isCurrent = deps.lifecycle?.capture() ?? (() => true);
1046
1144
  try {
1047
1145
  await applyTelegramThreadCapability(
1048
1146
  ctx,
1049
1147
  true,
1050
1148
  "thread-target-observed",
1051
1149
  deps,
1150
+ isCurrent,
1052
1151
  );
1053
1152
  } catch (error) {
1153
+ if (!isCurrent()) return;
1054
1154
  deps.recordEvent("bus", error, { phase: "thread-target-observed" });
1055
1155
  } finally {
1056
1156
  transitionPending = false;
@@ -1079,6 +1179,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1079
1179
  let consecutiveDisabledProbes = 0;
1080
1180
  const stop = (): void => {
1081
1181
  generation += 1;
1182
+ deps.lifecycle?.invalidate();
1082
1183
  if (interval) clearInterval(interval);
1083
1184
  interval = undefined;
1084
1185
  };
@@ -1087,7 +1188,8 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1087
1188
  return;
1088
1189
  }
1089
1190
  const expectedGeneration = generation;
1090
- const isCurrent = (): boolean => generation === expectedGeneration;
1191
+ const isLifecycleCurrent = deps.lifecycle?.capture() ?? (() => true);
1192
+ const isCurrent = (): boolean => generation === expectedGeneration && isLifecycleCurrent();
1091
1193
  let tracked: Promise<void>;
1092
1194
  tracked = readTelegramThreadCapability(deps)
1093
1195
  .then(async (threadModeEnabled) => {
@@ -1103,6 +1205,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1103
1205
  true,
1104
1206
  "capability-monitor-retry",
1105
1207
  deps,
1208
+ isCurrent,
1106
1209
  );
1107
1210
  }
1108
1211
  return;
@@ -1123,6 +1226,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1123
1226
  false,
1124
1227
  "capability-monitor-disabled-confirmed",
1125
1228
  deps,
1229
+ isCurrent,
1126
1230
  );
1127
1231
  return;
1128
1232
  }
@@ -1153,6 +1257,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1153
1257
  ? "capability-monitor-disabled-confirmed"
1154
1258
  : "capability-monitor-disabled",
1155
1259
  deps,
1260
+ isCurrent,
1156
1261
  );
1157
1262
  })
1158
1263
  .catch((error) => {
@@ -1526,6 +1631,19 @@ export async function runTelegramPollLoop<
1526
1631
  TContext = unknown,
1527
1632
  >(deps: TelegramPollLoopDeps<TUpdate, TContext>): Promise<void> {
1528
1633
  if (!deps.config.botToken) return;
1634
+ let consecutiveGetUpdatesConflicts = 0;
1635
+ const retryConflict = async () => {
1636
+ consecutiveGetUpdatesConflicts += 1;
1637
+ if (consecutiveGetUpdatesConflicts >= TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT) {
1638
+ throw new TelegramPersistentGetUpdatesConflictError(consecutiveGetUpdatesConflicts);
1639
+ }
1640
+ await deps.sleep(
1641
+ consecutiveGetUpdatesConflicts < TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT
1642
+ ? TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS
1643
+ : TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS,
1644
+ deps.signal,
1645
+ );
1646
+ };
1529
1647
  try {
1530
1648
  await deps.deleteWebhook(deps.signal);
1531
1649
  } catch {
@@ -1564,7 +1682,9 @@ export async function runTelegramPollLoop<
1564
1682
  } catch (error) {
1565
1683
  if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
1566
1684
  reportTelegramPollingPhase(deps, "retrying");
1567
- deps.recordRuntimeEvent?.("polling", error, {
1685
+ if (isTelegramGetUpdatesConflictError(error)) {
1686
+ await retryConflict();
1687
+ } else deps.recordRuntimeEvent?.("polling", error, {
1568
1688
  phase: "initial-sync",
1569
1689
  ...(error instanceof TelegramGetUpdatesTimeoutError
1570
1690
  ? { timeoutMs: error.timeoutMs }
@@ -1572,7 +1692,6 @@ export async function runTelegramPollLoop<
1572
1692
  });
1573
1693
  }
1574
1694
  }
1575
- let consecutiveGetUpdatesConflicts = 0;
1576
1695
  let currentUpdateId: number | undefined;
1577
1696
  while (!deps.signal.aborted) {
1578
1697
  try {
@@ -1600,6 +1719,10 @@ export async function runTelegramPollLoop<
1600
1719
  } catch (error) {
1601
1720
  if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
1602
1721
  reportTelegramPollingPhase(deps, "retrying", currentUpdateId);
1722
+ if (isTelegramGetUpdatesConflictError(error)) {
1723
+ await retryConflict();
1724
+ continue;
1725
+ }
1603
1726
  deps.recordRuntimeEvent?.("polling", error, {
1604
1727
  phase:
1605
1728
  error instanceof TelegramGetUpdatesTimeoutError
@@ -1609,17 +1732,6 @@ export async function runTelegramPollLoop<
1609
1732
  ? { timeoutMs: error.timeoutMs }
1610
1733
  : {}),
1611
1734
  });
1612
- if (isTelegramGetUpdatesConflictError(error)) {
1613
- consecutiveGetUpdatesConflicts += 1;
1614
- await deps.sleep(
1615
- consecutiveGetUpdatesConflicts <
1616
- TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT
1617
- ? TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS
1618
- : TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS,
1619
- deps.signal,
1620
- );
1621
- continue;
1622
- }
1623
1735
  consecutiveGetUpdatesConflicts = 0;
1624
1736
  deps.onErrorStatus(getTelegramPollingErrorMessage(error));
1625
1737
  await deps.sleep(TELEGRAM_POLLING_RETRY_MS, deps.signal);
@@ -152,7 +152,7 @@ type TelegramBeforeAgentStartEvent = Omit<
152
152
  BeforeAgentStartEvent,
153
153
  "systemPrompt"
154
154
  > & {
155
- systemPrompt: TelegramSystemPrompt;
155
+ systemPrompt?: TelegramSystemPrompt | null;
156
156
  };
157
157
 
158
158
  type TelegramBeforeAgentStartResult = {
@@ -165,11 +165,12 @@ type TelegramBeforeAgentStartHook = (
165
165
 
166
166
  export function buildTelegramBridgeSystemPrompt(options: {
167
167
  prompt: string;
168
- systemPrompt: TelegramSystemPrompt;
168
+ systemPrompt?: TelegramSystemPrompt | null;
169
169
  telegramPrefix?: string;
170
170
  localSystemPromptSuffix: string;
171
171
  telegramTurnSystemPromptSuffix: string;
172
172
  }): TelegramBeforeAgentStartResult {
173
+ const basePrompt = options.systemPrompt ?? "";
173
174
  const telegramPrefix = options.telegramPrefix ?? TELEGRAM_PREFIX;
174
175
  const telegramHead = telegramPrefix.endsWith("]")
175
176
  ? telegramPrefix.slice(0, -1)
@@ -182,12 +183,12 @@ export function buildTelegramBridgeSystemPrompt(options: {
182
183
  ? `${options.telegramTurnSystemPromptSuffix}\n- The current user message came from Telegram.`
183
184
  : "";
184
185
  return {
185
- systemPrompt: Array.isArray(options.systemPrompt)
186
+ systemPrompt: Array.isArray(basePrompt)
186
187
  ? [
187
- ...options.systemPrompt,
188
+ ...basePrompt,
188
189
  options.localSystemPromptSuffix + telegramSuffix,
189
190
  ]
190
- : options.systemPrompt +
191
+ : basePrompt +
191
192
  options.localSystemPromptSuffix +
192
193
  telegramSuffix,
193
194
  };
@@ -221,8 +222,9 @@ function stripTelegramToolMetadataFromString(systemPrompt: string): string {
221
222
  }
222
223
 
223
224
  function stripTelegramToolMetadataFromSystemPrompt(
224
- systemPrompt: TelegramSystemPrompt,
225
+ systemPrompt: TelegramSystemPrompt | null | undefined,
225
226
  ): TelegramSystemPrompt {
227
+ if (!systemPrompt) return "";
226
228
  return Array.isArray(systemPrompt)
227
229
  ? systemPrompt.map(stripTelegramToolMetadataFromString)
228
230
  : stripTelegramToolMetadataFromString(systemPrompt);
@@ -1908,6 +1908,8 @@ export async function handleTelegramAgentEndRuntime<
1908
1908
  if (!isDeliveryActive()) return;
1909
1909
  if (richAttachmentDelivered) {
1910
1910
  await deps.clearPreview(turn.chatId, { target: turn.target });
1911
+ if (!isDeliveryActive()) return;
1912
+ deps.setPreviewPendingText("");
1911
1913
  }
1912
1914
  } catch (error) {
1913
1915
  deps.recordRuntimeEvent?.("delivery", error, {
@@ -1938,6 +1940,8 @@ export async function handleTelegramAgentEndRuntime<
1938
1940
  { replyMarkup, target: turn.target },
1939
1941
  );
1940
1942
  }
1943
+ if (!isDeliveryActive()) return;
1944
+ deps.setPreviewPendingText("");
1941
1945
  } catch (error) {
1942
1946
  deps.recordRuntimeEvent?.("delivery", error, {
1943
1947
  phase: "final-text",
@@ -291,6 +291,7 @@ export interface TelegramStatusBarTheme {
291
291
  export interface TelegramStatusBarState {
292
292
  hasBotToken: boolean;
293
293
  pollingActive: boolean;
294
+ pollingStopReason?: string;
294
295
  paired: boolean;
295
296
  busRole?: TelegramBridgeBusRole;
296
297
  busLifecyclePhase?: TelegramBridgeBusLifecyclePhase;
@@ -699,6 +700,7 @@ export function createTelegramBridgeStatusRuntime<
699
700
  queuedItems: queuedItemCount,
700
701
  }),
701
702
  queuedStatus: deps.formatQueuedStatus(queuedItems),
703
+ pollingStopReason: deps.getPollingState?.().stopReason,
702
704
  error,
703
705
  };
704
706
  },
@@ -889,6 +891,8 @@ export function buildTelegramStatusBarText(
889
891
  : "";
890
892
  if (!state.hasBotToken)
891
893
  return `${label} ${theme.fg("muted", "not configured")}${queued}`;
894
+ if (state.pollingStopReason === "persistent-conflict" && state.busRole !== "follower")
895
+ return `${label} ${theme.fg("error", "error")}`;
892
896
  if (!state.paired)
893
897
  return `${label} ${theme.fg("warning", "awaiting pairing")}${queued}`;
894
898
  if (state.busLifecyclePhase === "electing")
@@ -2160,6 +2160,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
2160
2160
  failurePhase: string,
2161
2161
  error: unknown,
2162
2162
  currentUpdateId?: number,
2163
+ extraDetails?: Record<string, unknown>,
2163
2164
  ): "blocked" => {
2164
2165
  blocked = true;
2165
2166
  state.lastFailureAtMs = getNowMs();
@@ -2168,6 +2169,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
2168
2169
  phase: failurePhase,
2169
2170
  generation: owner?.generation,
2170
2171
  ...(currentUpdateId !== undefined ? { updateId: currentUpdateId } : {}),
2172
+ ...extraDetails,
2171
2173
  });
2172
2174
  transition("blocked", currentUpdateId, blockedReason);
2173
2175
  return "blocked";
@@ -2553,6 +2555,10 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
2553
2555
  `Telegram queue receipt ${normalized.receiptId} conflicts with committed authority.`,
2554
2556
  ),
2555
2557
  currentUpdateId,
2558
+ {
2559
+ receiptId: normalized.receiptId,
2560
+ sourceUpdateIds: normalized.sourceUpdateIds,
2561
+ },
2556
2562
  );
2557
2563
  }
2558
2564
  return "duplicate";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.43.0",
3
+ "version": "0.43.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -13,4 +13,6 @@ These slash commands are registered Pi commands, not shell executables or agent
13
13
 
14
14
  When `PI_CODING_AGENT_DIR` selects another compatible runtime, resolve its equivalent `tmp/telegram` directory.
15
15
 
16
+ A `persistent-conflict` polling stop means the bounded competing-`getUpdates` threshold triggered full transport stand-down, not cancellation of accepted local Pi work. The terminal diagnostic distinguishes lost local ownership from a competing client despite an apparently owned lock. Check other profiles, agent directories, installations, or non-Pi clients sharing the bot; after removing the competition, reconnect through the supported Pi command. Do not restart repeatedly or alter lock files to compete for the stream.
17
+
16
18
  Do not mutate ownership files, bridge state, journals, bindings, or locks to force recovery. Use supported commands and preserve exact profile, target, transport, and session authority. Never claim successful delivery without transport evidence.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -45,7 +45,7 @@
45
45
  "@llblab/pi-codex-usage": "0.9.4",
46
46
  "@llblab/pi-grow-loop": "0.7.4",
47
47
  "@llblab/pi-state-flow": "0.3.0",
48
- "@llblab/pi-telegram": "0.43.0",
48
+ "@llblab/pi-telegram": "0.43.1",
49
49
  "@llblab/skills": "1.14.0"
50
50
  },
51
51
  "bundledDependencies": [