@llblab/pi-kit 0.1.7 → 0.1.8

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,11 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.1.8 - 2026-08-23
6
+
7
+ - `Telegram Releases`: Advances `@llblab/pi-telegram` to `0.37.1`, bringing journal-owned polling admission state from `0.37.0` plus the settings-manager compatibility hotfix for `/start`, model, and queue menus.
8
+ - `Release Verification`: Allows up to ten minutes for Trusted Publisher releases to become publicly readable before package identity and bundled inventory verification, avoiding false failures from npm registry propagation delay.
9
+
5
10
  ## 0.1.7 - 2026-08-22
6
11
 
7
12
  - `Telegram Hotfix`: Advances `@llblab/pi-telegram` to `0.36.11`, bringing at-most-once durable prompt dispatch that prevents session or process replacement from automatically replaying an already-admitted Telegram prompt.
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  | `@llblab/pi-actors` | `0.50.0` | Extension and Skills |
10
10
  | `@llblab/pi-codex-usage` | `0.9.3` | Extension |
11
11
  | `@llblab/pi-grow-loop` | `0.7.2` | Extension and Skills |
12
- | `@llblab/pi-telegram` | `0.36.11` | Extension and Skills |
12
+ | `@llblab/pi-telegram` | `0.37.1` | Extension and Skills |
13
13
 
14
14
  Versions are exact by design. Updating an extension does not change an installed kit until this repository explicitly advances that dependency and publishes a new kit version.
15
15
 
@@ -2,6 +2,14 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.37.1: Settings Manager Compatibility Hotfix
6
+
7
+ - `Menu Compatibility`: Keeps `/start`, model, and queue menu rendering compatible with Pi settings-manager implementations that do not expose `reload()`. Hosts with reload retain explicit refresh behavior; other hosts use the freshly constructed settings snapshot instead of failing with `settingsManager.reload is not a function`.
8
+
9
+ ## 0.37.0: Journal-Owned Telegram Admission
10
+
11
+ - `Configuration-Only State`: Moves the per-profile polling cursor out of `telegram.json` into atomic durable journal revisions, including cursor-only initial sync, compaction, reconstruction, and recovery. A journal-first one-shot cutover removes legacy config state idempotently, exact bot/profile fences remain enforced, status reads journal authority, and unsafe downgrade now fails closed.
12
+
5
13
  ## 0.36.11: At-Most-Once Prompt Dispatch Hotfix
6
14
 
7
15
  - `At-Most-Once Prompt Dispatch`: Commits each exact durable Telegram receipt synchronously before Pi model admission and blocks dispatch when that commit fails, closing the session/process-replacement replay window that could deliver an already-processed old prompt again; the narrow commit-before-admission crash boundary now favors no duplicate over retry.
@@ -274,7 +274,7 @@ Stable public entrypoints are documented in [Public API](./docs/public-api.md),
274
274
 
275
275
  ## Safety Boundaries
276
276
 
277
- Durable inbound admission is a **process-crash recovery** guarantee. Atomic private-file replacement preserves acknowledged journal authority across ordinary process exit, crash, kill, and replacement, but the extension does not flush files or parent directories for host/kernel/filesystem/device/power-loss durability. Keep `~/.pi/agent` on appropriately managed storage and backups if that stronger operational guarantee is required. Before downgrading below `0.28.0`, run `node scripts/check-downgrade.mjs`; a blocked result means `0.28.x` must drain the retained journal first. See [Durable Admission And Recovery](./docs/architecture.md#durable-admission-and-recovery).
277
+ Durable inbound admission is a **process-crash recovery** guarantee. Atomic private-file replacement preserves acknowledged journal authority and its journal-owned `acceptedThroughUpdateId` polling cursor across ordinary process exit, crash, kill, and replacement, but the extension does not flush files or parent directories for host/kernel/filesystem/device/power-loss durability. `telegram.json` contains configuration only. Keep `~/.pi/agent` on appropriately managed storage and backups if that stronger operational guarantee is required. Before downgrading below `0.37.0`, run `node scripts/check-downgrade.mjs`; any retained cursor-schema journal blocks downgrade because an older runtime could repoll admitted updates. See [Durable Admission And Recovery](./docs/architecture.md#durable-admission-and-recovery).
278
278
 
279
279
  `pi-telegram` intentionally does not:
280
280
 
@@ -116,9 +116,9 @@ Mirrored domain regressions live in `/tests/*.test.ts`. Shared test fixtures sho
116
116
 
117
117
  ## Configuration And Ownership
118
118
 
119
- Telegram configuration lives in `~/.pi/agent/telegram.json`. Bot/session identity (`botToken`, `botUsername`, `botId`, `allowedUserId`, `lastUpdateId`) persists only under `profiles.default` or `profiles.<name>`; shared handlers and assistant/voice/time settings stay top-level. Authoritative transport ownership lives separately in the pi-telegram-private `~/.pi/agent/tmp/telegram/owners.json` store. Its top-level slots are `default` and validated named profile names; unrelated extensions never read or write this file.
119
+ Telegram configuration lives in `~/.pi/agent/telegram.json`. Bot/session identity (`botToken`, `botUsername`, `botId`, `allowedUserId`) persists only under `profiles.default` or `profiles.<name>`; shared handlers and assistant/voice/time settings stay top-level. Per-profile polling/admission state lives only in the durable update journal as `acceptedThroughUpdateId`. Authoritative transport ownership lives separately in the pi-telegram-private `~/.pi/agent/tmp/telegram/owners.json` store. Its top-level slots are `default` and validated named profile names; unrelated extensions never read or write this file.
120
120
 
121
- `telegram.json` is one global cross-instance configuration document. Ordinary reads rely on atomic publication and do not take the mutation guard. Every cooperating Pi instance persists only its recursive delta from the snapshot it loaded, merges that delta into the latest disk document inside `telegram.json.transaction`, and publishes atomically only when the semantic result differs; a no-op merge adopts the newer disk snapshot in memory without replacing the file. Unrelated global and profile changes therefore survive stale writers, while `lastUpdateId` additionally merges monotonically. Two serialized writers changing the same leaf use commit order, so the later local delta wins. A non-transactional external editor cannot participate in that conflict protocol: it should write through same-directory atomic replacement while Pi is idle, then let instances reload; an editor racing the transaction may lose its same-leaf change and must retry from the resulting file.
121
+ `telegram.json` is one global cross-instance configuration document. Ordinary reads rely on atomic publication and do not take the mutation guard. Every cooperating Pi instance persists only its recursive delta from the snapshot it loaded, merges that delta into the latest disk document inside `telegram.json.transaction`, and publishes atomically only when the semantic result differs; a no-op merge adopts the newer disk snapshot in memory without replacing the file. Unrelated global and profile changes therefore survive stale writers. Two serialized writers changing the same leaf use commit order, so the later local delta wins. A non-transactional external editor cannot participate in that conflict protocol: it should write through same-directory atomic replacement while Pi is idle, then let instances reload; an editor racing the transaction may lose its same-leaf change and must retry from the resulting file.
122
122
 
123
123
  ### Setup Flow
124
124
 
@@ -161,9 +161,9 @@ Version `0.24.0` intentionally does not read or migrate the former agent-level `
161
161
 
162
162
  Telegram private-chat Threaded Mode is the public switch for multi-instance Telegram operation. Classic single-DM polling is the base mode. When Telegram private-chat threads are available for the bot, the bridge enables the local leader/follower bus automatically; when threads are unavailable or later disabled, the bridge returns to classic single-DM polling as a first-class mode. Before a non-owner `/telegram-connect` chooses follower registration or singleton takeover, it discards process-local status/capability projections and reads the current owner-published mode: `enabled` registers a follower without a takeover prompt, while `disabled` uses the classic confirmation flow.
163
163
 
164
- Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes the `owners.json` slot, diagnostics logs, state files, thread/bus ownership, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the `default` slot. A different selected profile is a parallel bot runtime: its owner slot, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, `tmp/telegram/logs.<profile>._prev.jsonl`, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and other named profiles while shared bridge settings remain top-level/global.
164
+ Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`) and scopes its durable journal cursor, the `owners.json` slot, diagnostics logs, state files, thread/bus ownership, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the `default` slot. A different selected profile is a parallel bot runtime: its owner slot, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, `tmp/telegram/logs.<profile>._prev.jsonl`, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and other named profiles while shared bridge settings remain top-level/global.
165
165
 
166
- Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so named-profile offsets and unrelated global/profile updates do not stale-replace one another. Polling publishes only its monotonic `lastUpdateId` into the current config-store snapshot; it never persists the detached full config object captured at poll start, which would interpret newer Settings fields as deletions. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup recognizes only those UUID-prefixed scratch files. It never age-deletes journals, ownership, state, logs, or other top-level runtime files and therefore cannot redirect live traffic or orphan immutable journal segments.
166
+ Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so unrelated global/profile updates do not stale-replace one another. Each profile's journal transaction independently publishes its monotonic admission cursor together with admitted work; polling never writes runtime state through config persistence. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup recognizes only those UUID-prefixed scratch files. It never age-deletes journals, ownership, state, logs, or other top-level runtime files and therefore cannot redirect live traffic or orphan immutable journal segments.
167
167
 
168
168
  When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Follower liveness is intentionally fast because heartbeat traffic is local IPC: followers heartbeat every `1s`, the leader treats them as stale after `2s`, and the prune loop runs every `1s` so stopped followers are detected promptly while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Pruning alone preserves the binding; when Thread cleanup is enabled, only a subsequent OS check that confirms the exact registered PID absent may create fenced cleanup intent, and that cleanup serializes ahead of replacement registration. Successful follower target reuse refreshes the binding's recovery timestamp. Absent follower bindings remain durable restoration hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; process absence and heartbeat pruning alone do not remove them. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Bot capability monitoring does not probe through the bus until the process either owns that direct lock or has completed authenticated follower registration. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
169
169
 
@@ -240,7 +240,7 @@ The canonical update transition contract is:
240
240
 
241
241
  The worker executes at most 64 eligible entries from one validated journal snapshot, commits ordinary completions through one journal transaction, then yields through a generation-checked event-loop boundary. Retry, queue, or prior-generation boundaries first flush completed ids and force a fresh snapshot, preserving exact state-transition atomicity without per-entry parse/rewrite churn. A deterministic 2,048-entry stress gate requires exactly 32 completion publications, 33 reads including the final empty snapshot, continued 1ms timer progress, and less than 250ms maximum observed heartbeat delay. Byte-capacity tests cover failed and retry-wait diagnostics, queue receipt/owner and handoff metadata, and operator dispositions; every rejected growth leaves the prior authority bytes unchanged. It still scans later independent entries after retry or terminal persistence. An unresolved reaction remains a queue-mutation dependency even in `retry-wait` or `failed`, but dispatch checks that dependency against the candidate queue item's exact chat and source message ids instead of globally blocking unrelated targets. Successful replay or an exact discard disposition releases the dependency. Worker state, debug status, state snapshots, and redacted runtime events expose journal depth, retry/terminal counts, the next retry, latest terminal identity, copyable operator commands, and the exact first foreign queued owner identity (instance, PID/birth, session, and acquisition) when semantic authority belongs to another process.
242
242
 
243
- Upgrades create journals lazily before the first post-upgrade offset advance. A bot/profile identity change with unresolved authority fails closed. Once reconstructed authority is empty, the next read atomically rebinds profile and bot identity under the journal transaction and removes redundant old-identity segments best-effort; stable-`botId` token rotation remains valid even with entries. Downgrading below `0.28.0` with a non-empty journal is unsafe because the older runtime cannot drain updates whose Telegram offsets already advanced. Run `node scripts/check-downgrade.mjs [agent-dir]`; a blocked result requires draining with a compatible `0.28.x` runtime, while a safe result confirms all reconstructed journal authority is empty before downgrade. Runtime state from an older release must recover without deleting `telegram.json`, ownership state, or journal authority.
243
+ The journal is the sole polling/admission authority. Each atomic journal revision publishes the admitted batch and monotonic `acceptedThroughUpdateId` together; cursor-only initial synchronization uses an empty batch revision. Existing config cursors are transferred once before polling: journal publication precedes config removal, restart retries are idempotent, established journal authority never regresses, and an unprovable non-empty journal fails closed. Upgrades create journals lazily before the first post-upgrade offset advance. A bot/profile identity change with unresolved authority fails closed. Once reconstructed authority is empty, the next read atomically rebinds profile and bot identity under the journal transaction and removes redundant old-identity segments best-effort; stable-`botId` token rotation remains valid even with entries. Downgrading below `0.37.0` with a cursor-schema journal is unsafe because an older runtime cannot recover `acceptedThroughUpdateId` and could repoll admitted updates. Run `node scripts/check-downgrade.mjs [agent-dir]`; the conservative older-schema validator rejects that retained authority even when entries are drained. Runtime state from an older release must recover without deleting `telegram.json`, ownership state, or journal authority.
244
244
 
245
245
  Polling and inbound-worker diagnostics remain separate so an executing, deferred, locally queued, foreign-queued, or blocked journal head cannot masquerade as a stalled `getUpdates` request.
246
246
 
@@ -84,7 +84,6 @@ interface TelegramBotProfile {
84
84
  botUsername?: string; // runtime-managed
85
85
  botId?: number; // runtime-managed
86
86
  allowedUserId?: number;
87
- lastUpdateId?: number; // runtime-managed
88
87
  }
89
88
 
90
89
  interface TelegramConfig {
@@ -114,7 +113,7 @@ interface TelegramConfig {
114
113
 
115
114
  Bot/session identity always persists under `profiles.<name>`. The ordinary setup path uses `profiles.default`; `/telegram-setup default` and `/telegram-connect default` are exact aliases for the bare commands. Named profiles use the same shape. Shared handlers plus `assistant`, `voice`, `time`, and `threads` remain top-level. On the first `0.24.0` load, unambiguous legacy root identity moves atomically into `profiles.default`; identical duplicates collapse, complementary fields merge, and conflicting values fail closed without modifying the file.
116
115
 
117
- The file is global across Pi instances. Cooperating instances serialize recursive delta merges through `telegram.json.transaction`, preserve unrelated global/profile changes from newer disk snapshots, and merge `lastUpdateId` monotonically. A semantically unchanged merge adopts the latest disk state in memory without replacing the file; later commits win when two deltas intentionally change the same leaf. Same-parent temp-file replacement retries bounded transient `EPERM`, `EACCES`, and `EBUSY` destination contention without deleting the live config or leaving transaction serialization. For manual edits, stop or idle the connected instances, publish a complete valid file atomically, and let them reload. A non-transactional editor racing Pi persistence has no same-leaf conflict guarantee.
116
+ The file is global across Pi instances and contains configuration only. The per-profile polling/admission cursor is `acceptedThroughUpdateId` in that profile's private durable update journal; it is not a config key. On first connection after this cut, a legacy config cursor is transferred directly into the journal before polling and then removed from config. Journal publication failure preserves the legacy source; config publication failure leaves the journal authoritative so retry is idempotent. Cooperating instances serialize recursive config delta merges through `telegram.json.transaction` and preserve unrelated global/profile changes from newer disk snapshots. A semantically unchanged merge adopts the latest disk state in memory without replacing the file; later commits win when two deltas intentionally change the same leaf. Same-parent temp-file replacement retries bounded transient `EPERM`, `EACCES`, and `EBUSY` destination contention without deleting the live config or leaving transaction serialization. For manual edits, stop or idle the connected instances, publish a complete valid file atomically, and let them reload. A non-transactional editor racing Pi persistence has no same-leaf conflict guarantee.
118
117
 
119
118
  Hidden/default semantics are represented by absence:
120
119
 
@@ -302,11 +302,6 @@ export default function (pi: Pi.ExtensionAPI) {
302
302
  persist: configStore.persist,
303
303
  markConfigChange: telegramSyncStateRuntime.markConfigChange,
304
304
  });
305
- const persistTelegramPollingOffset =
306
- Config.createTelegramPollingOffsetPersister(
307
- configStore,
308
- persistTelegramConfigWithSync,
309
- );
310
305
  const {
311
306
  current: currentInstanceThreadRuntime,
312
307
  status: threadStatusProjectionRuntime,
@@ -353,6 +348,10 @@ export default function (pi: Pi.ExtensionAPI) {
353
348
  getInboundWorkerState() {
354
349
  return updateAdmissionRuntimeBinding.getActive()?.getState();
355
350
  },
351
+ getAcceptedThroughUpdateId() {
352
+ return resolveTelegramUpdateJournalBinding()?.journal.read()
353
+ .acceptedThroughUpdateId;
354
+ },
356
355
  getActiveSourceMessageIds: activeTurnRuntime.getSourceMessageIds,
357
356
  hasActiveTurn: activeTurnRuntime.has,
358
357
  hasDispatchPending: lifecycle.hasDispatchPending,
@@ -1009,14 +1008,36 @@ export default function (pi: Pi.ExtensionAPI) {
1009
1008
  hasBotToken: configStore.hasBotToken,
1010
1009
  deleteWebhook,
1011
1010
  getUpdates,
1012
- persistConfig: persistTelegramPollingOffset,
1011
+ persistConfig: persistTelegramConfigWithSync,
1013
1012
  prepareUpdateBatch: textGroupRuntime.prepareUpdateBatch,
1014
1013
  journal: {
1015
- appendBatch(updates) {
1014
+ appendBatch(updates, acceptedThroughUpdateId) {
1016
1015
  return updateAdmissionLifecycleRuntime.appendBatch(
1017
1016
  updates as Journal.TelegramJournaledUpdate[],
1017
+ acceptedThroughUpdateId,
1018
1018
  );
1019
1019
  },
1020
+ getAcceptedThroughUpdateId() {
1021
+ return resolveTelegramUpdateJournalBinding()?.journal.read()
1022
+ .acceptedThroughUpdateId;
1023
+ },
1024
+ async prepareCursorCutover() {
1025
+ const binding = resolveTelegramUpdateJournalBinding();
1026
+ if (!binding) {
1027
+ throw new Error("Telegram update journal binding is unavailable.");
1028
+ }
1029
+ await Polling.cutOverTelegramPollingCursor({
1030
+ getLegacyCursor: configStore.getLegacyPollingCursor,
1031
+ readJournal: binding.journal.read,
1032
+ publishJournalCursor(acceptedThroughUpdateId) {
1033
+ binding.journal.appendBatch([], acceptedThroughUpdateId);
1034
+ },
1035
+ async removeLegacyCursor() {
1036
+ configStore.removeLegacyPollingCursor();
1037
+ await persistTelegramConfigWithSync();
1038
+ },
1039
+ });
1040
+ },
1020
1041
  getEntryCount: updateAdmissionLifecycleRuntime.getJournalEntryCount,
1021
1042
  signalWorker: updateAdmissionLifecycleRuntime.signal,
1022
1043
  getBootstrapEntryCount() {
@@ -99,8 +99,6 @@ export interface TelegramConfig {
99
99
  botId?: number;
100
100
  /** @deprecated persisted identity belongs in profiles.default; retained for effective/legacy views */
101
101
  allowedUserId?: number;
102
- /** @deprecated persisted identity belongs in profiles.default; retained for effective/legacy views */
103
- lastUpdateId?: number;
104
102
  inboundHandlers?: TelegramInboundHandlerConfig[];
105
103
  attachmentHandlers?: TelegramInboundHandlerConfig[];
106
104
  outboundHandlers?: TelegramOutboundHandlerConfig[];
@@ -144,6 +142,9 @@ export interface TelegramBotProfile {
144
142
  botUsername?: string;
145
143
  botId?: number;
146
144
  allowedUserId?: number;
145
+ }
146
+
147
+ interface TelegramLegacyCursorCarrier {
147
148
  lastUpdateId?: number;
148
149
  }
149
150
 
@@ -177,6 +178,8 @@ export interface TelegramConfigStore {
177
178
  getBotToken: () => string | undefined;
178
179
  hasBotToken: () => boolean;
179
180
  getAllowedUserId: () => number | undefined;
181
+ getLegacyPollingCursor: () => number | undefined;
182
+ removeLegacyPollingCursor: () => void;
180
183
  getInboundHandlers: () => TelegramInboundHandlerConfig[] | undefined;
181
184
  getAttachmentHandlers: () => TelegramInboundHandlerConfig[] | undefined;
182
185
  getOutboundHandlers: () => TelegramOutboundHandlerConfig[] | undefined;
@@ -368,15 +371,7 @@ function mergeTelegramConfigDelta(
368
371
  continue;
369
372
  }
370
373
  if (!desiredHas) {
371
- if (key !== "lastUpdateId") delete merged[key];
372
- continue;
373
- }
374
- if (
375
- key === "lastUpdateId" &&
376
- typeof desiredValue === "number" &&
377
- typeof merged[key] === "number"
378
- ) {
379
- merged[key] = Math.max(desiredValue, merged[key] as number);
374
+ delete merged[key];
380
375
  continue;
381
376
  }
382
377
  if (
@@ -433,6 +428,8 @@ export function getTelegramProfileFields(
433
428
  ): TelegramBotProfile | undefined {
434
429
  const token = config.botToken?.trim();
435
430
  if (!token) return undefined;
431
+ const legacyCursor = (config as TelegramConfig & TelegramLegacyCursorCarrier)
432
+ .lastUpdateId;
436
433
  return {
437
434
  botToken: token,
438
435
  ...(config.botUsername !== undefined
@@ -442,9 +439,7 @@ export function getTelegramProfileFields(
442
439
  ...(config.allowedUserId !== undefined
443
440
  ? { allowedUserId: config.allowedUserId }
444
441
  : {}),
445
- ...(config.lastUpdateId !== undefined
446
- ? { lastUpdateId: config.lastUpdateId }
447
- : {}),
442
+ ...(legacyCursor !== undefined ? { lastUpdateId: legacyCursor } : {}),
448
443
  };
449
444
  }
450
445
 
@@ -456,7 +451,7 @@ function omitTelegramRootProfileFields(config: TelegramConfig): TelegramConfig {
456
451
  allowedUserId: _allowedUserId,
457
452
  lastUpdateId: _lastUpdateId,
458
453
  ...sharedConfig
459
- } = config;
454
+ } = config as TelegramConfig & TelegramLegacyCursorCarrier;
460
455
  return sharedConfig;
461
456
  }
462
457
 
@@ -477,7 +472,8 @@ export function normalizeTelegramDefaultProfileConfig(config: TelegramConfig): {
477
472
  if (Object.hasOwn(config, "botToken") && !legacyToken) {
478
473
  throw new Error("Legacy Telegram default profile has no bot token");
479
474
  }
480
- const legacyProfile: Partial<TelegramBotProfile> = {
475
+ const legacyProfile: Partial<TelegramBotProfile> &
476
+ TelegramLegacyCursorCarrier = {
481
477
  ...(legacyToken ? { botToken: legacyToken } : {}),
482
478
  ...(config.botUsername !== undefined
483
479
  ? { botUsername: config.botUsername }
@@ -486,8 +482,12 @@ export function normalizeTelegramDefaultProfileConfig(config: TelegramConfig): {
486
482
  ...(config.allowedUserId !== undefined
487
483
  ? { allowedUserId: config.allowedUserId }
488
484
  : {}),
489
- ...(config.lastUpdateId !== undefined
490
- ? { lastUpdateId: config.lastUpdateId }
485
+ ...((config as TelegramConfig & TelegramLegacyCursorCarrier)
486
+ .lastUpdateId !== undefined
487
+ ? {
488
+ lastUpdateId: (config as TelegramConfig & TelegramLegacyCursorCarrier)
489
+ .lastUpdateId,
490
+ }
491
491
  : {}),
492
492
  };
493
493
  if (!canonicalProfile && !legacyToken) {
@@ -609,6 +609,16 @@ export function createTelegramConfigStore(
609
609
  getBotToken: () => getEffectiveConfig().botToken,
610
610
  hasBotToken: () => !!getEffectiveConfig().botToken,
611
611
  getAllowedUserId: () => getEffectiveConfig().allowedUserId,
612
+ getLegacyPollingCursor: () =>
613
+ (getEffectiveConfig() as TelegramConfig & TelegramLegacyCursorCarrier)
614
+ .lastUpdateId,
615
+ removeLegacyPollingCursor: () => {
616
+ const next = {
617
+ ...(getEffectiveConfig() as TelegramConfig & TelegramLegacyCursorCarrier),
618
+ };
619
+ delete next.lastUpdateId;
620
+ setEffectiveConfig(next);
621
+ },
612
622
  getInboundHandlers: () => [
613
623
  ...(config.inboundHandlers ?? []),
614
624
  ...(config.attachmentHandlers ?? []),
@@ -703,27 +713,6 @@ export function createTelegramConfigStore(
703
713
  };
704
714
  }
705
715
 
706
- export function createTelegramPollingOffsetPersister(
707
- configStore: Pick<TelegramConfigStore, "get" | "set" | "persist">,
708
- persist: () => Promise<void> = () => configStore.persist(),
709
- ): (pollingConfig: { lastUpdateId?: number }) => Promise<void> {
710
- return async (pollingConfig) => {
711
- const nextOffset = pollingConfig.lastUpdateId;
712
- if (typeof nextOffset === "number") {
713
- const current = configStore.get();
714
- const currentOffset = current.lastUpdateId;
715
- configStore.set({
716
- ...current,
717
- lastUpdateId:
718
- typeof currentOffset === "number"
719
- ? Math.max(currentOffset, nextOffset)
720
- : nextOffset,
721
- });
722
- }
723
- await persist();
724
- };
725
- }
726
-
727
716
  export function createTelegramProactivePushChecker(
728
717
  configStore: Pick<TelegramConfigStore, "get">,
729
718
  ): () => boolean {
@@ -157,6 +157,7 @@ export interface TelegramUpdateJournalEntry {
157
157
  export interface TelegramUpdateJournalFile {
158
158
  version: typeof TELEGRAM_UPDATE_JOURNAL_VERSION;
159
159
  revision?: number;
160
+ acceptedThroughUpdateId?: number;
160
161
  profile: string;
161
162
  botIdentity: TelegramUpdateJournalBotIdentity;
162
163
  entries: TelegramUpdateJournalEntry[];
@@ -312,6 +313,7 @@ export interface TelegramUpdateJournalStore {
312
313
  read(): TelegramUpdateJournalSnapshot;
313
314
  appendBatch<TUpdate extends TelegramUpdateJournalInput>(
314
315
  updates: readonly TUpdate[],
316
+ acceptedThroughUpdateId?: number,
315
317
  ): TelegramUpdateJournalAppendResult;
316
318
  markQueued(
317
319
  receipt: TelegramUpdateJournalQueueReceipt,
@@ -385,6 +387,7 @@ export interface TelegramUpdateJournalSegment {
385
387
  version: typeof TELEGRAM_UPDATE_JOURNAL_VERSION;
386
388
  revision: number;
387
389
  previousRevision: number;
390
+ acceptedThroughUpdateId?: number;
388
391
  profile: string;
389
392
  botIdentity: TelegramUpdateJournalBotIdentity;
390
393
  upsertedEntries: TelegramUpdateJournalEntry[];
@@ -975,6 +978,7 @@ function parseJournalFile(
975
978
  !hasOnlyKeys(value, [
976
979
  "version",
977
980
  "revision",
981
+ "acceptedThroughUpdateId",
978
982
  "profile",
979
983
  "botIdentity",
980
984
  "entries",
@@ -983,6 +987,8 @@ function parseJournalFile(
983
987
  !Number.isSafeInteger(value.version) ||
984
988
  (value.revision !== undefined &&
985
989
  (!isSafeNonNegativeInteger(value.revision) || value.revision === 0)) ||
990
+ (value.acceptedThroughUpdateId !== undefined &&
991
+ !isSafeNonNegativeInteger(value.acceptedThroughUpdateId)) ||
986
992
  !isNonEmptyString(value.profile) ||
987
993
  !Array.isArray(value.entries) ||
988
994
  (value.operatorDispositions !== undefined &&
@@ -1009,6 +1015,17 @@ function parseJournalFile(
1009
1015
  );
1010
1016
  }
1011
1017
  }
1018
+ if (
1019
+ entries.length > 0 &&
1020
+ value.acceptedThroughUpdateId !== undefined &&
1021
+ value.acceptedThroughUpdateId < entries.at(-1)!.updateId
1022
+ ) {
1023
+ throw createJournalError(
1024
+ "invalid",
1025
+ path,
1026
+ "has an admission cursor behind its active entries",
1027
+ );
1028
+ }
1012
1029
  const queuedReceipts = new Map<
1013
1030
  string,
1014
1031
  {
@@ -1096,6 +1113,9 @@ function parseJournalFile(
1096
1113
  ...(value.revision !== undefined
1097
1114
  ? { revision: value.revision as number }
1098
1115
  : {}),
1116
+ ...(value.acceptedThroughUpdateId !== undefined
1117
+ ? { acceptedThroughUpdateId: value.acceptedThroughUpdateId as number }
1118
+ : {}),
1099
1119
  profile: value.profile,
1100
1120
  botIdentity: validateBotIdentity(value.botIdentity, path),
1101
1121
  entries,
@@ -1113,6 +1133,7 @@ function parseJournalSegment(
1113
1133
  "version",
1114
1134
  "revision",
1115
1135
  "previousRevision",
1136
+ "acceptedThroughUpdateId",
1116
1137
  "profile",
1117
1138
  "botIdentity",
1118
1139
  "upsertedEntries",
@@ -1122,6 +1143,8 @@ function parseJournalSegment(
1122
1143
  value.version !== TELEGRAM_UPDATE_JOURNAL_VERSION ||
1123
1144
  !isSafePositiveInteger(value.revision) ||
1124
1145
  !isSafeNonNegativeInteger(value.previousRevision) ||
1146
+ (value.acceptedThroughUpdateId !== undefined &&
1147
+ !isSafeNonNegativeInteger(value.acceptedThroughUpdateId)) ||
1125
1148
  !isNonEmptyString(value.profile) ||
1126
1149
  !Array.isArray(value.upsertedEntries) ||
1127
1150
  !Array.isArray(value.removedUpdateIds) ||
@@ -1140,6 +1163,18 @@ function parseJournalSegment(
1140
1163
  }
1141
1164
  upsertedIds.add(entry.updateId);
1142
1165
  }
1166
+ if (
1167
+ upsertedEntries.length > 0 &&
1168
+ value.acceptedThroughUpdateId !== undefined &&
1169
+ value.acceptedThroughUpdateId <
1170
+ Math.max(...upsertedEntries.map((entry) => entry.updateId))
1171
+ ) {
1172
+ throw createJournalError(
1173
+ "invalid",
1174
+ path,
1175
+ "has an admission cursor behind its segment upserts",
1176
+ );
1177
+ }
1143
1178
  const removedUpdateIds: number[] = [];
1144
1179
  const removedIds = new Set<number>();
1145
1180
  for (const updateId of value.removedUpdateIds) {
@@ -1162,6 +1197,9 @@ function parseJournalSegment(
1162
1197
  version: TELEGRAM_UPDATE_JOURNAL_VERSION,
1163
1198
  revision: value.revision,
1164
1199
  previousRevision: value.previousRevision,
1200
+ ...(value.acceptedThroughUpdateId !== undefined
1201
+ ? { acceptedThroughUpdateId: value.acceptedThroughUpdateId as number }
1202
+ : {}),
1165
1203
  profile: value.profile,
1166
1204
  botIdentity: validateBotIdentity(value.botIdentity, path),
1167
1205
  upsertedEntries,
@@ -1190,6 +1228,9 @@ function cloneFile(file: TelegramUpdateJournalFile): TelegramUpdateJournalFile {
1190
1228
  return {
1191
1229
  version: TELEGRAM_UPDATE_JOURNAL_VERSION,
1192
1230
  ...(file.revision !== undefined ? { revision: file.revision } : {}),
1231
+ ...(file.acceptedThroughUpdateId !== undefined
1232
+ ? { acceptedThroughUpdateId: file.acceptedThroughUpdateId }
1233
+ : {}),
1193
1234
  profile: file.profile,
1194
1235
  botIdentity: { ...file.botIdentity },
1195
1236
  entries: file.entries.map(cloneEntry),
@@ -1879,6 +1920,17 @@ export function createTelegramUpdateJournalStore(
1879
1920
  `has a revision gap after ${revision}`,
1880
1921
  );
1881
1922
  }
1923
+ if (
1924
+ segment.acceptedThroughUpdateId !== undefined &&
1925
+ file.acceptedThroughUpdateId !== undefined &&
1926
+ segment.acceptedThroughUpdateId < file.acceptedThroughUpdateId
1927
+ ) {
1928
+ throw createJournalError(
1929
+ "invalid",
1930
+ segmentPath,
1931
+ "regresses the admission cursor",
1932
+ );
1933
+ }
1882
1934
  if (
1883
1935
  segment.profile !== storedProfile ||
1884
1936
  !identitiesMatch(segment.botIdentity, storedIdentity)
@@ -1907,6 +1959,11 @@ export function createTelegramUpdateJournalStore(
1907
1959
  {
1908
1960
  version: TELEGRAM_UPDATE_JOURNAL_VERSION,
1909
1961
  revision: segment.revision,
1962
+ ...(segment.acceptedThroughUpdateId !== undefined
1963
+ ? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId }
1964
+ : file.acceptedThroughUpdateId !== undefined
1965
+ ? { acceptedThroughUpdateId: file.acceptedThroughUpdateId }
1966
+ : {}),
1910
1967
  profile: storedProfile,
1911
1968
  botIdentity: mergeBotIdentity(file.botIdentity, segment.botIdentity),
1912
1969
  entries: [...entriesById.values()].sort(
@@ -2136,6 +2193,7 @@ export function createTelegramUpdateJournalStore(
2136
2193
  entries: TelegramUpdateJournalEntry[],
2137
2194
  contentChanged: boolean,
2138
2195
  operatorDispositions = current.file.operatorDispositions,
2196
+ acceptedThroughUpdateId = current.file.acceptedThroughUpdateId,
2139
2197
  ): { file: TelegramUpdateJournalFile; serializedBytes: number } => {
2140
2198
  const botIdentity = mergeBotIdentity(
2141
2199
  current.file.botIdentity,
@@ -2147,7 +2205,8 @@ export function createTelegramUpdateJournalStore(
2147
2205
  isDeepStrictEqual(
2148
2206
  current.file.operatorDispositions ?? [],
2149
2207
  operatorDispositions ?? [],
2150
- )
2208
+ ) &&
2209
+ current.file.acceptedThroughUpdateId === acceptedThroughUpdateId
2151
2210
  ) {
2152
2211
  return { file: current.file, serializedBytes: current.serializedBytes };
2153
2212
  }
@@ -2156,6 +2215,9 @@ export function createTelegramUpdateJournalStore(
2156
2215
  profile,
2157
2216
  botIdentity,
2158
2217
  entries,
2218
+ ...(acceptedThroughUpdateId !== undefined
2219
+ ? { acceptedThroughUpdateId }
2220
+ : {}),
2159
2221
  ...(operatorDispositions?.length
2160
2222
  ? { operatorDispositions }
2161
2223
  : {}),
@@ -2164,6 +2226,7 @@ export function createTelegramUpdateJournalStore(
2164
2226
  const serializedBytes = assertCapacity(file, serialized);
2165
2227
  const changed =
2166
2228
  contentChanged ||
2229
+ current.file.acceptedThroughUpdateId !== acceptedThroughUpdateId ||
2167
2230
  !isDeepStrictEqual(current.file.botIdentity, file.botIdentity);
2168
2231
  if (!changed) {
2169
2232
  return { file, serializedBytes: current.serializedBytes };
@@ -2193,6 +2256,9 @@ export function createTelegramUpdateJournalStore(
2193
2256
  botIdentity: file.botIdentity,
2194
2257
  upsertedEntries,
2195
2258
  removedUpdateIds,
2259
+ ...(acceptedThroughUpdateId !== undefined
2260
+ ? { acceptedThroughUpdateId }
2261
+ : {}),
2196
2262
  ...(!isDeepStrictEqual(
2197
2263
  current.file.operatorDispositions ?? [],
2198
2264
  operatorDispositions ?? [],
@@ -2260,8 +2326,18 @@ export function createTelegramUpdateJournalStore(
2260
2326
  };
2261
2327
  });
2262
2328
  },
2263
- appendBatch(updates) {
2329
+ appendBatch(updates, requestedAcceptedThroughUpdateId) {
2264
2330
  return runMutation(() => {
2331
+ if (
2332
+ requestedAcceptedThroughUpdateId !== undefined &&
2333
+ !isSafeNonNegativeInteger(requestedAcceptedThroughUpdateId)
2334
+ ) {
2335
+ throw createJournalError(
2336
+ "invalid",
2337
+ path,
2338
+ "received an invalid admission cursor",
2339
+ );
2340
+ }
2265
2341
  const current = readCurrent();
2266
2342
  const normalizedUpdates: TelegramJournaledUpdate[] = [];
2267
2343
  for (const update of updates) {
@@ -2290,6 +2366,8 @@ export function createTelegramUpdateJournalStore(
2290
2366
  const entriesById = new Map(
2291
2367
  current.file.entries.map((entry) => [entry.updateId, entry]),
2292
2368
  );
2369
+ const previousAcceptedThroughUpdateId =
2370
+ current.file.acceptedThroughUpdateId;
2293
2371
  const discardedUpdateIds = new Set(
2294
2372
  (current.file.operatorDispositions ?? [])
2295
2373
  .filter((disposition) => disposition.action === "discard")
@@ -2335,6 +2413,31 @@ export function createTelegramUpdateJournalStore(
2335
2413
  addedUpdateIds.push(entry.updateId);
2336
2414
  }
2337
2415
 
2416
+ const batchLastUpdateId = normalizedUpdates.at(-1)?.update_id;
2417
+ if (
2418
+ requestedAcceptedThroughUpdateId !== undefined &&
2419
+ batchLastUpdateId !== undefined &&
2420
+ requestedAcceptedThroughUpdateId < batchLastUpdateId
2421
+ ) {
2422
+ throw createJournalError(
2423
+ "invalid",
2424
+ path,
2425
+ "received an admission cursor behind its batch",
2426
+ );
2427
+ }
2428
+ if (
2429
+ requestedAcceptedThroughUpdateId !== undefined &&
2430
+ previousAcceptedThroughUpdateId !== undefined &&
2431
+ requestedAcceptedThroughUpdateId < previousAcceptedThroughUpdateId
2432
+ ) {
2433
+ throw createJournalError(
2434
+ "conflict",
2435
+ path,
2436
+ "received a regressing admission cursor",
2437
+ );
2438
+ }
2439
+ const acceptedThroughUpdateId =
2440
+ requestedAcceptedThroughUpdateId ?? previousAcceptedThroughUpdateId;
2338
2441
  const contentChanged = addedUpdateIds.length > 0;
2339
2442
  const published = publishMutation(
2340
2443
  current,
@@ -2344,6 +2447,8 @@ export function createTelegramUpdateJournalStore(
2344
2447
  )
2345
2448
  : current.file.entries,
2346
2449
  contentChanged,
2450
+ current.file.operatorDispositions,
2451
+ acceptedThroughUpdateId,
2347
2452
  );
2348
2453
  return {
2349
2454
  addedUpdateIds,
@@ -103,7 +103,7 @@ export interface TelegramModelMenuRuntimeOptions<
103
103
  }
104
104
 
105
105
  export interface MenuSettingsManager {
106
- reload: () => Promise<void>;
106
+ reload?: () => Promise<void>;
107
107
  flush?: () => Promise<void>;
108
108
  getEnabledModels: () => string[] | undefined;
109
109
  setEnabledModels?: (patterns: string[] | undefined) => void;
@@ -498,7 +498,9 @@ export function createTelegramModelMenuStateBuilder<
498
498
  threadId,
499
499
  activeModel: deps.getActiveModel(ctx),
500
500
  ctx,
501
- reloadSettings: () => settingsManager.reload(),
501
+ reloadSettings: async () => {
502
+ await settingsManager.reload?.();
503
+ },
502
504
  getConfiguredScopedModelPatterns: () =>
503
505
  settingsManager.getEnabledModels(),
504
506
  });
@@ -8,7 +8,6 @@ type MaybePromise<T> = T | Promise<T>;
8
8
 
9
9
  export interface TelegramPollingConfig {
10
10
  botToken?: string;
11
- lastUpdateId?: number;
12
11
  }
13
12
 
14
13
  export interface TelegramUpdate {
@@ -227,6 +226,7 @@ export interface TelegramPollingAdmissionRuntime<TContext> {
227
226
 
228
227
  export function createTelegramPollingAdmissionRuntime<TContext>(deps: {
229
228
  polling: TelegramPollingController<TContext>;
229
+ prepareStart?: () => MaybePromise<void>;
230
230
  validateStart?: () => void;
231
231
  worker: {
232
232
  onSessionStart: (ctx: TContext) => Promise<void>;
@@ -235,6 +235,7 @@ export function createTelegramPollingAdmissionRuntime<TContext>(deps: {
235
235
  return {
236
236
  isActive: deps.polling.isActive,
237
237
  async start(ctx) {
238
+ await deps.prepareStart?.();
238
239
  deps.validateStart?.();
239
240
  await deps.worker.onSessionStart(ctx);
240
241
  await deps.polling.start(ctx);
@@ -256,7 +257,12 @@ export type TelegramDurablePollingRuntimeAssemblyDeps<
256
257
  "appendUpdateBatch" | "getJournalEntryCount" | "signalUpdateWorker"
257
258
  > & {
258
259
  journal: {
259
- appendBatch: (updates: readonly TUpdate[]) => MaybePromise<unknown>;
260
+ appendBatch: (
261
+ updates: readonly TUpdate[],
262
+ acceptedThroughUpdateId?: number,
263
+ ) => MaybePromise<unknown>;
264
+ getAcceptedThroughUpdateId: () => number | undefined;
265
+ prepareCursorCutover?: () => MaybePromise<void>;
260
266
  getEntryCount: () => number;
261
267
  signalWorker: () => void;
262
268
  getBootstrapEntryCount: () => number;
@@ -274,13 +280,15 @@ export function createTelegramDurablePollingRuntimeAssembly<
274
280
  const controller = createTelegramPollingControllerRuntime({
275
281
  ...deps,
276
282
  appendUpdateBatch: deps.journal.appendBatch,
283
+ getAcceptedThroughUpdateId: deps.journal.getAcceptedThroughUpdateId,
277
284
  getJournalEntryCount: deps.journal.getEntryCount,
278
285
  signalUpdateWorker: deps.journal.signalWorker,
279
286
  });
280
287
  const admission = createTelegramPollingAdmissionRuntime({
281
288
  polling: controller,
289
+ prepareStart: deps.journal.prepareCursorCutover,
282
290
  validateStart() {
283
- if (deps.getConfig().lastUpdateId !== undefined) return;
291
+ if (deps.journal.getAcceptedThroughUpdateId() !== undefined) return;
284
292
  if (deps.journal.getBootstrapEntryCount() === 0) return;
285
293
  throw new TelegramPollingCursorBootstrapError(
286
294
  "Telegram polling cursor is missing while the durable update journal is non-empty.",
@@ -356,6 +364,7 @@ export function createTelegramPollingControllerRuntime<
356
364
  getUpdatesRequestBudgetMs: deps.getUpdatesRequestBudgetMs,
357
365
  persistConfig: deps.persistConfig,
358
366
  appendUpdateBatch: deps.appendUpdateBatch,
367
+ getAcceptedThroughUpdateId: deps.getAcceptedThroughUpdateId,
359
368
  getJournalEntryCount: deps.getJournalEntryCount,
360
369
  signalUpdateWorker: deps.signalUpdateWorker,
361
370
  prepareUpdateBatch: deps.prepareUpdateBatch,
@@ -1185,6 +1194,32 @@ export class TelegramPollingCursorBootstrapError extends Error {
1185
1194
  }
1186
1195
  }
1187
1196
 
1197
+ export interface TelegramPollingCursorCutoverDeps {
1198
+ getLegacyCursor: () => number | undefined;
1199
+ readJournal: () => {
1200
+ acceptedThroughUpdateId?: number;
1201
+ entries: readonly { updateId: number }[];
1202
+ };
1203
+ publishJournalCursor: (acceptedThroughUpdateId: number) => MaybePromise<void>;
1204
+ removeLegacyCursor: () => MaybePromise<void>;
1205
+ }
1206
+
1207
+ /** Transfer one legacy config cursor into journal authority before deleting it. */
1208
+ export async function cutOverTelegramPollingCursor(
1209
+ deps: TelegramPollingCursorCutoverDeps,
1210
+ ): Promise<void> {
1211
+ const legacyCursor = deps.getLegacyCursor();
1212
+ if (legacyCursor === undefined) return;
1213
+ const snapshot = deps.readJournal();
1214
+ if (snapshot.acceptedThroughUpdateId === undefined) {
1215
+ const provenEntryCursor = snapshot.entries.at(-1)?.updateId;
1216
+ await deps.publishJournalCursor(
1217
+ Math.max(legacyCursor, provenEntryCursor ?? legacyCursor),
1218
+ );
1219
+ }
1220
+ await deps.removeLegacyCursor();
1221
+ }
1222
+
1188
1223
  export interface TelegramPollingBatchAdmissionResult {
1189
1224
  updateCount: number;
1190
1225
  latestUpdateId?: number;
@@ -1195,7 +1230,11 @@ export interface TelegramPollingBatchAdmissionDeps<
1195
1230
  > extends TelegramRuntimeEventRecorderPort {
1196
1231
  updates: readonly TUpdate[];
1197
1232
  config: TelegramPollingConfig;
1198
- appendBatch: (updates: readonly TUpdate[]) => MaybePromise<unknown>;
1233
+ appendBatch: (
1234
+ updates: readonly TUpdate[],
1235
+ acceptedThroughUpdateId?: number,
1236
+ ) => MaybePromise<unknown>;
1237
+ getAcceptedThroughUpdateId?: () => number | undefined;
1199
1238
  persistConfig: (config: TelegramPollingConfig) => Promise<void>;
1200
1239
  signalWorker: () => void;
1201
1240
  onPhaseChange?: (
@@ -1229,7 +1268,8 @@ export async function admitTelegramPollingUpdateBatch<
1229
1268
  deps: TelegramPollingBatchAdmissionDeps<TUpdate>,
1230
1269
  ): Promise<TelegramPollingBatchAdmissionResult> {
1231
1270
  if (deps.updates.length === 0) return { updateCount: 0 };
1232
- validateTelegramPollingBatch(deps.updates, deps.config.lastUpdateId);
1271
+ const acceptedThroughUpdateId = deps.getAcceptedThroughUpdateId?.();
1272
+ validateTelegramPollingBatch(deps.updates, acceptedThroughUpdateId);
1233
1273
  const latestUpdateId = getLatestTelegramUpdateId(deps.updates);
1234
1274
  if (latestUpdateId === undefined) return { updateCount: 0 };
1235
1275
  reportTelegramPollingPhase(
@@ -1237,18 +1277,7 @@ export async function admitTelegramPollingUpdateBatch<
1237
1277
  "persisting-journal",
1238
1278
  deps.updates[0]?.update_id,
1239
1279
  );
1240
- await deps.appendBatch(deps.updates);
1241
- reportTelegramPollingPhase(deps, "persisting-offset", latestUpdateId);
1242
- const previousUpdateId = deps.config.lastUpdateId;
1243
- deps.config.lastUpdateId = latestUpdateId;
1244
- try {
1245
- await deps.persistConfig(deps.config);
1246
- } catch (error) {
1247
- if (deps.config.lastUpdateId === latestUpdateId) {
1248
- deps.config.lastUpdateId = previousUpdateId;
1249
- }
1250
- throw error;
1251
- }
1280
+ await deps.appendBatch(deps.updates, latestUpdateId);
1252
1281
  try {
1253
1282
  deps.signalWorker();
1254
1283
  } catch (error) {
@@ -1275,7 +1304,11 @@ export interface TelegramPollLoopDeps<
1275
1304
  ) => Promise<TUpdate[]>;
1276
1305
  getUpdatesRequestBudgetMs?: (body: Record<string, unknown>) => number;
1277
1306
  persistConfig: (config: TelegramPollingConfig) => Promise<void>;
1278
- appendUpdateBatch: (updates: readonly TUpdate[]) => MaybePromise<unknown>;
1307
+ appendUpdateBatch: (
1308
+ updates: readonly TUpdate[],
1309
+ acceptedThroughUpdateId?: number,
1310
+ ) => MaybePromise<unknown>;
1311
+ getAcceptedThroughUpdateId?: () => number | undefined;
1279
1312
  getJournalEntryCount: () => number;
1280
1313
  signalUpdateWorker: () => void;
1281
1314
  prepareUpdateBatch?: (updates: readonly TUpdate[]) => void;
@@ -1301,7 +1334,11 @@ export interface TelegramPollLoopRunnerDeps<
1301
1334
  ) => Promise<TUpdate[]>;
1302
1335
  getUpdatesRequestBudgetMs?: (body: Record<string, unknown>) => number;
1303
1336
  persistConfig: (config: TelegramPollingConfig) => Promise<void>;
1304
- appendUpdateBatch: (updates: readonly TUpdate[]) => MaybePromise<unknown>;
1337
+ appendUpdateBatch: (
1338
+ updates: readonly TUpdate[],
1339
+ acceptedThroughUpdateId?: number,
1340
+ ) => MaybePromise<unknown>;
1341
+ getAcceptedThroughUpdateId?: () => number | undefined;
1305
1342
  getJournalEntryCount: () => number;
1306
1343
  signalUpdateWorker: () => void;
1307
1344
  prepareUpdateBatch?: (updates: readonly TUpdate[]) => void;
@@ -1356,6 +1393,7 @@ export function createTelegramPollLoopRunner<
1356
1393
  getUpdatesRequestBudgetMs: deps.getUpdatesRequestBudgetMs,
1357
1394
  persistConfig: deps.persistConfig,
1358
1395
  appendUpdateBatch: deps.appendUpdateBatch,
1396
+ getAcceptedThroughUpdateId: deps.getAcceptedThroughUpdateId,
1359
1397
  getJournalEntryCount: deps.getJournalEntryCount,
1360
1398
  signalUpdateWorker: deps.signalUpdateWorker,
1361
1399
  prepareUpdateBatch: deps.prepareUpdateBatch,
@@ -1494,14 +1532,16 @@ export async function runTelegramPollLoop<
1494
1532
  // ignore
1495
1533
  }
1496
1534
  if (
1497
- deps.config.lastUpdateId === undefined &&
1535
+ deps.getAcceptedThroughUpdateId?.() === undefined &&
1498
1536
  deps.getJournalEntryCount() > 0
1499
1537
  ) {
1500
1538
  throw new TelegramPollingCursorBootstrapError(
1501
1539
  "Telegram polling cursor is missing while the durable update journal is non-empty.",
1502
1540
  );
1503
1541
  }
1504
- if (deps.config.lastUpdateId === undefined) {
1542
+ if (
1543
+ deps.getAcceptedThroughUpdateId?.() === undefined
1544
+ ) {
1505
1545
  try {
1506
1546
  const request = buildTelegramInitialSyncRequest();
1507
1547
  reportTelegramPollingPhase(deps, "long-poll");
@@ -1514,8 +1554,7 @@ export async function runTelegramPollLoop<
1514
1554
  "persisting-offset",
1515
1555
  lastUpdateId,
1516
1556
  );
1517
- deps.config.lastUpdateId = lastUpdateId;
1518
- await deps.persistConfig(deps.config);
1557
+ await deps.appendUpdateBatch([], lastUpdateId);
1519
1558
  deps.recordRuntimeEvent?.(
1520
1559
  "polling",
1521
1560
  new Error("Initialized Telegram cursor without executing history."),
@@ -1538,7 +1577,9 @@ export async function runTelegramPollLoop<
1538
1577
  while (!deps.signal.aborted) {
1539
1578
  try {
1540
1579
  currentUpdateId = undefined;
1541
- const request = buildTelegramLongPollRequest(deps.config.lastUpdateId);
1580
+ const request = buildTelegramLongPollRequest(
1581
+ deps.getAcceptedThroughUpdateId?.(),
1582
+ );
1542
1583
  reportTelegramPollingPhase(deps, "long-poll");
1543
1584
  const updates = await requestTelegramUpdatesWithinBudget(deps, request);
1544
1585
  reportTelegramPollingResponse(deps, updates.length);
@@ -1549,6 +1590,7 @@ export async function runTelegramPollLoop<
1549
1590
  updates,
1550
1591
  config: deps.config,
1551
1592
  appendBatch: deps.appendUpdateBatch,
1593
+ getAcceptedThroughUpdateId: deps.getAcceptedThroughUpdateId,
1552
1594
  persistConfig: deps.persistConfig,
1553
1595
  signalWorker: deps.signalUpdateWorker,
1554
1596
  onPhaseChange: deps.onPhaseChange,
@@ -9,7 +9,6 @@ export interface TelegramSetupConfig {
9
9
  botId?: number;
10
10
  botUsername?: string;
11
11
  allowedUserId?: number;
12
- lastUpdateId?: number;
13
12
  }
14
13
 
15
14
  export interface TelegramBotTokenPromptSpec {
@@ -321,7 +321,6 @@ export interface TelegramBridgeStatusConfig {
321
321
  botToken?: string;
322
322
  botUsername?: string;
323
323
  allowedUserId?: number;
324
- lastUpdateId?: number;
325
324
  }
326
325
 
327
326
  export interface TelegramBridgeStatusRuntimeDeps<
@@ -337,6 +336,7 @@ export interface TelegramBridgeStatusRuntimeDeps<
337
336
  isPollingActive: () => boolean;
338
337
  getPollingState?: () => TelegramBridgePollingState;
339
338
  getInboundWorkerState?: () => TelegramBridgeInboundWorkerState | undefined;
339
+ getAcceptedThroughUpdateId?: () => number | undefined;
340
340
  getActiveSourceMessageIds: () => number[] | undefined;
341
341
  hasActiveTurn: () => boolean;
342
342
  hasDispatchPending: () => boolean;
@@ -717,7 +717,7 @@ export function createTelegramBridgeStatusRuntime<
717
717
  ...(deps.getInboundWorkerState
718
718
  ? { inboundWorker: deps.getInboundWorkerState() }
719
719
  : {}),
720
- lastUpdateId: config.lastUpdateId,
720
+ lastUpdateId: deps.getAcceptedThroughUpdateId?.(),
721
721
  activeSourceMessageIds: deps.getActiveSourceMessageIds(),
722
722
  pendingDispatch: deps.hasDispatchPending(),
723
723
  compactionInProgress: deps.isCompactionInProgress(),
@@ -1685,6 +1685,7 @@ export interface TelegramUpdateWorkerStateSnapshot {
1685
1685
  }
1686
1686
 
1687
1687
  export interface TelegramUpdateWorkerJournalSnapshot {
1688
+ acceptedThroughUpdateId?: number;
1688
1689
  entries: readonly {
1689
1690
  updateId: number;
1690
1691
  update: TelegramJournaledUpdate;
@@ -3669,7 +3670,10 @@ export interface TelegramUpdateAdmissionLifecycleJournalBinding {
3669
3670
  runtimeKey: string;
3670
3671
  recoveryKey: string;
3671
3672
  journal: TelegramUpdateWorkerJournalPort & {
3672
- appendBatch: (updates: readonly TelegramJournaledUpdate[]) => unknown;
3673
+ appendBatch: (
3674
+ updates: readonly TelegramJournaledUpdate[],
3675
+ acceptedThroughUpdateId?: number,
3676
+ ) => unknown;
3673
3677
  applyOperatorDisposition?: (
3674
3678
  input: TelegramUpdateJournalOperatorDispositionInput,
3675
3679
  ) => TelegramUpdateJournalOperatorDispositionResult;
@@ -4303,7 +4307,10 @@ export interface TelegramUpdateAdmissionLifecycleRuntime<TContext>
4303
4307
  onSessionStart: (ctx: TContext) => Promise<void>;
4304
4308
  onSessionShutdown: () => Promise<void>;
4305
4309
  onTransportChanged: (ctx?: TContext) => Promise<void>;
4306
- appendBatch: (updates: readonly TelegramJournaledUpdate[]) => unknown;
4310
+ appendBatch: (
4311
+ updates: readonly TelegramJournaledUpdate[],
4312
+ acceptedThroughUpdateId?: number,
4313
+ ) => unknown;
4307
4314
  discardQueueReceipt: (input: {
4308
4315
  queueKind: "prompt" | "control";
4309
4316
  receiptId: string;
@@ -4603,11 +4610,11 @@ export function createTelegramUpdateAdmissionLifecycleRuntime<TContext>(
4603
4610
  await stopCurrent(true);
4604
4611
  if (ctx !== undefined) await bind(ctx);
4605
4612
  }),
4606
- appendBatch(updates) {
4613
+ appendBatch(updates, acceptedThroughUpdateId) {
4607
4614
  if (!journal || !worker) {
4608
4615
  throw new Error("Telegram update admission worker is not active.");
4609
4616
  }
4610
- return journal.appendBatch(updates);
4617
+ return journal.appendBatch(updates, acceptedThroughUpdateId);
4611
4618
  },
4612
4619
  discardQueueReceipt(input) {
4613
4620
  if (!journal || !worker || !journal.discardQueued) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.36.11",
3
+ "version": "0.37.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -43,7 +43,7 @@
43
43
  "@llblab/pi-actors": "0.50.0",
44
44
  "@llblab/pi-codex-usage": "0.9.3",
45
45
  "@llblab/pi-grow-loop": "0.7.2",
46
- "@llblab/pi-telegram": "0.36.11"
46
+ "@llblab/pi-telegram": "0.37.1"
47
47
  },
48
48
  "bundledDependencies": [
49
49
  "@llblab/pi-actors",