@llblab/pi-telegram 0.23.0 → 0.23.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/AGENTS.md +14 -7
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +34 -59
- package/docs/architecture.md +1 -2
- package/docs/multi-instance-bus.md +1 -1
- package/index.ts +2 -6
- package/lib/bindings.ts +5 -9
- package/lib/bus-leader.ts +11 -128
- package/lib/config.ts +4 -5
- package/lib/thread-reconciler.ts +3 -71
- package/lib/threads.ts +0 -43
- package/package.json +3 -2
package/AGENTS.md
CHANGED
|
@@ -41,6 +41,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
41
41
|
- `/AGENTS.md`: Durable engineering and runtime conventions
|
|
42
42
|
- `/BACKLOG.md`: Canonical open work. Keep only open top-level tasks; when all subtasks under a top-level task are complete, remove that task from the backlog and record completed delivery in `CHANGELOG.md` if user-visible. Put detailed decomposition under the single owning top-level task with nested checkboxes and explicit done criteria instead of promoting completed slices into separate top-level backlog items.
|
|
43
43
|
- `/CHANGELOG.md`: Completed delivery history focused on the final released behavior and user/operator/developer impact. Prefer multiple domain-scoped bullets with an inline-code domain label followed by a colon, change, and impact—for example, `Lock Fencing`: change + impact—instead of accumulating unrelated changes into one long entry; never use square brackets for the domain label. Never include personal usernames, user/chat ids, message/thread ids, or operator-specific thread labels in changelog/docs evidence; use role placeholders such as `<remote-peer>`, `<paired-owner>`, or `assigned leader thread`. Do not record transient implementation churn such as "added then removed" mechanics, internal reversions, or cleanup of an abandoned intermediate path unless the final product surface exposes that as a meaningful migration/breaking change.
|
|
44
|
+
- `Pre-release changelog consolidation`: Treat the current version section as a working set during development, then consolidate it before the release commit, PR, tag, and GitHub Release. Merge repeated implementation and smoke chronology into the smallest truthful set of outcome-focused domain bullets; preserve distinct shipped behavior, safety/compatibility contracts, migrations, known limitations, and meaningful operator evidence. Remove superseded findings, repeated validation runs, and fixed-then-reworked mechanics. Do not rewrite older release sections during this gate.
|
|
44
45
|
|
|
45
46
|
## 4. Core Entities
|
|
46
47
|
|
|
@@ -85,11 +86,9 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
85
86
|
- Follower registration readiness should stay on the smallest critical path that makes the follower routable: create or reuse the Telegram target, persist the active binding, mark target-bindings fresh, and return the target to the follower. Registration requires a present exact generation. Cross-session target reuse and any carried target absent from current bindings require one synchronous visibility probe: explicit stale evidence may provision a replacement, while ambiguous/non-stale failure must reject registration without replay, binding invalidation, or speculative replacement. Persist an ambiguously observed absent target only as non-routable `probe-required` restoration evidence so targetless retries and leader reloads must probe the exact target again before activation or replacement. Registration and explicit disconnect mutations for one durable follower profile must serialize across awaited cleanup so an old runtime's disconnect cannot delete or unregister a replacement generation. Manual follower identity must survive a Pi process reload in the same terminal so reload is not treated as a new follower/thread; do not key durable follower ownership only by the short-lived Pi process id. Telegram-visible connected notices, duplicate/replaced thread cleanup, and other reconciliation work are important but non-critical; run them after registration and record slow/failing background steps as runtime events rather than delaying follower usability.
|
|
86
87
|
- Instance slots are extension-owned ring-order metadata. Allocate fresh slots from the `bot.lastSlot` cursor as `A` → `B` → ... → `Z` → `A`, allowing the cursor to move to lower letters after wraparound. Only current live/recovering bindings plus unexpired pending provisions and explicit reservations occupy slots; historical records must not block allocation indefinitely. Preserve a slot on ordinary current binding/thread reuse without advancing or rewinding the fresh-allocation cursor. Follower bindings survive process absence as stable restoration hints until explicit stale/deleted/offline evidence invalidates them. Successful target reuse must refresh the binding timestamp and replacement runtime identity; generic leader startup or heartbeat pruning must not erase the target merely because no follower registration is currently live. An authenticated live follower whose carried exact target is missing from current persisted bindings should recover that target instead of creating another Telegram thread; preserve a carried slot only when it does not collide with another current record. Preserve a cursor that belongs to a live or recoverable binding and continue skipping retained restart hints plus unexpired pending/reserved slots. Explicit stale/deleted/offline reconciliation may release that slot; generic process absence may not. The alphabet cap is a feature — compact single-letter tabs are scannable.
|
|
87
88
|
- Instance thread names are provisioner-authored identity metadata, not model-authored output. Fresh Threaded Mode threads select one of five curated compact 4-6 letter Latin names for the assigned slot letter using provisioning timestamp entropy, then create the Telegram thread with that thread name immediately. The title is the thread name itself, not `{slot} {threadName}` or `{slot} — {threadName}`. Do not register or reintroduce an agent-facing thread rename tool; removing the extra rename prompt/tool turn is an intentional simplicity constraint. Thread names must mirror into terminal status, live diagnostics, and `[telegram|thread:name]` through one target-aware current-instance identity resolver. Registered follower/leader metadata takes precedence over a stale shared record for the same target, with the record used only as fallback; do not hide a valid baked/custom thread name behind old slot-prefix validation. A follower that later becomes leader keeps its existing name; leadership changes are transport role changes, not identity resets.
|
|
88
|
-
- Telegram private-chat Threaded Mode treats `All` as an aggregate/control surface, not a durable `General` thread
|
|
89
|
-
- Unbound thread detection: when a message arrives from the owner in an unknown `message_thread_id` while Threaded Mode is active, the default runtime first reclaims it for the leader if the leader has no active bound thread and routes the prompt locally. Later unknown threads are preserved by default and receive an in-thread reroute chooser that can send the captured prompt to a live thread or restore a stale leader/follower binding through explicit restore actions; destructive cleanup happens only after explicit user confirmation and through `thread-reconciler`. If the operator wants another Pi instance, they start Pi in a terminal and run `/telegram-connect`; Telegram-originated hidden auto-spawn and `/thread`-style process launch flows are intentionally absent. If Threaded Mode is unavailable, the message is processed normally through classic routing.
|
|
89
|
+
- Telegram private-chat Threaded Mode treats `All` as an aggregate/control surface, not a durable `General` thread or process launcher, and leader activation proactively creates or reuses its private-DM thread. When the owner writes in an unknown thread, the leader reclaims and routes the first one only if it lacks an active binding; later unknown threads remain intact and receive an in-thread chooser to reroute the captured prompt or restore a stale binding. Destructive cleanup requires explicit confirmation through `thread-reconciler`; another Pi instance still requires a manually started process followed by `/telegram-connect`. Without Threaded Mode, process the message through classic routing.
|
|
90
90
|
- Telegram extension work should not hold Pi's core agent lifecycle hostage once Pi has produced the semantic result. For Telegram-originated turns, final Telegram delivery, attachment upload, and transport cleanup are extension-owned side effects; schedule them off the critical `agent_end` path when ordering can still be preserved, record failures as runtime events, and keep dispatch of the next Telegram turn behind the delivery task when needed to avoid interleaving previews/finals. Public activity handlers run through isolated asynchronous per-handler queues; lifecycle hooks enqueue normalized events and never await consumer work. Proactive push defaults enabled and requires explicit `assistant.proactivePush: false` to opt out. While enabled, core delivery projects each completed Pi-visible assistant text block from local/autonomous work once and in order; bind admitted blocks to the exact target, profile/token transport stamp, direct leader epoch or follower registration generation, and session generation. Token deltas, hidden reasoning, tool traffic, stale authority, and Telegram-owned turns never enter that projection.
|
|
91
91
|
- Telegram runtime state should be treated as event-driven reconciliation of extension assumptions against observable Telegram signals, not as a full Telegram bot read-model and not as a reason to query Telegram on every action. Keep a local assumption model for bot identity/capabilities, pairing assumptions, thread support, known thread lifecycle, bound instance targets, reservations, and transport health. Invalidate and reconcile the relevant slice on meaningful events: startup/reload, lifecycle service messages, stale-send/API errors, setup/pairing changes, follower register/prune, explicit status/debug requests, and bounded low-frequency leader health ticks. `lib/sync.ts` owns sync slices, observation intake, invalidation triggers, status/debug freshness, and reconciliation scheduling; it must not promise complete bot-state mirroring because Bot API lacks a complete topic listing surface. `lib/thread-reconciler.ts` owns pure Threaded Mode lifecycle planning and should become the only policy authority for destructive thread cleanup decisions. `threads` owns current Telegram UI thread/tab binding primitives and thread-name helpers mapped to Bot API topic transport, `status` renders projections, and `index.ts` only wires ports. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging and resets on extension start / runtime scope changes; it is diagnostics only, not routing/provisioning authority. `state.json` should be an observable/debug snapshot aligned with `/telegram-status` (extension runtime, bot capabilities including `bot.lastSlot`, runtime role, live roster projection, reservations, diagnostics), not routing/provisioning authority. Because every process on one Telegram profile shares this file, only the active transport lock owner may persist it; followers read current state and gain write authority only after promotion. Status-only writes must refresh disk-backed bindings before serialization so a stale loaded snapshot cannot erase newer leader state. Live bus/runtime state is authoritative; file reservations and fresh capability observations may be startup hints/collision guards only; stale snapshots must re-probe before suppressing bus/topic behavior. Do not persist stale/offline/failed thread target history as source of truth.
|
|
92
|
-
- Cross-session follower binding reuse must surface the target with one compact connected probe before registration succeeds. Explicit stale-topic rejection may replace the target with the next monotonic slot; ambiguous/non-stale send failure must not replay, replace, or claim proof that the tab is stale.
|
|
93
92
|
- Thread bindings are bidirectional. From the Pi-instance side, an instance knows and preserves its target, slot, thread name, lifecycle state, and direct-delivery defaults. From the Telegram-client/bot side, the bridge observes thread creation/close/reopen/stale-send signals and reconciles them into instance binding state. Lifecycle transitions that affect operator understanding should be Telegram-visible when a live transport remains: every successful follower registration/re-registration gets a compact connected notice in the assigned thread, while heartbeat pruning stays silent because it is only liveness suspicion. These notices should use the instance thread name when known and fall back to the slot letter only while the thread is still unnamed. New unknown threads (owner writing in `All`) are preserved until the user explicitly chooses a reroute/restore/new-instance action that makes cleanup eligible; any destructive cleanup must go through `thread-reconciler`. Unknown `forum_topic_created` service events are observations, not destructive cleanup proof.
|
|
94
93
|
- The multi-instance bus uses private bot DMs with Telegram private-chat Threaded Mode enabled for the bot. No supergroup, group, or forum configuration is needed; the thread chat is always the private chat with the paired owner. Threaded capability checks must use bot/private-chat evidence such as `getMe.has_topics_enabled`, incoming `message_thread_id`, and topic operation success/failure; do not use group chat metadata as the control-plane truth for Telegram private-chat Threaded Mode.
|
|
95
94
|
- Target-scoped behavior must preserve `{ chatId, threadId? }` through inbound messages/edits/callbacks/reactions, thread lifecycle service messages, media and split-text grouping, queue mutations, active-turn cleanup, previews, reply deduplication, menus, sections, generated buttons, voice, attachments, and direct delivery. Threaded Mode replies must keep Telegram's reply affordance to the triggering message just like classic DM mode. In Telegram private-chat Threaded Mode, mobile Telegram has been live-verified to render `sendMessage` replies that include top-level `message_thread_id` plus same-chat `reply_parameters.message_id`; Telegram Desktop may fail to show the visual reply header for the same correct payload, so do not treat Desktop-only absence as bridge failure without mobile/payload evidence.
|
|
@@ -138,7 +137,15 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
138
137
|
- For non-trivial implementation, release, or architecture-audit work, run an `AGENTS.md` compliance pass: reread the durable principles and relevant project docs, map the changed behavior to the rules it touches, validate code/tests/docs against those rules, and explicitly surface any rule conflict, obsolete rule, or evidence-backed improvement. When the rules themselves drift from the product reality, update `AGENTS.md` or document a deliberate exception in the same pass instead of silently working around it.
|
|
139
138
|
- Treat Windows Telegram runtime error reports as high-signal evidence even when Linux is the primary development environment. Windows uses different filesystem/IPC behavior and is more likely to reveal lock, heartbeat, named-pipe, atomic-rename, and Threaded Mode edge cases; minimize each report into a regression or a documented platform caveat instead of dismissing it as environment noise.
|
|
140
139
|
|
|
141
|
-
## 6.2
|
|
140
|
+
## 6.2 Agent Context Discipline
|
|
141
|
+
|
|
142
|
+
- Keep successful command output compact at the tool boundary. Redirect full validation logs to a temporary file, report only the command status and a small summary, and inspect a bounded failure tail or targeted range only when the command fails. `npm test` uses the dot reporter for this default; use `npm run test:verbose` only when individual test names provide necessary diagnostics.
|
|
143
|
+
- Read large artifacts search-first and range-bounded. Prefer path/content search, exact line ranges, `git diff --stat`, and path-scoped diffs over whole large files or complete repository diffs; the vendored Bot API reference must follow its stricter skill-local lookup contract.
|
|
144
|
+
- Treat `CHANGELOG.md` as a large artifact: read only the first 50 lines for the latest release by default; for an older release, locate its heading first and read only that bounded section. Never read the complete changelog merely to inspect one release.
|
|
145
|
+
- During implementation, prefer focused typechecks and tests. Run the complete validation suite only at a meaningful stable gate such as final review, release preparation, or after a cross-cutting correction.
|
|
146
|
+
- Bound independent review context: use focused reviewers for unresolved risks, then one complete independent review after implementation stabilizes. Do not repeatedly rebuild multiple full-project reviewer contexts without new evidence.
|
|
147
|
+
|
|
148
|
+
## 6.3 Validation Hotspots
|
|
142
149
|
|
|
143
150
|
- Treat queue handling, compaction interaction, and lifecycle-hook state transitions as regression-prone areas; validate them after changing dispatch logic
|
|
144
151
|
- Keep the standard `npm run typecheck` gate strict about unused locals and parameters so stale imports and abandoned adapters fail validation instead of accumulating silently
|
|
@@ -149,7 +156,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
149
156
|
- Prefer width-efficient monospace table and list formatting for narrow clients, with table padding based on grapheme/display width rather than raw UTF-16 length where possible
|
|
150
157
|
- Flatten nested Markdown quotes into indented single-blockquote output because Telegram does not render nested blockquotes reliably
|
|
151
158
|
|
|
152
|
-
## 6.
|
|
159
|
+
## 6.4 File And Naming Style
|
|
153
160
|
|
|
154
161
|
- Keep comments and user-facing docs in English unless the surrounding file already follows another convention
|
|
155
162
|
- Each project `.ts` file should start with a short multi-line responsibility header comment that explains the file boundary to future maintainers; source-module headers must include `Zones:` tags for cross-cutting responsibility areas
|
|
@@ -160,7 +167,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
160
167
|
- Keep composition wiring DRY with small local adapters or owning-domain contracts when repetition appears, but do not hide live mutable session state behind broad facades just to reduce repeated closures
|
|
161
168
|
- Keep interface contracts consistent for the same runtime entity: prefer the owning domain's exported contract when multiple modules mean the same entity, and use local structural `*Like`/view contracts only for deliberate narrow projections that avoid real coupling without duplicating source-of-truth shapes
|
|
162
169
|
|
|
163
|
-
## 6.
|
|
170
|
+
## 6.5 Current Domain Ownership Snapshot
|
|
164
171
|
|
|
165
172
|
The canonical detailed ownership map lives in [`docs/architecture.md`](./docs/architecture.md). Keep this section as a compact agent-facing index, not a second copy of the full map.
|
|
166
173
|
|
|
@@ -171,7 +178,7 @@ The canonical detailed ownership map lives in [`docs/architecture.md`](./docs/ar
|
|
|
171
178
|
- Extension platform: `sections` owns section registry, token mapping, callback dispatch, context building, and its globalThis bridge; `delivery` owns target-aware extension views, logical handles, target policy, ordering, lifecycle fencing, and its globalThis runtime membrane; `activity` owns normalized lifecycle registration, activity/source identity, non-blocking dispatch, delivery contexts, and its globalThis runtime membrane; `voice` owns the voice-provider registry and its globalThis bridge
|
|
172
179
|
- Pi SDK boundary: `pi` owns direct pi imports and bound extension API ports; `bindings` owns pi-facing command/tool/lifecycle registration wiring extracted from the entrypoint
|
|
173
180
|
|
|
174
|
-
## 6.
|
|
181
|
+
## 6.6 Entrypoint And Import Boundaries
|
|
175
182
|
|
|
176
183
|
- Keep the preview domain as a thin streaming lifecycle controller only: draft ids, safe-prefix selection for `sendRichMessageDraft`, voice suppression, serialized flushes, diagnostics, and finalization state. Do not reintroduce assistant preview rendering there; keep `rendering.ts` scoped to bridge-owned UI/compat regular-message rendering rather than assistant or guest Markdown delivery. Drafts must only send structurally closed Markdown prefixes; draft failures are not proof that drafts are globally unsupported, so record the failure and skip that preview frame. Do not add raw plain-message fallback previews for assistant Markdown
|
|
177
184
|
- Preview/final delivery ordering is release-critical: finalization waits for active preview flushes, persisted final delivery should not be followed by a post-final draft-clear call that creates transient draft UI, and regressions should cover in-flight draft flush serialization plus final reply ordering.
|
package/BACKLOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Project Backlog
|
|
2
2
|
|
|
3
|
-
_This backlog tracks only open release-relevant work: hotfixes, live runtime verification, evidence-gated Telegram client follow-ups, and upstream Pi API blockers. Completed outcomes and validation evidence belong in `CHANGELOG.md`, not in this queue._
|
|
3
|
+
_This backlog tracks only open release-relevant work: hotfixes, bounded maintenance, live runtime verification, evidence-gated Telegram client follow-ups, and upstream Pi API blockers. Completed outcomes and validation evidence belong in `CHANGELOG.md`, not in this queue._
|
|
4
4
|
|
|
5
5
|
## P1 — Native Windows Threaded Mode Follow-Ups
|
|
6
6
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,53 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.23.1: Context Budget And Runtime Simplification
|
|
4
|
+
|
|
5
|
+
- `Bus Runtime Simplification`: Removed the no-op follower-binding recovery timer and grace configuration, the unused disconnected-announcement helper, and the duplicate follower-prune callback. Heartbeat pruning now emits one accurate diagnostic while preserving durable thread bindings. Impact: leader/follower recovery carries less dormant state and cannot imply offline cleanup that never occurs.
|
|
6
|
+
- `Thread Reconciliation Safety`: Removed unreachable reservation-probe/removal machinery and the uncalled heartbeat-prune destructive cleanup action. Explicit disconnect, replacement, previous-leader, and expired-provision cleanup retain their confirmation and leader-epoch fences. Impact: reconciliation matches the documented rule that heartbeat loss removes live routing authority without deleting a follower's recoverable Telegram thread.
|
|
7
|
+
- `Destination Resolution`: Collapsed proactive chat-id and target selection onto the target resolver's active-turn → assigned-thread → paired-chat priority, retaining a scalar adapter only where an API requires a chat id. Impact: proactive projection, activity typing, and Guest attachment staging cannot drift between duplicate destination sources.
|
|
8
|
+
- `Agent Context Budget`: Added compact successful-test output with an opt-in verbose reporter, bounded failure-log inspection, search-first large-artifact and Bot API lookup rules, scoped diff/review guidance, and stable-gate validation policy. Compressed repeated agent, architecture, and multi-instance contracts without weakening their safety meaning. Impact: ordinary validation and review consume substantially less model context while actionable diagnostics remain available in retained logs.
|
|
9
|
+
- `Validation`: Full typecheck/tests/audit/package dry-run, strict Domain DAG, ABCd context validation, repository hygiene checks, and an independent regression review all passed after cleanup. Impact: the behavior-preserving hotfix is release-ready with no identified blockers.
|
|
10
|
+
|
|
3
11
|
## 0.23.0: Telegram Bot API 10.2 Rich Output And Proactive Projection
|
|
4
12
|
|
|
5
|
-
- `Follower
|
|
6
|
-
- `Explicit Thread Disconnect`:
|
|
7
|
-
- `
|
|
8
|
-
- `
|
|
9
|
-
- `Promoted Leader Reload Live Smoke`: Confirmed that a follower promoted to leader records `follower-promoted-session-handoff`, reload restores the exact prior target/slot/name, and leader startup reports `reused: true` without creating a replacement tab. Impact: the original promoted-leader identity-loss blocker is closed with direct runtime evidence; the same three-instance smoke separately exposed follower-tab visibility and deterministic successor-order follow-ups.
|
|
10
|
-
- `Promoted Leader Reload Handoff`: Retained the promoted target, slot, and thread name immediately after successful election and refreshed that short-lived process-local handoff before leader session suspension, binding both paths to the exact Telegram profile owner key. Restoration persists the binding under acquired leader authority before topic provisioning. The fallback identity source includes the current instance's inherited manual-follower record even after registration stops, because repeated live diagnostics proved leader-only/default identity projection remained empty and no shutdown handoff event was emitted. The same restoration projection now keeps the inherited thread name visible as `Cinder electing`-style status instead of regressing to generic `telegram electing`. Impact: promotion and election status retain the existing instance identity, while promoted leader reload reclaims the same Telegram thread instead of resetting to slot `A` and allocating a new tab, as confirmed by the promoted-leader reload smoke.
|
|
11
|
-
- `Leader/Follower Election Cycle Smoke`: Confirmed live with two instances that leader disconnect promotes the registered follower, reconnecting the previous leader registers it as follower, disconnecting the promoted leader promotes the remaining follower back, and reconnecting the second instance restores it as follower. Impact: repeated bidirectional election, promotion, disconnect, and re-registration converge without manual takeover or a stranded topology.
|
|
13
|
+
- `Follower Visibility And Restoration`: Added one synchronous connected probe before cross-session target reuse or recovery of an exact target absent from current bindings. Success surfaces the existing tab; explicit stale-topic evidence provisions a monotonic replacement, while ambiguous failures preserve non-routable `probe-required` evidence and reject without replay or speculative replacement. Impact: followers cannot report invisible or deleted tabs as restored, and retries must resolve the exact target before activation.
|
|
14
|
+
- `Explicit Thread Disconnect`: Authenticated registration and disconnect with present exact generations, serialized both mutations per durable follower profile across awaited cleanup, and required thread-named confirmation plus successful deletion or explicit already-gone evidence before removing routing authority. Incomplete cleanup preserves leader/follower state with retry guidance, and promoted leaders authorize inherited cleanup through their current epoch. Impact: `/telegram-disconnect` cannot let an old runtime delete a replacement registration or falsely report success while its tab remains.
|
|
15
|
+
- `Follower Succession And Handoff`: Added the authenticated live slot roster to heartbeats so the lowest observed follower proceeds immediately on leader loss while higher slots defer for one bounded grace and then recheck exact atomic ownership. Promoted followers retain target, slot, thread name, profile ownership, and electing status through same-process session handoff; reload persists that identity under acquired leader authority before provisioning. Impact: failover prefers slot order without claiming consensus, dead lower candidates cannot block recovery indefinitely, and promoted reload reuses the existing tab instead of resetting identity.
|
|
16
|
+
- `Threaded Topology Evidence`: Two- and three-instance live smokes confirmed usable follower tabs, repeated bidirectional promotion/disconnect/re-registration, promoted-leader reload reuse, and healthy convergence without manual takeover. Occasional timing-dependent promotion order remains compatible with bounded grace because exact atomic ownership—not timing—fences leadership. Impact: visibility, restoration, and multi-instance recovery have direct operator evidence without overstating succession guarantees.
|
|
12
17
|
- `Composition Root Compression`: Reduced `index.ts` from 1,119 to 1,083 lines by moving assistant-output admission/authority/sender coordination into the existing Pi-binding orchestration domain, moving config-persist sync sequencing and stale-topic API recovery adapters into `sync`, and replacing local forwarding callbacks with direct live-port wiring where signatures already matched. Impact: the entrypoint retains visible runtime composition while low-level policy, failure projection, mutable sequencing, and reusable adapters stay with their owning domains.
|
|
13
|
-
- `
|
|
14
|
-
- `
|
|
15
|
-
- `
|
|
16
|
-
- `Follower Rich Attachment Smoke`: Confirmed from an assigned follower Telegram topic that one queued PNG plus final text arrives as one reply-anchored Rich Message, with no separate attachment notice, second upload, or duplicate final. Impact: Bot API 10.2 one-result media delivery now has live client evidence across both direct-leader and follower transports.
|
|
17
|
-
- `Follower Bidirectional Routing`: Propagated the exact live follower registration generation from target ownership into every leader-forwarded message envelope and recorded rejected forwarding acknowledgements. Impact: follower threads can receive Telegram prompts again without weakening replacement-generation fencing, and a future route rejection leaves actionable diagnostics instead of disappearing silently.
|
|
18
|
-
- `Follower Thread Restoration`: Preserved stable manual-follower owner/target bindings across process absence and changed replacement registration to reclaim the existing current thread rather than creating another same-named tab. Persisted bindings remain restart hints only; authenticated live registration remains mandatory routing authority, while explicit stale/deleted/offline evidence still invalidates reuse. Impact: restarting the same follower restores its recognizable thread and slot without allowing absent processes to receive work.
|
|
19
|
-
- `Inbound Prompt Topology Smoke`: Confirmed in a live voice reply to an image message that the current voice attachment and its transcription appear before the independent `[reply]` block and replied-message attachment context. Impact: live prompt evidence matches the deterministic current-user-first topology without separating an attachment from its derived output.
|
|
20
|
-
- `Inbound Prompt Topology`: Kept attachment-derived `[outputs]`, including voice transcription, immediately after the current user `[attachments]` block and moved independent reply/source context after the complete current-user material. Impact: attachment meaning remains topologically connected to the user input that produced it instead of being split by quoted-message context.
|
|
18
|
+
- `Forwarded Input`: Added a bounded one-second candidate window that joins ordinary short comments with an adjacent same-sender/chat/thread forward across polling responses, including Rich Message text and follower-routed updates. Nested forwarded photo, video, animation, audio, and voice-note media now render under source-attributed attachments immediately after the forward block. A post-reload live smoke confirmed comment → forward → attachment ordering with the image available as model input. Impact: comments and forwards form one correctly attributed Pi turn without relying on incidental polling batches or mislabeling forwarded media as current-user attachments.
|
|
19
|
+
- `Follower Routing And Restart`: Propagated the exact live registration generation into every leader-forwarded envelope, recorded rejected acknowledgements, and preserved stable manual-follower bindings across process absence as restart hints only. Replacement registration reclaims the current recognizable thread and slot, while authenticated live registration remains mandatory routing authority and explicit stale/deleted/offline evidence invalidates reuse. Impact: follower prompts remain generation-fenced and diagnosable, and restarts avoid duplicate same-named tabs without routing to absent processes.
|
|
20
|
+
- `Inbound Prompt Topology`: Kept attachment-derived `[outputs]`, including voice transcription, immediately after current-user `[attachments]`, with independent reply/source context following the complete current-user block. A live voice reply to an image confirmed the same current-user-first ordering. Impact: attachment meaning remains connected to the input that produced it instead of being split by quoted-message context.
|
|
21
21
|
- `Portable Lock Standard`: Synchronized `docs/locks.md` bit-for-bit with the reusable cross-project standard and removed Telegram-specific runtime, bus, profile, and release-history policy from that portable artifact. Impact: the standard can be copied into another singleton-extension project as a complete implementation contract, while project-specific behavior remains owned by architecture documentation.
|
|
22
|
-
- `Proactive
|
|
23
|
-
- `Assistant Config`: Moved the proactive local-result delivery switch under `assistant.proactivePush` beside assistant rendering and draft-preview policy, without retaining the removed top-level key as a compatibility alias. Impact: configuration and Settings UI use one coherent assistant namespace; operators who want projection disabled set `assistant.proactivePush` to `false`.
|
|
22
|
+
- `Proactive Configuration And Documentation`: Moved local/autonomous projection policy under `assistant.proactivePush` beside assistant rendering and draft-preview settings, enabled it when absent, retained nested explicit `false` as the canonical opt-out, and intentionally omitted a compatibility alias for the removed top-level key. Settings explain `on` (default) and `off`, while README, Activity, Outbound, Public API, architecture, and durable contracts document ordered public-block projection, exclusions, rendering, authority, follower routing, and no-replay behavior. Impact: configuration, operator copy, and maintainer contracts describe one coherent default-enabled feature with an explicit disable path.
|
|
24
23
|
- `Config Transactions`: Valid Telegram configuration snapshots now load directly from the atomically published file, while malformed-file recovery and every merge/write remain serialized by the cross-process transaction guard. Impact: ordinary `telegram.json` reads no longer create transaction-directory churn, without weakening concurrent persistence or destructive recovery safety.
|
|
25
|
-
- `Proactive Projection Documentation`: Updated the README, Activity, Outbound, Public API, architecture ownership map, and durable project contract to define `assistant.proactivePush` as ordered projection of every completed public local/autonomous text block rather than a final-only notification. Impact: commentary/final visibility, hidden reasoning/tool exclusions, Rich/HTML behavior, exact authority fences, follower routing, and no-replay semantics now have one consistent operator and maintainer contract.
|
|
26
24
|
- `Release Preflight`: Ran the complete 0.23.0 validation suite after follower visibility, bounded succession preference, explicit cross-role thread disconnect, cross-batch grouping, and forwarded Rich media fixes: 1,292 tests passed with one platform-only skip, typecheck passed, npm audit reported zero vulnerabilities, package dry-run produced `@llblab/pi-telegram@0.23.0`, and `git diff --check` stayed clean. Impact: deterministic, packaging, dependency, and repository hygiene gates are green, and the final independent release review reported SHIP with no blockers.
|
|
27
|
-
- `
|
|
28
|
-
- `
|
|
29
|
-
- `Proactive
|
|
30
|
-
- `
|
|
31
|
-
- `
|
|
32
|
-
- `
|
|
33
|
-
- `
|
|
34
|
-
- `
|
|
35
|
-
- `Proactive Replacement Fence`: Added an extension-level barrier where an old session starts one proactive send, queues its final behind that send, shuts down, and starts a replacement session before the first acknowledgement returns. Only the already-started mutation executes; the queued old-generation final is dropped. Impact: session replacement cannot deliver delayed proactive blocks through the replacement runtime.
|
|
36
|
-
- `Proactive Multi-Block Integration`: Added an extension-level local-run fixture with a completed checkpoint, tool boundary, hidden reasoning event, final block, and `agent_end`. It proves configured Rich delivery emits checkpoint then final exactly once and in order, excludes reasoning text, and does not revive the removed final-only path. Impact: the Grow-Loop-shaped output sequence now has deterministic end-to-end coverage without any runtime dependency on Grow Loop.
|
|
37
|
-
- `Proactive Projection Wiring`: Connected normalized Activity `assistant-segment` events to Activity's assistant-output projection, rendered each admitted local/autonomous block through the configured Rich/HTML assistant sender and authorized instance target, started/stopped projection with the session generation, and removed Queue's old final-only `agent_end` sender. Impact: intermediate and final public blocks now share one ordered non-blocking path with no final duplication, while Telegram-owned turns and authority failures remain excluded.
|
|
38
|
-
- `Assistant Output Projection`: Extended Activity's normalized output runtime to admit completed local/autonomous public assistant segments, deduplicate event identity, preserve source order, revalidate policy and delivery authority before each send, isolate failures, and drop queued work after generation stop. Impact: lifecycle wiring projects intermediate and final public blocks without a new domain, blocking Pi, or importing Grow Loop behavior; Telegram/unknown sources, empty text, disabled policy, and stale queued work remain silent.
|
|
39
|
-
- `Proactive Output Boundary`: Confirmed Pi's `message_update.assistantMessageEvent` provides `text_end` as the completed public text boundary, distinct `thinking_*` and `toolcall_*` event families, and optional commentary/final phase signatures. Added an autonomous multi-block regression proving the existing Activity normalizer flushes completed text as intermediate/final assistant segments while reasoning and tool boundaries never enter segment text. Impact: proactive projection can consume normalized public blocks instead of token deltas or provider-private content without depending on Grow Loop.
|
|
40
|
-
- `Integrated Rich Attachment Smoke`: After a full Pi reload, delivered one ordinary `telegram_attach` PNG plus final Markdown through the configured Rich assistant path in an assigned private topic. The operator confirmed one reply-anchored composite message with no duplicate attachment notice or second upload. Impact: the implemented direct-leader orchestration has live evidence beyond raw Bot API probing.
|
|
41
|
-
- `Rich Thinking Safety`: Restricted the internal outgoing `InputRichMessage` contract to Markdown or HTML plus typed media, excluding explicit block arrays, and added an invariant that core reply, preview, queue, and attachment paths never construct Telegram Thinking blocks. Impact: draft-only Thinking placeholders cannot become an accidental transport for hidden reasoning; any future Activity use must start from explicitly public text.
|
|
42
|
-
- `Rich Output Documentation`: Documented the single-artifact Rich result, exact ownership, reply/thread targeting, HTML/voice/Guest/multi-file compatibility boundaries, known-failure fallback, ambiguity no-replay rule, and remaining live follower evidence across outbound, architecture, multi-instance bus, public API, and docs navigation references. Impact: documented behavior now matches only probe-confirmed and deterministically tested implementation rather than broader Bot API possibility.
|
|
43
|
-
- `Rich Attachment Delivery`: Integrated exactly one probe-confirmed PNG/JPEG photo, MP4 video, or MP3 audio artifact with final assistant Markdown into one configured-Rich multipart result. Successful sends record exact message ownership; known-safe rejection returns to the existing text-plus-attachment path, while `commit-unknown` and malformed successful results stop without fallback or replay. HTML mode, multiple/unsupported artifacts, Guest Mode, voice-only output, explicit voice markup, and OGG/Opus remain unchanged. Impact: requested supported media can arrive as one reply-anchored final result without weakening rendering choice, voice policy, generation checks, or ambiguity safety.
|
|
44
|
-
- `Rich Attachment Planning`: Added an outbound-domain planner for exactly one probe-confirmed PNG/JPEG photo, MP4 video, or MP3 audio artifact in configured Rich mode. It constructs one target-scoped, reply-anchored multipart Rich Message with normalized Markdown and optional inline keyboard, while HTML mode, multiple artifacts, empty text, and OGG/Opus voice remain on existing paths. Impact: assistant integration can consume one deterministic request plan without changing voice policy or compatibility behavior.
|
|
45
|
-
- `Follower Rich Upload Authorization`: Allowed target-scoped multipart `sendRichMessage` calls through the follower API boundary and added exact own-thread acceptance plus cross-thread rejection coverage. Impact: a future probe-confirmed Rich media upload can use the same direct/follower runtime without widening follower authority beyond its registered target.
|
|
46
|
-
- `Rich Media Live Probe`: Confirmed Bot API and client behavior in an assigned private topic for one composite Rich Message containing HTTPS photo/video/audio, one multipart `attach://` voice note, and one cached-`file_id` voice note. The operator confirmed the composite media renders as one result and the independently requested voice notes remain separate messages; all three probe messages were then deleted successfully. Impact: URL, multipart, cached-file, media-classification, topic-targeting, and composite one-result behavior now have live evidence for bounded assistant-path integration.
|
|
47
|
-
- `Structured Rich Markdown`: Added one Bot API 10.2 fixture that preserves a native table, inline and display mathematics, a preformatted code block, details, unordered and ordered lists, and quotation content through normalization and final `sendRichMessage` request construction. Impact: the established Markdown renderer already covers the targeted structured blocks without a parallel explicit-block renderer.
|
|
48
|
-
- `Rich Media Request Probe`: Added deterministic direct and follower transport probes for Rich Message HTTPS photo references, cached voice-note `file_id`, and single-file `attach://` multipart request construction. Impact: JSON and multipart payload shapes now cross existing transport boundaries unchanged; Bot API acceptance and Telegram client rendering remain explicitly live-gated before automatic assistant integration.
|
|
49
|
-
- `Rich Media Transport Types`: Added narrow Bot API 10.2 types for `InputRichMessage.media`, `InputRichMessageMedia`, photo/video/animation/audio media, and `InputMediaVoiceNote`, including native Rich Message body pass-through across direct and follower bus transports. Impact: URL or cached-file Rich media requests can retain their complete typed shape without exposing raw Bot API credentials or changing current rendering behavior; multipart upload remains evidence-gated open work.
|
|
50
|
-
- `Telegram Bot API Reference`: Synchronized the vendored Telegram bot skill reference with Bot API 10.2 while preserving the established changelog, section, object-table, and method structure. Added complete Rich Message input blocks/media, voice-note media, Ephemeral Messages, Communities, subscription updates, and refreshed task-oriented line/anchor indexes. Impact: implementation and review can resolve current 10.2 fields and methods locally instead of relying on stale model knowledge.
|
|
25
|
+
- `Proactive Projection`: Extended the existing Activity assistant-output path to project deduplicated completed public `text_end` segments from local and autonomous work through the configured Rich/HTML sender. Intermediate and final blocks share one ordered, non-blocking queue; Telegram-owned turns, hidden reasoning, tool traffic, empty text, disabled policy, and the removed final-only `agent_end` path remain excluded. Impact: public checkpoints and finals arrive once and in source order without a parallel projection domain or a Grow Loop dependency.
|
|
26
|
+
- `Proactive Authority And Ordering`: Bound every admitted block to its target, profile/token transport stamp, session generation, and exact direct leader epoch or follower registration generation, then revalidated authority immediately before each non-idempotent send/edit mutation. Direct and follower barriers prove queued stale-generation work drops, follower IPC preserves order, replacement transport receives no old mutation, and ambiguous transport outcomes retain no-replay behavior. Impact: asynchronous handlers, ownership changes, follower re-registration, and session replacement cannot redirect or duplicate delayed projection.
|
|
27
|
+
- `Proactive Projection Evidence`: Deterministic local-run, direct/follower ordering, privacy, and replacement-fence coverage plus a post-reload live smoke confirmed that one public commentary checkpoint and final arrived separately and in order in the assigned thread while internal tool traffic stayed silent. Impact: the normalized multi-block lifecycle has both regression and client evidence without duplicate output.
|
|
28
|
+
- `Rich Attachment Results`: Added one deterministic outbound plan that combines exactly one probe-confirmed PNG/JPEG photo, MP4 video, or MP3 audio artifact with normalized final assistant Markdown, reply/thread targeting, and an optional inline keyboard in one configured-Rich multipart result. Known-safe rejection returns to text plus attachment; `commit-unknown` or malformed success stops without replay. HTML mode, multiple or unsupported artifacts, empty text, Guest Mode, voice-only output, explicit voice markup, and OGG/Opus retain their established paths. Impact: supported requested media can arrive as one reply-anchored result without weakening rendering choice, voice policy, or ambiguity safety.
|
|
29
|
+
- `Rich Authority And Safety`: Recorded exact message ownership, authorized follower multipart upload only to its registered thread, and fenced scheduled or in-flight Rich finals across session replacement and ownership loss. Old generations cannot start multipart mutation or continue with preview cleanup, fallback delivery, or queue dispatch after authority changes. The outgoing Rich contract permits Markdown/HTML plus typed media but excludes Thinking blocks, with invariants across reply, preview, queue, and attachment paths. Impact: replacement sessions, cross-thread followers, and hidden reasoning cannot redirect or duplicate Rich delivery.
|
|
30
|
+
- `Rich Transport And Rendering`: Added narrow Bot API 10.2 media and voice-note types plus direct/follower JSON, cached-file, HTTPS, and single-file `attach://` multipart transport coverage. Structured Markdown regressions preserve tables, inline/display mathematics, code, details, lists, and quotations through normalization and final request construction without a parallel block renderer. Impact: supported Rich content crosses both transport roles with its typed shape and native structure intact.
|
|
31
|
+
- `Rich Client Evidence`: Post-reload direct-leader and follower Telegram-turn smokes confirmed one queued PNG plus final Markdown arrives as one reply-anchored composite result with no duplicate upload, attachment notice, or final. A separate live probe confirmed HTTPS photo/video/audio composites plus multipart and cached-`file_id` voice notes, with independently requested voice notes remaining separate messages. Impact: the bounded integration has deterministic transport coverage and direct client evidence across leader and follower paths.
|
|
32
|
+
- `Rich Output Contracts And Reference`: Documented single-artifact behavior, ownership, targeting, compatibility fallbacks, ambiguity no-replay, and evidence boundaries across operator and maintainer docs. Synchronized the vendored Bot API reference with 10.2 Rich Message blocks/media, voice notes, Ephemeral Messages, Communities, subscription updates, and refreshed lookup indexes while preserving its full-reference structure. Impact: implementation and review can resolve current behavior and API fields locally without overstating broader Bot API possibilities.
|
|
51
33
|
|
|
52
34
|
## 0.22.1: Termux-Compatible Filesystem Transactions
|
|
53
35
|
|
|
@@ -56,23 +38,16 @@
|
|
|
56
38
|
|
|
57
39
|
## 0.22.0: Concurrency And Runtime Ownership Hardening
|
|
58
40
|
|
|
59
|
-
- `Composition Root`: Reduced `index.ts` from 1,534 to 1,077 lines by moving transport generations, Threaded Mode orchestration,
|
|
60
|
-
- `
|
|
61
|
-
- `
|
|
62
|
-
- `
|
|
63
|
-
- `
|
|
64
|
-
- `
|
|
65
|
-
- `
|
|
66
|
-
- `
|
|
67
|
-
- `
|
|
68
|
-
- `
|
|
69
|
-
- `Profile Handoff`: Preserved the selected profile identity across config reload, reordered profile activation so the old runtime stops before the new profile becomes visible, and stamped queue/final/Activity/Delivery work with immutable profile transport generations. Named-profile setup follows the same stop-old then commit-new order after validation. Impact: accepted same-profile work survives lock handoff, while old-profile work cannot use a replacement bot token or target.
|
|
70
|
-
- `Session Generation Fence`: Added one session generation registry across lifecycle contexts and guarded agent/tool/message, compaction, preview, final-delivery, control-dispatch, and shutdown paths against replacement contexts. Preview operations invalidate by generation, stale scheduled finals drop before delivery, shutdown rechecks after polling/preview awaits, and preview cleanup has a bounded wait. Impact: delayed callbacks from an old Pi session cannot clear or deliver through its replacement session.
|
|
71
|
-
- `Target And Ownership Scope`: Preserved immutable chat/thread targets on model-switch continuations, made public Delivery handles deeply frozen and privately bound, and scoped message ownership by bot profile plus exact follower registration generation. Forwarded traffic carries that generation, and follower edit/delete requires exact recorded message ownership. Impact: callers cannot retarget handles, and delayed or sibling follower traffic cannot mutate replacement-owned messages.
|
|
72
|
-
- `Idempotent Inbound Admission`: Retained successfully handled update ids until their polling offset commits, so config-write retries advance offsets without re-running update side effects. Deferred media albums and split-text groups now retain their exact message sets through asynchronous admission failures, suspend across session replacement, rebind to the replacement context, and retry until dispatch succeeds. Impact: polling/config retries and transient queue failures no longer duplicate accepted prompts or silently lose grouped input.
|
|
73
|
-
- `Method-Aware Bot API And Bus Retries`: Classified retry-safe Bot API methods separately from non-idempotent sends/uploads/topic creation. The local bus memoizes in-flight/completed request results, rejects request-id collisions, preserves structured ambiguity, and maps missing acknowledgements for non-idempotent follower calls to `TelegramApiCommitUnknownError`. Delivery exposes `commit-unknown` with any recoverable partial handle. Impact: response loss cannot silently authorize blind replay of messages, media, registrations, forwarded updates, or topic creation.
|
|
74
|
-
- `Secondary Shared State`: Revision-serialized same-process thread snapshots, serialized JSONL append/reset/rotation across processes, restricted destructive log reset to the exact transport owner, identity-checked malformed-config quarantine under the config transaction, and replaced bare PPID follower identity with process-birth identity (`/proc` start ticks where available, collision-resistant generation fallback elsewhere). Impact: delayed writers and PID reuse cannot erase newer state or reinterpret stale follower ownership.
|
|
75
|
-
- `Live Runtime Smoke`: After one clean process restart loaded the process-global generation fix, a local `/reload` retained the assigned leader thread, restored Telegram automatically, and accepted the next Telegram message without `/telegram-connect`, follower fallback, or takeover. A post-refactor private Guest Mode exchange then preserved `[telegram|guest:<remote-peer>]`, delivered a generated voice response, and delivered one requested text attachment through the one-result guest path. Impact: the stale self-lock belonged to the pre-fix runtime-generation collision rather than persistent state corruption, while the composition refactor preserves live thread, Guest identity, voice, and artifact delivery.
|
|
41
|
+
- `Composition Root And Validation`: Reduced `index.ts` from 1,534 to 1,077 lines by moving transport generations, Threaded Mode orchestration, request/ownership identity, synchronization, provisioning, lifecycle, diagnostics, Delivery policy, inbound authority, follower forwarding, and retry policy into their owning flat domains. Domain regressions and structural guards prohibit local runtime adapters, direct Node imports, dependency cycles, and leaf-domain drift. The release gate passed 1,229 tests with one platform-only skip, typecheck, strict Domain DAG, ABCd, invariants, package dry-run, zero-vulnerability audit, and `git diff --check`. Impact: the entrypoint retains high-level composition while release-critical policy remains independently testable and structurally enforced.
|
|
42
|
+
- `Lock Transactions And Fencing`: Serialized all `locks.json` acquisition, refresh, release, and dead-owner recovery through fail-closed cross-process transactions while preserving unrelated profile keys. Collision-resistant leader epochs, exact owner/profile retention, and time-monotonic same-process generations fence forced replacement, refresh, release, direct transport, state persistence, and reload handoff. Impact: concurrent or stale runtimes cannot both win ownership, reverse a replacement, mutate through replacement transport, or corrupt the shared registry.
|
|
43
|
+
- `Leader Startup And Follower Election`: Started exact-owner heartbeat refresh immediately after lock acquisition and retained it through binding handoff, provisioning, server startup, and polling; startup failure cleans up and releases ownership. Followers remain in re-registration recovery while an exact live lease exists, promote only after atomic stale/no-owner acquisition, and losers re-register with the winner using their carried target. Impact: slow startup, transient IPC loss, simultaneous followers, and session handoff cannot create split-brain polling, strand a follower, or duplicate its thread.
|
|
44
|
+
- `Reconciliation And Provisioning Fences`: Revalidated the stamped leader epoch before every destructive Bot API call, local deletion mutation, cleanup persistence, and provisioning boundary; missing ownership fails closed. Thread snapshots commit through exact lock transactions, pending creation intents survive displacement as serialized recovery evidence, and the next owner adopts successfully created targets without replaying creation. Impact: stale leaders cannot delete replacement-owned threads or publish authoritative bindings, while successful topic creation survives handoff without duplicates or deadlock.
|
|
45
|
+
- `Bus Endpoint Generations`: Bound each Unix bus server to a private generation socket and atomically published the stable profile endpoint as a relative symlink. Stop removes only its private path, replacement links remain intact, and startup waits for live legacy direct-socket servers before migration. Impact: delayed old-server teardown cannot unlink the replacement leader endpoint.
|
|
46
|
+
- `Configuration, Profiles, And Shared State`: Transactional `telegram.json` writers merge recursive deltas onto the latest snapshot, preserve monotonic profile offsets, and quarantine malformed config only behind identity-checked guards. Profile switching keeps the selected identity stable, stops old transport before exposing the new profile, and stamps queued/Activity/Delivery work with immutable profile generations. Revisioned thread snapshots, serialized JSONL rotation, owner-only destructive log reset, and process-birth follower identity protect secondary state. Impact: concurrent writers, delayed callbacks, profile replacement, and PID reuse cannot regress offsets, erase newer state, or reinterpret stale ownership.
|
|
47
|
+
- `Session, Target, And Message Ownership`: Added one generation registry across lifecycle, compaction, preview, final delivery, control dispatch, and shutdown so stale callbacks drop before touching replacement sessions. Model-switch continuations retain immutable targets; Delivery handles remain deeply frozen and privately bound; message ownership includes bot profile and exact follower registration generation. Impact: old sessions and sibling/replaced followers cannot retarget work, clear replacement state, or edit/delete messages they no longer own.
|
|
48
|
+
- `Idempotent Inbound Admission`: Retained handled update ids until polling-offset commit and preserved exact deferred album/split-text message sets across admission failure and session replacement. Suspended groups rebind to the replacement context and retry until dispatch succeeds. Impact: config retries and transient queue failures do not duplicate accepted prompts or silently lose grouped input.
|
|
49
|
+
- `Method-Aware Retry Safety`: Classified retry-safe Bot API methods separately from non-idempotent sends, uploads, and topic creation. The local bus memoizes request results, rejects id collisions, preserves ambiguity, maps missing non-idempotent acknowledgements to `TelegramApiCommitUnknownError`, and exposes `commit-unknown` with recoverable partial handles. Impact: response loss cannot authorize blind replay of messages, media, registrations, forwarded updates, or topic creation.
|
|
50
|
+
- `Live Runtime Evidence`: After a clean restart, local `/reload` retained the assigned leader thread, restored Telegram automatically, and accepted the next message without reconnect, follower fallback, or takeover. A private Guest Mode exchange preserved guest attribution and delivered generated voice plus a requested attachment through the one-result path. Impact: live evidence confirmed generation recovery, thread identity, Guest routing, voice, and artifact delivery after the hardening refactor.
|
|
76
51
|
|
|
77
52
|
## 0.21.1: Runtime And Session Semantics Hotfix
|
|
78
53
|
|
package/docs/architecture.md
CHANGED
|
@@ -126,7 +126,6 @@ Telegram configuration lives in `~/.pi/agent/telegram.json`. Polling ownership l
|
|
|
126
126
|
- Live polling owners require explicit takeover confirmation.
|
|
127
127
|
- Long-lived polling timers use snapshotted ownership context and stop local polling when the lock no longer points at their own process.
|
|
128
128
|
- `locks.json` owns only external Telegram control/polling. Local extension and accepted queue state remain per Pi instance when ownership moves, but previews, final delivery, dispatch transport mutations, and other delayed work stop until exact direct or follower authority becomes valid again; ownership loss never permits delivery through replacement transport.
|
|
129
|
-
- Proactive local/autonomous public-output projection is not accepted-turn delivery. It is allowed only when `assistant.proactivePush` is enabled and this instance has exact direct ownership or an authenticated follower registration. Every completed public block, including visible commentary and the final block, retains its admission-time target and transport authority; hidden reasoning, tools, token deltas, stale work, and Telegram-owned turns are excluded.
|
|
130
129
|
|
|
131
130
|
Deleting `locks.json` resets runtime ownership without deleting Telegram configuration.
|
|
132
131
|
|
|
@@ -146,7 +145,7 @@ Follower binding is manual and process-first: the operator starts another Pi pro
|
|
|
146
145
|
|
|
147
146
|
When Threaded Mode is enabled, writing a message in the `All` tab can create a new thread without an existing instance binding. The bridge detects this during update execution: if a message from the owner has a `message_thread_id` that no instance owns, the message is routed to the unbound-thread handler instead of the leader's normal message handler. In the default runtime, this handler first reclaims the thread for the leader when the leader has no active bound thread, assigns the current leader thread identity, persists the active binding, and serves the prompt locally. If the leader already has an active thread, the handler preserves the prompt in the source Telegram thread and shows a target-thread chooser; explicit successful routing may later close/delete only extra confirmed source threads through `thread-reconciler` proof-before-delete planning and stale-epoch fencing. Unknown `forum_topic_created` service events are recorded as observations and are not destructive cleanup proof, because Telegram can deliver creation events before local provisioning/binding writes become visible across reloads. If Threaded Mode is unavailable, the message is processed normally through classic routing.
|
|
148
147
|
|
|
149
|
-
Threadless messages from `All` are not routed as prompts once bound threads exist, because `All` cannot identify the owning Pi instance. Known commands
|
|
148
|
+
Threadless messages from `All` are not routed as prompts once bound threads exist, because `All` cannot identify the owning Pi instance. Known commands open a compact live-target chooser, while ordinary prompts get guidance to use a bound Pi thread. This prevents accidental empty tabs from black-holing prompts or bypassing the manual follower-registration contract above.
|
|
150
149
|
|
|
151
150
|
The routing identity split is deliberate:
|
|
152
151
|
|
|
@@ -374,7 +374,7 @@ All files containing routing, chat ids, thread ids, or process details use priva
|
|
|
374
374
|
- A missed heartbeat does not delete, close, mark offline, or send a disconnected notice for the follower's Telegram thread binding because the common cause may be leader reload, IPC handoff, or transient reconnect rather than a dead follower.
|
|
375
375
|
- Followers treat rejected/missing heartbeat acknowledgements as registration loss: retain the last known target locally, clear registered truth, try to re-register with the current leader, wait a short leader-reload grace window, and retry. They promote only after the exact leader lease becomes stale or inactive; a live owner with an unreachable endpoint leaves the follower disconnected/retrying rather than creating a competing poller.
|
|
376
376
|
- Persisted current manual-follower bindings survive process absence as restoration hints, but cannot receive inbound or outbound work until the replacement follower authenticates and registers with a fresh generation.
|
|
377
|
-
-
|
|
377
|
+
- Fresh registration sends a compact connected notice in the assigned thread; cross-session restoration uses that same notice as the visibility probe and follows the stale/ambiguous recovery contract defined above.
|
|
378
378
|
- Registration requires a present generation, and explicit disconnect requires that same exact live generation. Leader-side registration and disconnect mutations serialize per durable follower profile across old and replacement runtime instance IDs, so a replacement registration cannot overtake awaited destructive cleanup and an old disconnect cannot remove its successor's routing authority.
|
|
379
379
|
- Successful forwarded updates and follower-originated API calls refresh liveness, so active followers are not pruned only because the interval heartbeat tick lagged.
|
|
380
380
|
- Destructive follower thread teardown belongs to explicit `/telegram-disconnect` or confirmed reconciliation actions, not generic heartbeat pruning. A registered follower sends an authenticated request fenced by its exact registration generation; the active leader closes and deletes that follower's exact topic, marks its durable binding offline, removes live routing authority, and acknowledges completion before the follower stops. Cleanup counts as confirmed only after successful deletion or explicit already-gone evidence. Incomplete cleanup preserves the binding and registration for retry. A promoted leader uses its current owned leader epoch even when the inherited record still carries a historical `manual-follower` owner label.
|
package/index.ts
CHANGED
|
@@ -155,11 +155,6 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
155
155
|
contextStore: telegramSessionContextStore,
|
|
156
156
|
});
|
|
157
157
|
const activeTurnRuntime = Queue.createTelegramActiveTurnStore();
|
|
158
|
-
const proactivePushChatIdGetter =
|
|
159
|
-
Config.createTelegramProactivePushChatIdGetter({
|
|
160
|
-
getActiveTurnChatId: activeTurnRuntime.getChatId,
|
|
161
|
-
getAllowedUserId: configStore.getAllowedUserId,
|
|
162
|
-
});
|
|
163
158
|
const proactivePushTargetGetter =
|
|
164
159
|
Config.createTelegramProactivePushTargetGetter({
|
|
165
160
|
getActiveTurnTarget: activeTurnRuntime.getTarget,
|
|
@@ -171,6 +166,8 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
171
166
|
},
|
|
172
167
|
getAllowedUserId: configStore.getAllowedUserId,
|
|
173
168
|
});
|
|
169
|
+
const proactivePushChatIdGetter =
|
|
170
|
+
Config.createTelegramProactivePushChatIdGetter(proactivePushTargetGetter);
|
|
174
171
|
const buttonActionStore = Outbound.createTelegramButtonActionStore();
|
|
175
172
|
const pendingModelSwitchStore =
|
|
176
173
|
Model.createPendingModelSwitchStore<
|
|
@@ -1067,7 +1064,6 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1067
1064
|
deleteMessage: deleteTelegramMessage,
|
|
1068
1065
|
sendGuestReply,
|
|
1069
1066
|
finalizeMarkdownPreview,
|
|
1070
|
-
proactivePushChatIdGetter,
|
|
1071
1067
|
proactivePushTargetGetter,
|
|
1072
1068
|
isProactivePushEnabled: configControls.isProactivePushEnabled,
|
|
1073
1069
|
getAssistantRenderingMode: configControls.getAssistantRenderingMode,
|
package/lib/bindings.ts
CHANGED
|
@@ -344,7 +344,6 @@ interface TelegramLifecycleBindingDeps {
|
|
|
344
344
|
Pi.AgentEndEvent["messages"][number],
|
|
345
345
|
Keyboard.TelegramInlineKeyboardMarkup
|
|
346
346
|
>["finalizeMarkdownPreview"];
|
|
347
|
-
proactivePushChatIdGetter: () => number | undefined;
|
|
348
347
|
proactivePushTargetGetter: () => Queue.TelegramQueueTarget | undefined;
|
|
349
348
|
isProactivePushEnabled: () => boolean;
|
|
350
349
|
getAssistantRenderingMode: () => "rich" | "html";
|
|
@@ -387,7 +386,6 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
387
386
|
deleteMessage,
|
|
388
387
|
sendGuestReply,
|
|
389
388
|
finalizeMarkdownPreview,
|
|
390
|
-
proactivePushChatIdGetter,
|
|
391
389
|
proactivePushTargetGetter,
|
|
392
390
|
isProactivePushEnabled,
|
|
393
391
|
getAssistantRenderingMode,
|
|
@@ -425,7 +423,7 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
425
423
|
caption?: string,
|
|
426
424
|
): Promise<void> => {
|
|
427
425
|
const stagingTarget = proactivePushTargetGetter();
|
|
428
|
-
const stagingChatId = stagingTarget?.chatId
|
|
426
|
+
const stagingChatId = stagingTarget?.chatId;
|
|
429
427
|
if (stagingChatId === undefined) {
|
|
430
428
|
throw new Error(
|
|
431
429
|
"Guest attachment staging requires a paired Telegram chat",
|
|
@@ -470,7 +468,7 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
470
468
|
caption?: string,
|
|
471
469
|
): Promise<void> => {
|
|
472
470
|
const stagingTarget = proactivePushTargetGetter();
|
|
473
|
-
const stagingChatId = stagingTarget?.chatId
|
|
471
|
+
const stagingChatId = stagingTarget?.chatId;
|
|
474
472
|
if (stagingChatId === undefined) {
|
|
475
473
|
throw new Error("Guest voice staging requires a paired Telegram chat");
|
|
476
474
|
}
|
|
@@ -595,11 +593,9 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
595
593
|
if (!canSendAgentActivity(ctx)) return false;
|
|
596
594
|
const turn = activeTurnRuntime.get();
|
|
597
595
|
const target = turn?.target ?? proactivePushTargetGetter();
|
|
598
|
-
promptDispatchRuntime.startTypingLoop(
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
{ target },
|
|
602
|
-
);
|
|
596
|
+
promptDispatchRuntime.startTypingLoop(ctx, turn?.chatId ?? target?.chatId, {
|
|
597
|
+
target,
|
|
598
|
+
});
|
|
603
599
|
return true;
|
|
604
600
|
};
|
|
605
601
|
const startActiveTurnTypingLoop = (ctx: Pi.ExtensionContext): void => {
|
package/lib/bus-leader.ts
CHANGED
|
@@ -109,17 +109,10 @@ export interface TelegramBusFollowerTargetProvisionerDeps {
|
|
|
109
109
|
) => void;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
export interface
|
|
112
|
+
export interface TelegramBusFollowerDisconnectHandlerDeps {
|
|
113
113
|
topicTargetStore: Pick<
|
|
114
114
|
Threads.TelegramTopicTargetStore,
|
|
115
|
-
| "
|
|
116
|
-
| "getActiveByInstanceId"
|
|
117
|
-
| "list"
|
|
118
|
-
| "listPendingProvisions"
|
|
119
|
-
| "markStaleByTarget"
|
|
120
|
-
| "markOfflineByInstanceId"
|
|
121
|
-
| "persist"
|
|
122
|
-
| "removePendingProvision"
|
|
115
|
+
"markOfflineByInstanceId" | "persist"
|
|
123
116
|
>;
|
|
124
117
|
callApi: <TResponse>(
|
|
125
118
|
method: string,
|
|
@@ -161,11 +154,9 @@ export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
|
|
|
161
154
|
runtime: Omit<
|
|
162
155
|
TelegramBusLeaderRuntimeDeps<TContext>,
|
|
163
156
|
| "callApi"
|
|
164
|
-
| "onFollowerPruned"
|
|
165
157
|
| "onFollowerDisconnected"
|
|
166
158
|
| "provisionFollowerTarget"
|
|
167
159
|
| "provisionLeaderTarget"
|
|
168
|
-
| "reconcileFollowerBindings"
|
|
169
160
|
| "recordRuntimeEvent"
|
|
170
161
|
>;
|
|
171
162
|
getAllowedUserId: () => number | undefined;
|
|
@@ -218,9 +209,6 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
|
|
|
218
209
|
recordThreadReconciliationPlan: deps.recordThreadReconciliationPlan,
|
|
219
210
|
setLeaderTarget: deps.setLeaderTarget,
|
|
220
211
|
}),
|
|
221
|
-
onFollowerPruned: createTelegramBusFollowerPruneHandler({
|
|
222
|
-
...provisionerPorts,
|
|
223
|
-
}),
|
|
224
212
|
onFollowerDisconnected: createTelegramBusFollowerDisconnectHandler({
|
|
225
213
|
...provisionerPorts,
|
|
226
214
|
}),
|
|
@@ -228,12 +216,6 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
|
|
|
228
216
|
...provisionerPorts,
|
|
229
217
|
}),
|
|
230
218
|
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
231
|
-
reconcileFollowerBindings:
|
|
232
|
-
createTelegramBusFollowerBindingRealityReconciler({
|
|
233
|
-
topicTargetStore: deps.topicTargetStore,
|
|
234
|
-
followerRegistry: deps.runtime.followerRegistry,
|
|
235
|
-
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
236
|
-
}),
|
|
237
219
|
callApi: createTelegramBusLeaderApiProxy({
|
|
238
220
|
call: deps.callApi,
|
|
239
221
|
callMultipart: deps.callMultipart,
|
|
@@ -244,29 +226,6 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
|
|
|
244
226
|
});
|
|
245
227
|
}
|
|
246
228
|
|
|
247
|
-
export const TELEGRAM_BUS_RECENT_FOLLOWER_BINDING_GRACE_MS = 10_000;
|
|
248
|
-
|
|
249
|
-
export interface TelegramBusFollowerBindingRealityDeps {
|
|
250
|
-
topicTargetStore: Pick<
|
|
251
|
-
Threads.TelegramTopicTargetStore,
|
|
252
|
-
| "load"
|
|
253
|
-
| "list"
|
|
254
|
-
| "markOfflineByInstanceId"
|
|
255
|
-
| "forgetIdentityByProfileKey"
|
|
256
|
-
| "getBotState"
|
|
257
|
-
| "persist"
|
|
258
|
-
| "setBotState"
|
|
259
|
-
>;
|
|
260
|
-
followerRegistry: Pick<TelegramBusFollowerRegistry, "list">;
|
|
261
|
-
getNowMs?: () => number;
|
|
262
|
-
recentBindingGraceMs?: number;
|
|
263
|
-
recordRuntimeEvent: (
|
|
264
|
-
category: string,
|
|
265
|
-
error: unknown,
|
|
266
|
-
details?: Record<string, unknown>,
|
|
267
|
-
) => void;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
229
|
export interface TelegramBusFollowerMessageOwnershipRecord {
|
|
271
230
|
follower: TelegramBusFollowerView;
|
|
272
231
|
chatId: number;
|
|
@@ -296,15 +255,10 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
|
|
|
296
255
|
registration: TelegramBusInstanceRegistration,
|
|
297
256
|
) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
|
|
298
257
|
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
299
|
-
reconcileFollowerBindings?: () => Promise<unknown> | unknown;
|
|
300
258
|
provisionLeaderTarget?: (ctx: TContext) => Promise<void> | void;
|
|
301
259
|
getNowMs?: () => number;
|
|
302
260
|
followerPruneIntervalMs?: number;
|
|
303
261
|
followerStaleAfterMs?: number;
|
|
304
|
-
followerRecoveryGraceMs?: number;
|
|
305
|
-
onFollowerPruned?: (
|
|
306
|
-
follower: TelegramBusFollowerView,
|
|
307
|
-
) => Promise<void> | void;
|
|
308
262
|
onFollowerDisconnected?: (
|
|
309
263
|
follower: TelegramBusFollowerView,
|
|
310
264
|
) => Promise<void> | void;
|
|
@@ -315,23 +269,11 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
|
|
|
315
269
|
) => void;
|
|
316
270
|
}
|
|
317
271
|
|
|
318
|
-
export function createTelegramBusFollowerBindingRealityReconciler(
|
|
319
|
-
deps: TelegramBusFollowerBindingRealityDeps,
|
|
320
|
-
): () => Promise<number> {
|
|
321
|
-
return async () => {
|
|
322
|
-
await deps.topicTargetStore.load();
|
|
323
|
-
// A missing live registration proves only that the follower process is
|
|
324
|
-
// currently absent. Keep its durable owner/target binding so a replacement
|
|
325
|
-
// process with the same manual profile can reclaim the existing thread.
|
|
326
|
-
return 0;
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
|
|
330
272
|
export function createTelegramBusInstanceLifecycleAnnouncement(input: {
|
|
331
273
|
target: TelegramTarget & { threadId: number };
|
|
332
274
|
threadName?: string;
|
|
333
275
|
slot?: string;
|
|
334
|
-
state: "connected"
|
|
276
|
+
state: "connected";
|
|
335
277
|
}): TelegramBusFollowerLifecycleAnnouncement {
|
|
336
278
|
return {
|
|
337
279
|
target: { ...input.target },
|
|
@@ -340,23 +282,6 @@ export function createTelegramBusInstanceLifecycleAnnouncement(input: {
|
|
|
340
282
|
};
|
|
341
283
|
}
|
|
342
284
|
|
|
343
|
-
export function createTelegramBusFollowerDisconnectedAnnouncement(input: {
|
|
344
|
-
follower: TelegramBusFollowerView;
|
|
345
|
-
threadName?: string;
|
|
346
|
-
slot?: string;
|
|
347
|
-
}): TelegramBusFollowerLifecycleAnnouncement | undefined {
|
|
348
|
-
if (!input.follower.target?.threadId) return undefined;
|
|
349
|
-
return createTelegramBusInstanceLifecycleAnnouncement({
|
|
350
|
-
target: {
|
|
351
|
-
chatId: input.follower.target.chatId,
|
|
352
|
-
threadId: input.follower.target.threadId,
|
|
353
|
-
},
|
|
354
|
-
threadName: input.threadName,
|
|
355
|
-
slot: input.slot,
|
|
356
|
-
state: "disconnected",
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
|
|
360
285
|
const TELEGRAM_BUS_SLOW_FOLLOWER_REGISTRATION_MS = 1000;
|
|
361
286
|
|
|
362
287
|
function scheduleTelegramBusLeaderBackgroundTask(
|
|
@@ -753,23 +678,8 @@ export function createTelegramBusFollowerTargetProvisioner(
|
|
|
753
678
|
};
|
|
754
679
|
}
|
|
755
680
|
|
|
756
|
-
export function createTelegramBusFollowerPruneHandler(
|
|
757
|
-
deps: TelegramBusFollowerPruneHandlerDeps,
|
|
758
|
-
): (follower: TelegramBusFollowerView) => Promise<void> {
|
|
759
|
-
return async (follower) => {
|
|
760
|
-
deps.recordRuntimeEvent(
|
|
761
|
-
"bus",
|
|
762
|
-
"Telegram bus follower heartbeat stale; preserving thread binding",
|
|
763
|
-
{
|
|
764
|
-
phase: "follower-pruned",
|
|
765
|
-
instanceId: follower.instanceId,
|
|
766
|
-
},
|
|
767
|
-
);
|
|
768
|
-
};
|
|
769
|
-
}
|
|
770
|
-
|
|
771
681
|
export function createTelegramBusFollowerDisconnectHandler(
|
|
772
|
-
deps:
|
|
682
|
+
deps: TelegramBusFollowerDisconnectHandlerDeps,
|
|
773
683
|
): (follower: TelegramBusFollowerView) => Promise<void> {
|
|
774
684
|
return async (follower) => {
|
|
775
685
|
const target = follower.target;
|
|
@@ -1458,32 +1368,12 @@ export function createTelegramBusLeaderRuntime<TContext>(
|
|
|
1458
1368
|
const getNowMs = deps.getNowMs ?? Date.now;
|
|
1459
1369
|
const followerPruneIntervalMs = deps.followerPruneIntervalMs ?? 1000;
|
|
1460
1370
|
const followerStaleAfterMs = deps.followerStaleAfterMs ?? 5000;
|
|
1461
|
-
const followerRecoveryGraceMs = deps.followerRecoveryGraceMs ?? 5000;
|
|
1462
1371
|
let pruneInterval: ReturnType<typeof setInterval> | undefined;
|
|
1463
|
-
let followerRealityTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1464
1372
|
const stopPruning = () => {
|
|
1465
1373
|
if (!pruneInterval) return;
|
|
1466
1374
|
clearInterval(pruneInterval);
|
|
1467
1375
|
pruneInterval = undefined;
|
|
1468
1376
|
};
|
|
1469
|
-
const stopFollowerRealityTimer = () => {
|
|
1470
|
-
if (!followerRealityTimer) return;
|
|
1471
|
-
clearTimeout(followerRealityTimer);
|
|
1472
|
-
followerRealityTimer = undefined;
|
|
1473
|
-
};
|
|
1474
|
-
const scheduleFollowerBindingReality = () => {
|
|
1475
|
-
stopFollowerRealityTimer();
|
|
1476
|
-
if (!deps.reconcileFollowerBindings) return;
|
|
1477
|
-
followerRealityTimer = setTimeout(() => {
|
|
1478
|
-
followerRealityTimer = undefined;
|
|
1479
|
-
void Promise.resolve(deps.reconcileFollowerBindings?.()).catch((error) =>
|
|
1480
|
-
deps.recordRuntimeEvent?.("bus", error, {
|
|
1481
|
-
phase: "follower-binding-reality",
|
|
1482
|
-
}),
|
|
1483
|
-
);
|
|
1484
|
-
}, followerRecoveryGraceMs);
|
|
1485
|
-
followerRealityTimer.unref?.();
|
|
1486
|
-
};
|
|
1487
1377
|
const pruneFollowers = async () => {
|
|
1488
1378
|
try {
|
|
1489
1379
|
await localServer.ensureEndpoint();
|
|
@@ -1497,18 +1387,14 @@ export function createTelegramBusLeaderRuntime<TContext>(
|
|
|
1497
1387
|
followerStaleAfterMs,
|
|
1498
1388
|
);
|
|
1499
1389
|
for (const follower of removed) {
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
phase: "follower-
|
|
1390
|
+
deps.recordRuntimeEvent?.(
|
|
1391
|
+
"bus",
|
|
1392
|
+
"Telegram bus follower heartbeat stale; preserving thread binding",
|
|
1393
|
+
{
|
|
1394
|
+
phase: "follower-pruned",
|
|
1505
1395
|
instanceId: follower.instanceId,
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
deps.recordRuntimeEvent?.("bus", "Telegram bus follower timed out", {
|
|
1509
|
-
phase: "follower-prune",
|
|
1510
|
-
instanceId: follower.instanceId,
|
|
1511
|
-
});
|
|
1396
|
+
},
|
|
1397
|
+
);
|
|
1512
1398
|
}
|
|
1513
1399
|
};
|
|
1514
1400
|
const startPruning = () => {
|
|
@@ -1542,21 +1428,18 @@ export function createTelegramBusLeaderRuntime<TContext>(
|
|
|
1542
1428
|
return {
|
|
1543
1429
|
startPolling: async (ctx) => {
|
|
1544
1430
|
await localServer.start();
|
|
1545
|
-
scheduleFollowerBindingReality();
|
|
1546
1431
|
startPruning();
|
|
1547
1432
|
try {
|
|
1548
1433
|
await deps.provisionLeaderTarget?.(ctx);
|
|
1549
1434
|
await deps.startPolling(ctx);
|
|
1550
1435
|
} catch (error) {
|
|
1551
1436
|
stopPruning();
|
|
1552
|
-
stopFollowerRealityTimer();
|
|
1553
1437
|
await localServer.stop();
|
|
1554
1438
|
throw error;
|
|
1555
1439
|
}
|
|
1556
1440
|
},
|
|
1557
1441
|
stopPolling: async () => {
|
|
1558
1442
|
stopPruning();
|
|
1559
|
-
stopFollowerRealityTimer();
|
|
1560
1443
|
try {
|
|
1561
1444
|
await deps.stopPolling();
|
|
1562
1445
|
} finally {
|
package/lib/config.ts
CHANGED
|
@@ -730,11 +730,10 @@ export interface TelegramProactivePushTarget {
|
|
|
730
730
|
threadId?: number;
|
|
731
731
|
}
|
|
732
732
|
|
|
733
|
-
export function createTelegramProactivePushChatIdGetter(
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
return () => deps.getActiveTurnChatId() ?? deps.getAllowedUserId();
|
|
733
|
+
export function createTelegramProactivePushChatIdGetter(
|
|
734
|
+
getTarget: () => TelegramProactivePushTarget | undefined,
|
|
735
|
+
): () => number | undefined {
|
|
736
|
+
return () => getTarget()?.chatId;
|
|
738
737
|
}
|
|
739
738
|
|
|
740
739
|
export function createTelegramProactivePushTargetGetter(deps: {
|
package/lib/thread-reconciler.ts
CHANGED
|
@@ -62,11 +62,6 @@ export interface TelegramReservedThreadMessageObservation {
|
|
|
62
62
|
leaderEpoch?: number | string;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
export interface ThreadReservationProbeResult {
|
|
66
|
-
target: ThreadTarget;
|
|
67
|
-
stale: boolean;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
65
|
export interface ReplacedInstanceBindingInput {
|
|
71
66
|
instanceId: string;
|
|
72
67
|
replacementTarget: ThreadTarget;
|
|
@@ -111,14 +106,6 @@ export type ThreadReconciliationAction =
|
|
|
111
106
|
instanceId?: string;
|
|
112
107
|
leaderEpoch?: number | string;
|
|
113
108
|
}
|
|
114
|
-
| {
|
|
115
|
-
kind: "close-delete-pruned-follower-topic";
|
|
116
|
-
target: TelegramTarget & { threadId: number };
|
|
117
|
-
reason: "pruned-follower";
|
|
118
|
-
instanceId?: string;
|
|
119
|
-
messageId?: number;
|
|
120
|
-
leaderEpoch?: number | string;
|
|
121
|
-
}
|
|
122
109
|
| {
|
|
123
110
|
kind: "close-delete-replaced-follower-topic";
|
|
124
111
|
target: TelegramTarget & { threadId: number };
|
|
@@ -150,11 +137,6 @@ export type ThreadReconciliationAction =
|
|
|
150
137
|
pendingProvisionId: string;
|
|
151
138
|
instanceId?: string;
|
|
152
139
|
leaderEpoch?: number | string;
|
|
153
|
-
}
|
|
154
|
-
| {
|
|
155
|
-
kind: "remove-reservation";
|
|
156
|
-
target: TelegramTarget & { threadId: number };
|
|
157
|
-
reason: "reservation-probe-stale";
|
|
158
140
|
};
|
|
159
141
|
|
|
160
142
|
export type ThreadReconciliationPhase =
|
|
@@ -203,7 +185,6 @@ export interface ThreadReconciliationApplyPorts {
|
|
|
203
185
|
lastSyncError?: string,
|
|
204
186
|
) => boolean;
|
|
205
187
|
persist?: () => Promise<void>;
|
|
206
|
-
removeReservationByTarget?: (target: ThreadTarget) => boolean;
|
|
207
188
|
removePendingProvisionById?: (id: string) => boolean;
|
|
208
189
|
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
209
190
|
recordRuntimeEvent?: (
|
|
@@ -223,8 +204,6 @@ export interface ThreadReconciliationInput {
|
|
|
223
204
|
unboundMessages?: readonly TelegramUnboundThreadMessageObservation[];
|
|
224
205
|
reservedMessages?: readonly TelegramReservedThreadMessageObservation[];
|
|
225
206
|
proactiveReservationCleanup?: boolean;
|
|
226
|
-
reservationProbeResults?: readonly ThreadReservationProbeResult[];
|
|
227
|
-
prunedFollowerInstanceIds?: readonly string[];
|
|
228
207
|
replacedBindings?: readonly ReplacedInstanceBindingInput[];
|
|
229
208
|
previousLeaderCleanup?: PreviousLeaderCleanupInput;
|
|
230
209
|
previousState?: ThreadReconciliationMachineState;
|
|
@@ -309,7 +288,6 @@ function isCleanupAction(action: ThreadReconciliationAction): boolean {
|
|
|
309
288
|
action.kind === "close-delete-unbound-topic" ||
|
|
310
289
|
action.kind === "close-delete-reserved-topic" ||
|
|
311
290
|
action.kind === "close-stale-replaced-topic" ||
|
|
312
|
-
action.kind === "close-delete-pruned-follower-topic" ||
|
|
313
291
|
action.kind === "close-delete-replaced-follower-topic" ||
|
|
314
292
|
action.kind === "close-delete-previous-leader-topic" ||
|
|
315
293
|
action.kind === "close-delete-disconnected-instance-topic" ||
|
|
@@ -318,11 +296,7 @@ function isCleanupAction(action: ThreadReconciliationAction): boolean {
|
|
|
318
296
|
}
|
|
319
297
|
|
|
320
298
|
function isSyncAction(action: ThreadReconciliationAction): boolean {
|
|
321
|
-
return
|
|
322
|
-
action.kind === "mark-topic-active" ||
|
|
323
|
-
action.kind === "mark-topic-stale" ||
|
|
324
|
-
action.kind === "remove-reservation"
|
|
325
|
-
);
|
|
299
|
+
return action.kind === "mark-topic-active" || action.kind === "mark-topic-stale";
|
|
326
300
|
}
|
|
327
301
|
|
|
328
302
|
function createThreadReconciliationMachineState(
|
|
@@ -520,11 +494,6 @@ export async function applyThreadReconciliationPlan(
|
|
|
520
494
|
const persistFences: ThreadReconciliationAction[] = [];
|
|
521
495
|
const incompleteActions: ThreadReconciliationAction[] = [];
|
|
522
496
|
for (const action of plan.actions) {
|
|
523
|
-
if (action.kind === "remove-reservation") {
|
|
524
|
-
shouldPersist =
|
|
525
|
-
ports.removeReservationByTarget?.(action.target) || shouldPersist;
|
|
526
|
-
continue;
|
|
527
|
-
}
|
|
528
497
|
if (action.kind === "mark-topic-active") {
|
|
529
498
|
shouldPersist =
|
|
530
499
|
ports.markActiveByTarget?.(action.target) || shouldPersist;
|
|
@@ -586,7 +555,6 @@ export async function applyThreadReconciliationPlan(
|
|
|
586
555
|
if (
|
|
587
556
|
action.kind === "close-delete-unbound-topic" ||
|
|
588
557
|
action.kind === "close-delete-reserved-topic" ||
|
|
589
|
-
action.kind === "close-delete-pruned-follower-topic" ||
|
|
590
558
|
action.kind === "close-delete-replaced-follower-topic" ||
|
|
591
559
|
action.kind === "close-delete-previous-leader-topic" ||
|
|
592
560
|
action.kind === "close-delete-disconnected-instance-topic" ||
|
|
@@ -658,7 +626,6 @@ export async function applyThreadReconciliationPlan(
|
|
|
658
626
|
if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
|
|
659
627
|
if (
|
|
660
628
|
action.kind !== "close-delete-previous-leader-topic" &&
|
|
661
|
-
action.kind !== "close-delete-pruned-follower-topic" &&
|
|
662
629
|
action.kind !== "close-delete-replaced-follower-topic" &&
|
|
663
630
|
action.kind !== "close-delete-disconnected-instance-topic" &&
|
|
664
631
|
action.kind !== "close-delete-expired-pending-provision-topic"
|
|
@@ -674,9 +641,7 @@ export async function applyThreadReconciliationPlan(
|
|
|
674
641
|
? "Unbound Telegram topic deleted"
|
|
675
642
|
: action.kind === "close-delete-reserved-topic"
|
|
676
643
|
? "Reserved Telegram topic deleted"
|
|
677
|
-
: action.kind === "close-delete-
|
|
678
|
-
? "Pruned follower Telegram topic deleted"
|
|
679
|
-
: action.kind === "close-delete-replaced-follower-topic"
|
|
644
|
+
: action.kind === "close-delete-replaced-follower-topic"
|
|
680
645
|
? "Replaced follower Telegram topic deleted"
|
|
681
646
|
: action.kind === "close-delete-previous-leader-topic"
|
|
682
647
|
? "Previous leader Telegram topic deleted"
|
|
@@ -689,9 +654,7 @@ export async function applyThreadReconciliationPlan(
|
|
|
689
654
|
? "thread-reconciler-unbound-topic-delete"
|
|
690
655
|
: action.kind === "close-delete-reserved-topic"
|
|
691
656
|
? "thread-reconciler-reserved-topic-delete"
|
|
692
|
-
: action.kind === "close-delete-
|
|
693
|
-
? "thread-reconciler-pruned-follower-topic-delete"
|
|
694
|
-
: action.kind === "close-delete-replaced-follower-topic"
|
|
657
|
+
: action.kind === "close-delete-replaced-follower-topic"
|
|
695
658
|
? "thread-reconciler-replaced-follower-topic-delete"
|
|
696
659
|
: action.kind === "close-delete-previous-leader-topic"
|
|
697
660
|
? "thread-reconciler-previous-leader-topic-delete"
|
|
@@ -746,11 +709,6 @@ export function planThreadReconciliation(
|
|
|
746
709
|
.filter(isCurrentRecord)
|
|
747
710
|
.map((record) => targetKey(record.target)),
|
|
748
711
|
);
|
|
749
|
-
const allReservationTargets = new Set(
|
|
750
|
-
(input.reservations ?? []).map((reservation) =>
|
|
751
|
-
targetKey(reservation.target),
|
|
752
|
-
),
|
|
753
|
-
);
|
|
754
712
|
const reservedTargets = new Set(
|
|
755
713
|
(input.reservations ?? [])
|
|
756
714
|
.filter((reservation) => isReservationAlive(reservation, input.nowMs))
|
|
@@ -826,22 +784,6 @@ export function planThreadReconciliation(
|
|
|
826
784
|
}
|
|
827
785
|
}
|
|
828
786
|
|
|
829
|
-
for (const prunedInstanceId of input.prunedFollowerInstanceIds ?? []) {
|
|
830
|
-
for (const record of input.records) {
|
|
831
|
-
if (record.instanceId !== prunedInstanceId) continue;
|
|
832
|
-
if (!isActiveOrStartingRecord(record)) continue;
|
|
833
|
-
actions.push({
|
|
834
|
-
kind: "close-delete-pruned-follower-topic",
|
|
835
|
-
target: record.target,
|
|
836
|
-
reason: "pruned-follower",
|
|
837
|
-
instanceId: prunedInstanceId,
|
|
838
|
-
...(input.currentLeaderEpoch !== undefined
|
|
839
|
-
? { leaderEpoch: input.currentLeaderEpoch }
|
|
840
|
-
: {}),
|
|
841
|
-
});
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
|
|
845
787
|
for (const replacement of input.replacedBindings ?? []) {
|
|
846
788
|
for (const record of input.records) {
|
|
847
789
|
if (record.instanceId !== replacement.instanceId) continue;
|
|
@@ -900,16 +842,6 @@ export function planThreadReconciliation(
|
|
|
900
842
|
}
|
|
901
843
|
}
|
|
902
844
|
|
|
903
|
-
for (const probe of input.reservationProbeResults ?? []) {
|
|
904
|
-
if (!probe.stale) continue;
|
|
905
|
-
if (!allReservationTargets.has(targetKey(probe.target))) continue;
|
|
906
|
-
actions.push({
|
|
907
|
-
kind: "remove-reservation",
|
|
908
|
-
target: probe.target,
|
|
909
|
-
reason: "reservation-probe-stale",
|
|
910
|
-
});
|
|
911
|
-
}
|
|
912
|
-
|
|
913
845
|
for (const message of input.reservedMessages ?? []) {
|
|
914
846
|
const key = targetKey(message.target);
|
|
915
847
|
if (!reservedTargets.has(key)) continue;
|
package/lib/threads.ts
CHANGED
|
@@ -237,7 +237,6 @@ export interface TelegramTopicTargetStore {
|
|
|
237
237
|
target: TelegramTarget & { threadId: number },
|
|
238
238
|
) => Promise<boolean>;
|
|
239
239
|
removePendingProvision: (id: string) => boolean;
|
|
240
|
-
removeReservationByTarget: (target: TelegramTarget) => boolean;
|
|
241
240
|
getBotState: () => TelegramBotStateSnapshot;
|
|
242
241
|
setBotState: (state: Partial<TelegramBotStateSnapshot>) => void;
|
|
243
242
|
setStatusSnapshot: (snapshot: {
|
|
@@ -1396,15 +1395,6 @@ export function createTelegramTopicTargetStore(
|
|
|
1396
1395
|
if (changed) markDirty();
|
|
1397
1396
|
return changed;
|
|
1398
1397
|
},
|
|
1399
|
-
removeReservationByTarget(target) {
|
|
1400
|
-
const before = reservations.length;
|
|
1401
|
-
reservations = reservations.filter(
|
|
1402
|
-
(reservation) => !targetMatches(reservation.target, target),
|
|
1403
|
-
);
|
|
1404
|
-
const changed = reservations.length !== before;
|
|
1405
|
-
if (changed) markDirty();
|
|
1406
|
-
return changed;
|
|
1407
|
-
},
|
|
1408
1398
|
getBotState() {
|
|
1409
1399
|
return Object.fromEntries(
|
|
1410
1400
|
Object.entries(botState).filter(([, value]) => value !== undefined),
|
|
@@ -1983,9 +1973,6 @@ export async function provisionOwnBusTopic(
|
|
|
1983
1973
|
syncStatus?: "closed" | "deleted",
|
|
1984
1974
|
lastSyncError?: string,
|
|
1985
1975
|
) => deps.store.markStaleByTarget(target, syncStatus, lastSyncError),
|
|
1986
|
-
removeReservationByTarget: (
|
|
1987
|
-
target: TelegramTarget & { threadId: number },
|
|
1988
|
-
) => deps.store.removeReservationByTarget(target),
|
|
1989
1976
|
removePendingProvisionById: (id: string) =>
|
|
1990
1977
|
deps.store.removePendingProvision(id),
|
|
1991
1978
|
persist: () => deps.store.persist(),
|
|
@@ -2024,36 +2011,6 @@ export async function provisionOwnBusTopic(
|
|
|
2024
2011
|
durationMs: Date.now() - reservationCleanupApplyStartedAtMs,
|
|
2025
2012
|
actions: reservationCleanupPlan.actions.length,
|
|
2026
2013
|
});
|
|
2027
|
-
const reservationProbeResults: ThreadReconciler.ThreadReservationProbeResult[] =
|
|
2028
|
-
[];
|
|
2029
|
-
deps.recordEvent("bus", "Bus leader reservation probes skipped", {
|
|
2030
|
-
phase: "leader-topic-reservation-probe-skipped",
|
|
2031
|
-
reservations: reservationsBeforeCleanup.length,
|
|
2032
|
-
});
|
|
2033
|
-
const reservationProbePlan = ThreadReconciler.planThreadReconciliation({
|
|
2034
|
-
nowMs: Date.now(),
|
|
2035
|
-
currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
|
|
2036
|
-
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
2037
|
-
records: deps.store.list(),
|
|
2038
|
-
reservations: deps.store.listReservations(),
|
|
2039
|
-
pendingProvisions: deps.store.listPendingProvisions(),
|
|
2040
|
-
reservationProbeResults,
|
|
2041
|
-
});
|
|
2042
|
-
deps.recordThreadReconciliationPlan?.(reservationProbePlan);
|
|
2043
|
-
const reservationProbeApplyStartedAtMs = Date.now();
|
|
2044
|
-
await ThreadReconciler.applyThreadReconciliationPlan(
|
|
2045
|
-
reservationProbePlan,
|
|
2046
|
-
reservationCleanupPorts,
|
|
2047
|
-
);
|
|
2048
|
-
deps.recordEvent(
|
|
2049
|
-
"bus",
|
|
2050
|
-
"Bus leader reservation probe reconciliation applied",
|
|
2051
|
-
{
|
|
2052
|
-
phase: "leader-topic-reservation-probe-apply-duration",
|
|
2053
|
-
durationMs: Date.now() - reservationProbeApplyStartedAtMs,
|
|
2054
|
-
actions: reservationProbePlan.actions.length,
|
|
2055
|
-
},
|
|
2056
|
-
);
|
|
2057
2014
|
const nowMs = Date.now();
|
|
2058
2015
|
const currentLeaderOwner: TelegramThreadOwner = {
|
|
2059
2016
|
kind: "leader",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-telegram",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"node": ">=22.19.0"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
|
-
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
30
|
+
"test": "node --experimental-strip-types --test --test-reporter=dot tests/*.test.ts",
|
|
31
|
+
"test:verbose": "node --experimental-strip-types --test --test-reporter=spec tests/*.test.ts",
|
|
31
32
|
"typecheck": "tsc --noEmit",
|
|
32
33
|
"audit": "npm audit",
|
|
33
34
|
"pack:check": "npm pack --dry-run",
|