@llblab/pi-telegram 0.20.2 → 0.20.3
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 +3 -3
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +8 -0
- package/README.md +2 -1
- package/docs/architecture.md +3 -3
- package/docs/multi-instance-bus.md +18 -18
- package/index.ts +6 -8
- package/lib/bindings.ts +16 -1
- package/lib/bus-follower.ts +34 -31
- package/lib/bus-leader.ts +34 -4
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -79,7 +79,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
79
79
|
- In Threaded Mode, exactly one live leader owns `getUpdates` and direct Telegram Bot API calls for one bot token. `/telegram-connect` means join the local Telegram organism: if no live leader exists the instance becomes leader; if a live leader exists the terminal-visible instance registers as follower; ordinary connect must not offer manual takeover while the leader heartbeat is live. Leadership is an ephemeral transport role, not permanent ownership by the first terminal-visible Pi instance. Followers must be started by the operator as real Pi processes, register over the local bus, heartbeat, receive only their routed inbound updates, and send outbound replies/previews/files/chat-actions through the leader transport; Telegram must not spawn hidden Pi processes, launch follower subprocesses, or expose a slash command that creates a new local Pi instance. If a leader reloads while followers stay alive, followers must treat rejected/unknown/unauthorized heartbeats as a registration-loss signal and optimistically re-register with the currently active leader before considering promotion; after a short grace window without successful re-registration, a live follower may promote itself and force-acquire transport leadership so the organism degrades forward instead of staying split-brain. Terminal status must not remain `follower` while the leader registry can no longer route that follower's thread. Heartbeat pruning is liveness bookkeeping, not permission to delete, mark offline, or send a Telegram-visible disconnected notice for a follower's thread; preserve active follower tabs/bindings across transient reload/reconnect incidents and reserve destructive teardown/offline transitions for explicit disconnect or confirmed reconciliation cases. If the leader dies, a live follower should eventually be able to take over through stale-leader election/recovery so the local multi-instance organism keeps running. Explicit `/telegram-disconnect` is a manual thread teardown: a leader deletes its own bound thread before releasing transport ownership, while a follower asks the leader to delete its assigned thread through scoped bus API before unregistering. Status is reactive to the effective runtime role and bot capability: classic/non-thread mode shows ordinary `telegram connected`, active bus leadership shows `telegram leader`, and registered followers show `telegram follower`; stable multi-instance truth belongs in the bus role (`leader`/`follower`), while lifecycle is reserved for transient recovery state such as `electing` and must not duplicate stable roles with labels like `leader-active`. When leadership or Threaded Mode availability changes at runtime, the status line and `/telegram-status` must converge promptly instead of preserving a stale role label.
|
|
80
80
|
- Runtime routing binds to a live `instanceId`; current thread identity should use explicit owner records (`leader`, `manual-follower`, `pending-topic`) rather than hiding ownership direction in legacy string keys. Compatibility keys may be derived internally, but stale session records must not be treated as live owners. A live instance owns at most one active bound thread; provisioning a new target for the same `instanceId` must remove older current-state bindings and close duplicate Telegram threads when possible. A reload creates a new runtime `instanceId`; if the previous leader slot is still known/alive, keep it occupied in reservations and assign the new leader the next free slot instead of creating a duplicate same-letter tab or blocking startup on Telegram UI convergence.
|
|
81
81
|
- 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. 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.
|
|
82
|
-
- 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 may survive
|
|
82
|
+
- 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 may survive through a bounded leader-reload re-registration grace window. Successful target reuse must refresh the binding timestamp, and post-reload compaction must preserve a recently refreshed binding across the brief registry gap caused by follower session replacement; older records without a live owner leave current `state.json` without implying destructive Telegram thread deletion. When post-grace follower compaction removes the binding represented by `bot.lastSlot`, realign the cursor to the newest-created remaining live binding in the same reconciliation pass; preserve a cursor that still belongs to a live binding and continue skipping unexpired pending/reserved slots. Do not reinterpret an otherwise orphaned cursor outside compaction because normal thread deletion may intentionally preserve ring sequence. The alphabet cap is a feature — compact single-letter tabs are scannable.
|
|
83
83
|
- 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 and live diagnostics from the bound thread record; 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.
|
|
84
84
|
- Telegram private-chat Threaded Mode treats `All` as an aggregate/control surface, not a durable `General` thread and not a process launcher. Leader activation creates/reuses a leader thread in the private bot DM (chat = allowedUserId) proactively. If all threads are closed and the owner writes from `All`, a first new unbound thread should be reclaimed by the leader when the leader lacks an active bound thread. Later unknown threads require explicit routing or restore; creating another Pi instance is a manual terminal action followed by `/telegram-connect`.
|
|
85
85
|
- 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.
|
|
@@ -99,7 +99,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
99
99
|
- UI emoji semantics live in `docs/ui-style.md` as the project-level emoji registry. Before adding a new UI emoji, reuse an existing registered meaning or update the registry in the same change. Use `🧵` consistently as the canonical Telegram/Pi thread marker in thread chooser headings, thread lifecycle copy, and target-routing status text; do not decorate every concrete thread-target button with it when the button label is already the thread name or slot. Operator-facing Threaded Mode routing copy should use the `Pi` glyph for Pi instance/thread/session concepts; reserve `Pi` spelling for product prose, package/API names, and surrounding docs where the wordmark is clearer.
|
|
100
100
|
- Telegram prompt context metadata uses `[tag|key:value|key:value]` without spaces around `:`. `[telegram...]` identifies the current inbound surface only: owner/current turns use `[telegram]` or `[telegram|thread:<name>]`, while guest-mode turns use `[telegram|guest:<group-title-or-peer-username-or-id>]`. In private guest replies authored by the owner, `guest:<...>` must name the replied guest peer, not the owner. Alternate/source authors belong on source-specific blocks such as `[reply|from:<username-or-id>]`, `[forward|from:<username-or-id>]`, and `[attachments|from:<username-or-id>]`; plain `[attachments]` remains current-turn/current-owner attachments and should render immediately after the `[telegram...]` current text before reply/forward/source attachment context.
|
|
101
101
|
- Telegram `reply_to_message` context is prompt-only and must not affect slash-command parsing; when Telegram includes `rich_message` blocks on a quoted rich reply, extract plain text from those blocks before falling back to raw `text`/`caption` so prompt context does not leak raw Rich Markdown source
|
|
102
|
-
- Long-lived timers, pollers, watchers, and deferred queue dispatch must be session-bound and avoid stale live Pi contexts after session replacement. Pi session replacement/reload changes agent context
|
|
102
|
+
- Long-lived timers, pollers, watchers, and deferred queue dispatch must be session-bound and avoid stale live Pi contexts after session replacement. Pi session replacement/reload changes agent context, so Threaded Mode followers must stop the old receiver/heartbeat and never retain a stale context or socket. Before suspension, snapshot a short-lived same-process handoff containing the assigned target identity; on the next session start, automatically re-register through the live leader with that target and bind the new context. Preserve the existing Telegram thread during this handoff, show `Telegram disconnected` only while restoration is genuinely pending or failed, and require explicit `/telegram-connect` only when no fresh same-process handoff can be restored.
|
|
103
103
|
- Do not add Telegram commands that imitate Pi interactive session replacement, navigation, or TUI rendering through private internals, ANSI terminal clearing, raw TTY injection, or a shadow `pi` subprocess. Features such as a real Telegram `/new` require a public Pi API that runs the same session-replacement path as the terminal command.
|
|
104
104
|
- In-flight `/model` switching is limited to Telegram-owned active turns; if a tool call is active, abort is delayed until the tool finishes
|
|
105
105
|
|
|
@@ -114,7 +114,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
114
114
|
- `telegram_voice` and `telegram_button` are not Pi tools; keep prompts/docs explicit that agents should author markup while voice synthesis provider extensions own TTS/OGG conversion, and pi-telegram owns button routing plus Telegram delivery
|
|
115
115
|
- Voice reply policy and prompt context are owned by pi-telegram's `telegram.json` `voice.replyMode`: missing/invalid config behaves as `manual` but does not add a `[voice]` prompt-context block; only an explicit valid `voice.replyMode` renders context. Render a single voice field as `[voice] reply mode: manual|mirror|always`, and render multiple fields as a `[voice]` list; place voice context after `[outputs]` when handler output exists, otherwise after `[attachments]`; provider prompt contributions are optional provider-specific additions, not the default policy channel
|
|
116
116
|
- Optional `telegram.json` `time` may add `[time] YYYY-MM-DD HH:mm:ss <timezone>` to Telegram-originated prompts for wall-clock context. It is hidden by default, uses `time.injectionMode` values `hidden|always|interval`, stores `time.interval` in milliseconds, uses the system timezone, and should render last after `[attachments]`, `[outputs]`, and `[voice]` sections. The Settings row `🕒 Time injection: hidden|always|interval` controls `time.injectionMode` only.
|
|
117
|
-
- Agent activity status is native-only for the release path: Telegram uses `sendChatAction(typing)`
|
|
117
|
+
- Agent activity status is native-only for the release path: Telegram uses `sendChatAction(typing)` and renders its native `…typing` indicator as the automatic work signal. When a connected instance starts any real agent run, send that indicator to the instance's own thread (and aggregate `All` surface), including Telegram turns, local/TUI prompts, and autonomous continuations such as Grow Loop; stop it at agent/session completion. This signal is independent from terminal `Active`, which remains scoped to Telegram-owned turns. Confirmed manual `/compact` and automatic compaction tied to an active Telegram turn also use native activity. Do not send activity merely for startup/connect/reload/recovery or add extra in-chat work surfaces without a new explicit design decision.
|
|
118
118
|
- Voice reply mode Settings UI standard: the top-level Settings row is `👄 Voice reply: hidden|manual|mirror|always`; `hidden` is the true default and means no valid `voice.replyMode` is persisted, behavior is manual, and no voice policy is added to prompt context; explicit `manual` behaves the same operationally but renders reply-mode context. The submenu title is `👄 Voice reply mode:`; choice buttons use lowercase labels with a model-style active dot (`🟢 hidden`, `🟢 mirror`) rather than per-mode emoji; the explanatory submenu body uses compact HTML-code bullets such as `<code>-</code> <code>hidden</code> (default): ...`. Preserve this wording/icons unless the operator explicitly asks to redesign it
|
|
119
119
|
- Outbound voice delivery is one fallback pipeline: configured `outboundHandlers` with `type: "voice"` run first in `telegram.json` order, then programmatic voice handlers, then registered voice synthesis providers as zero-config progressive fallbacks; provider extensions must not override operator-configured handlers
|
|
120
120
|
- `telegram_voice` text is arbitrary TTS-target text and the prompt should show only forms that include text to speak: `<!-- telegram_voice text="Short summary" -->` or `<!-- telegram_voice: Short summary -->`. Avoid raw Markdown/code/tables in voice text.
|
package/BACKLOG.md
CHANGED
|
@@ -30,7 +30,7 @@ Context: The release should avoid speculative live-test matrices. Future Telegra
|
|
|
30
30
|
|
|
31
31
|
Open work:
|
|
32
32
|
|
|
33
|
-
- [ ] Capture any new Telegram client or Bot API behavior that contradicts the documented Threaded Mode contract.
|
|
33
|
+
- [ ] Capture any new Telegram client or Bot API behavior that contradicts the documented Threaded Mode contract, including a live local/autonomous `…typing` observation when convenient.
|
|
34
34
|
- [ ] Add a focused regression or documented client caveat only for confirmed behavior.
|
|
35
35
|
- [ ] Keep one-off live environment names, thread names, and operator-specific observations out of repository context unless they demonstrate a general product issue.
|
|
36
36
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.20.3: Persistent Threads And Activity
|
|
4
|
+
|
|
5
|
+
- `[Follower Sessions]` Registered followers now snapshot their target, slot, and thread name before same-process Pi session replacement, stop the old receiver/heartbeat context, and automatically re-register the new session through the live leader. Impact: `/new` and `/reload` no longer intentionally leave a healthy follower disconnected or require another manual `/telegram-connect`.
|
|
6
|
+
- `[Thread Identity]` Follower re-registration transfers an exact requested target from the previous runtime instance through the stable manual-follower identity, and every reuse refreshes the binding's recovery timestamp. Post-leader-reload compaction preserves recently refreshed bindings across a brief follower registry gap, while still removing genuinely historical records. Impact: follower or leader reload does not rotate baked names or create duplicate same-slot Telegram tabs merely because runtime instance ids changed or the follower reload overlaps the leader's compaction deadline.
|
|
7
|
+
- `[Native Activity]` Telegram's native `…typing` indicator now starts for every connected instance agent run, including local/TUI prompts and autonomous continuations such as Grow Loop, using the active Telegram turn target when present and otherwise the instance's assigned target. Impact: Telegram shows that an instance is working even when a new Telegram prompt is queued behind local work, while terminal `Active` remains scoped to Telegram-owned turns.
|
|
8
|
+
- `[Live Linux]` Leader reload triggered bounded follower election/re-registration without replacing follower threads; subsequent Boreal follower `/reload` and `/new` both automatically restored follower role on the same slot `B` and thread `524509`. Impact: leader reload and both Pi session-replacement paths have direct evidence for exact follower thread preservation without manual reconnect or duplicate tabs.
|
|
9
|
+
- `[Validation]` Relaxed the process-shutdown child harness timeout from 2 to 5 seconds after full-suite concurrency exceeded the old wall-clock bound while the isolated shutdown suite remained green. Impact: process ownership regressions still fail on leaked handles but no longer flake solely from parallel test-runner load.
|
|
10
|
+
|
|
3
11
|
## 0.20.2: Live Thread Reality
|
|
4
12
|
|
|
5
13
|
- `[Profiles]` Named `/telegram-connect <profile>` now verifies the persisted profile before stopping polling or changing session-local profile activation. Impact: a mistyped or missing profile leaves the current bot, transport ownership, and polling runtime untouched instead of disconnecting a healthy session.
|
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ The first Telegram user to message the bot becomes the allowed owner. Other user
|
|
|
94
94
|
| Prompt templates | Run Pi prompt templates as Telegram-safe commands such as `/fix_tests`. | Reusable local workflows become phone-accessible without exposing arbitrary terminal commands. |
|
|
95
95
|
| Model and thinking | Switch model or reasoning level from Telegram through safe continuation flows. | Mobile control can adjust execution strategy without tearing down the current session. |
|
|
96
96
|
| Compaction | Confirm `/compact`, show native active status during compaction, and preserve Telegram-owned turn semantics. | Context maintenance is visible and safe from the phone. |
|
|
97
|
-
| Draft previews | Show native
|
|
97
|
+
| Draft previews | Show Telegram's native `…typing` indicator whenever the connected instance is doing agent work, or enable Rich Draft previews for streamed answer text. | Local prompts, Telegram turns, and autonomous continuations remain visibly active while draft visibility stays independent from final rendering. |
|
|
98
98
|
| Assistant rendering | Choose Native Rich Markdown or legacy Markdown-to-HTML for final assistant replies. | Renderer compatibility is explicit instead of being conflated with draft previews. |
|
|
99
99
|
| Bridge UI rendering | Render tool rows, reasoning/technical steps, menus, queue controls, status, settings, diagnostics, and sections through explicit Telegram HTML/plain UI. | Harness-owned surfaces remain operationally predictable and visually distinct from model-authored answers. |
|
|
100
100
|
| Inbound files | Download inbound files to the Pi agent temp directory with size limits. | Screenshots, PDFs, datasets, and artifacts enter Pi as inspectable local files. |
|
|
@@ -184,6 +184,7 @@ Classic private DM mode is the base product mode. When Telegram private-chat Thr
|
|
|
184
184
|
- One live leader owns `getUpdates`.
|
|
185
185
|
- Followers are visible Pi processes started by the operator.
|
|
186
186
|
- Each connected instance gets a Telegram thread target.
|
|
187
|
+
- Follower session replacement automatically reconnects the new session context to the same thread instead of requiring another manual connect.
|
|
187
188
|
- Unknown threads are preserved and offered explicit reroute/restore choices.
|
|
188
189
|
- Telegram never launches hidden Pi processes.
|
|
189
190
|
|
package/docs/architecture.md
CHANGED
|
@@ -108,7 +108,7 @@ Telegram configuration lives in `~/.pi/agent/telegram.json`. Polling ownership l
|
|
|
108
108
|
- Session start schedules Telegram polling resume asynchronously only when the existing lock already points at the current `pid`/`cwd`, or when a stale same-`cwd` lock can be safely replaced after process restart. Startup and `/resume` should not wait on Telegram leader election, Bot API probes, poller handoff, or thread reconciliation before restoring the Pi session.
|
|
109
109
|
- Pi `print`/`json` run modes stay passive: they do not start or resume Telegram polling even if a lock is present. Older Pi runtimes without `ctx.mode` keep the previous compatibility behavior.
|
|
110
110
|
- Inherited child sessions that see the same `telegram.json` but do not own the `pid`/`cwd` lock must not auto-start polling or call `getUpdates` unless the operator force-takes ownership.
|
|
111
|
-
- Session replacement suspends polling/watchers without releasing ownership so the next session-start hook in the same process can resume.
|
|
111
|
+
- Session replacement suspends polling/watchers without releasing ownership so the next session-start hook in the same process can resume. A registered follower snapshots its assigned target into a short-lived same-process handoff, stops the old receiver/heartbeat, and automatically re-registers the new session context through the live leader without marking or replacing its Telegram thread.
|
|
112
112
|
- Live polling owners require explicit takeover confirmation.
|
|
113
113
|
- Long-lived polling timers use snapshotted ownership context and stop local polling when the lock no longer points at their own process.
|
|
114
114
|
- `locks.json` owns only external Telegram control/polling. Local extension and queue state are per Pi instance: losing the lock stops live Telegram control here, but does not drain or silence this instance's accepted queue, previews, final delivery, or dispatch.
|
|
@@ -124,7 +124,7 @@ Named Telegram profiles are orthogonal to Threaded Mode. The selected profile ch
|
|
|
124
124
|
|
|
125
125
|
Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; profile bot/session fields and observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup is limited to stale scratch files and cannot redirect live traffic.
|
|
126
126
|
|
|
127
|
-
When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Follower liveness is intentionally fast because heartbeat traffic is local IPC: followers heartbeat every `1s`, the leader treats them as stale after `2s`, and the prune loop runs every `1s` so stopped followers are detected promptly while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping: it preserves the follower thread binding and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected.
|
|
127
|
+
When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Follower liveness is intentionally fast because heartbeat traffic is local IPC: followers heartbeat every `1s`, the leader treats them as stale after `2s`, and the prune loop runs every `1s` so stopped followers are detected promptly while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping: it preserves the follower thread binding and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Successful follower target reuse refreshes the binding's recovery timestamp; post-leader-reload compaction preserves that recently refreshed binding across a brief follower session-replacement registry gap, while genuinely historical records still leave current state after grace. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected.
|
|
128
128
|
|
|
129
129
|
Follower binding is manual and process-first: the operator starts another Pi process, then runs `/telegram-connect`; only then does that process register as a follower with an instance-scoped internal binding identity and cause the leader to create/reuse a thread for it. Telegram does not expose `/thread`, auto-spawn arbitrary unbound threads, or launch hidden follower subprocesses. In Threaded Mode, `/telegram-connect` does not offer manual takeover while a live leader exists; takeover is reserved for stale-leader election/recovery. Leadership remains an ephemeral transport role that another live follower can take over after stale heartbeat detection.
|
|
130
130
|
|
|
@@ -223,7 +223,7 @@ Native typing during compaction is deliberately narrower than the compaction fla
|
|
|
223
223
|
- Startup, reload, connect/reconnect, restore, leader/follower recovery, and idle/background compaction without an active Telegram turn must not send visible typing.
|
|
224
224
|
- Thread-targeted typing is sent to the concrete thread and mirrored to `All` as the aggregate activity surface.
|
|
225
225
|
|
|
226
|
-
|
|
226
|
+
At every connected instance `agent_start`, the lifecycle binding starts Telegram's native `…typing` indicator in that instance's assigned target, whether the run came from Telegram, the local TUI, or an autonomous continuation such as Grow Loop. Terminal `Active` remains Telegram-turn-specific; the native indicator answers the separate question of whether the instance is doing agent work. Assistant message start/update hooks still re-arm it during Telegram-owned turns so transient provider/model errors do not leave a continuing run without activity feedback, and agent/session completion stops it.
|
|
227
227
|
|
|
228
228
|
### Rendering And Delivery
|
|
229
229
|
|
|
@@ -155,7 +155,7 @@ Leader election is heartbeat-gated and lock-backed:
|
|
|
155
155
|
4. If the leader heartbeat is stale, attempt an atomic leadership takeover; ordinary `/telegram-connect` on a follower is not a leadership move while the leader is live.
|
|
156
156
|
5. If several followers detect stale leadership, atomic compare/write lock acquisition ensures only one becomes leader.
|
|
157
157
|
|
|
158
|
-
Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target so the new leader can reuse the same binding, then promote only after the grace window expires. The leader does not restore persisted followers into the live registry speculatively: after a bounded re-registration grace window it removes follower records that still lack a live bus owner from current `state.json`,
|
|
158
|
+
Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target so the new leader can reuse the same binding, then promote only after the grace window expires. Every successful reuse refreshes the binding timestamp. The leader does not restore persisted followers into the live registry speculatively: after a bounded re-registration grace window it removes older follower records that still lack a live bus owner from current `state.json`, while preserving a recently refreshed binding across the brief registry gap caused by follower session replacement and never deleting Telegram tabs. This preserves real thread bindings through transient reload gaps without allowing historical records or competing pollers to masquerade as live state.
|
|
159
159
|
|
|
160
160
|
## Leader/Follower Communication
|
|
161
161
|
|
|
@@ -231,7 +231,7 @@ In Telegram private-chat Threaded Mode:
|
|
|
231
231
|
- Prompts typed in a thread route to the owning instance.
|
|
232
232
|
- Replies, previews, files, voice, and buttons stay in that thread.
|
|
233
233
|
- Queue controls and reactions affect only that instance target.
|
|
234
|
-
-
|
|
234
|
+
- Telegram's native `…typing` indicator for real agent work is sent to that instance thread and mirrored to `All`; `All` is the aggregate surface and should show activity when any bound instance is running a Telegram turn, local prompt, or autonomous continuation. Terminal `Active` remains Telegram-turn-specific. Startup/connect/reload/recovery must not send activity by themselves.
|
|
235
235
|
- If the instance disconnects, the leader can post/update a compact status: `Instance offline`.
|
|
236
236
|
- If the same live binding identity returns, it can reclaim the thread and post a compact reconnect status.
|
|
237
237
|
|
|
@@ -289,22 +289,22 @@ Every outbound request carries its target. The leader injects `message_thread_id
|
|
|
289
289
|
|
|
290
290
|
Threaded Mode should make follower threads behave like normal Telegram instance surfaces, with the leader acting only as transport owner. Any feature in the matrix below that works for the leader must either work for followers or have an explicit documented exception.
|
|
291
291
|
|
|
292
|
-
| Surface
|
|
293
|
-
|
|
|
294
|
-
| Prompt intake
|
|
295
|
-
| Queued-message removal reactions | 👎/👻/💔/💩/🗑 removes pending prompt/media turn
|
|
296
|
-
| Queue priority reactions
|
|
297
|
-
| Message edits
|
|
298
|
-
| Callbacks/buttons/menus
|
|
299
|
-
| Replies/finals
|
|
300
|
-
| Previews/Rich Drafts
|
|
301
|
-
| Attachments/voice
|
|
302
|
-
| Native activity status
|
|
303
|
-
| Leader election / promotion
|
|
304
|
-
| `/start` command/menu bootstrap
|
|
305
|
-
| Follower reconnect
|
|
306
|
-
| Unbound thread reroute/restore
|
|
307
|
-
| Status/menu diagnostics
|
|
292
|
+
| Surface | Leader behavior | Follower requirement | Routing/ownership invariant | Regression evidence |
|
|
293
|
+
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
294
|
+
| Prompt intake | Thread prompt queues locally | Thread prompt is forwarded and queued by the owning follower | Target ownership routes by `{ chatId, threadId }` before local handling | Routing tests for foreign target message forwarding |
|
|
295
|
+
| Queued-message removal reactions | 👎/👻/💔/💩/🗑 removes pending prompt/media turn | Same reaction on a queued follower prompt removes that follower's pending turn before dispatch | When the leader forwards a prompt to a follower, it records `chatId/messageId -> follower instance` because Bot API reaction updates expose chat/message but not thread id | Update runtime regression records forwarded message ownership and forwards the later reaction |
|
|
296
|
+
| Queue priority reactions | 👍/⚡/❤/🕊/🔥 prioritizes queued prompts | Same reactions prioritize follower queued prompts | Reaction forwarding uses stored message ownership, then follower mutates its local queue | Reaction mutation tests plus forwarded-reaction coverage |
|
|
297
|
+
| Message edits | Edits update matching queued prompt text | Edits in a follower thread update that follower's queued prompt | Message target ownership forwards edits to the owning instance; stored message ownership is the fallback when Telegram edit payloads omit thread id | Update routing tests for foreign target and message-owned edited-message forwarding |
|
|
298
|
+
| Callbacks/buttons/menus | Callback handled by the owning instance/menu state | Follower callbacks are forwarded to the owning follower; follower menu sends/edits/deletes route through leader transport | Leader records ownership for follower-sent Bot API messages so callbacks can route by message id even when Telegram omits thread id; Bot API edit/delete lacks thread id, so follower bus allows validated same-chat message operations | Callback forwarding, generated-button target, bus follower-sent ownership, and bus edit/delete allowlist tests |
|
|
299
|
+
| Replies/finals | Final replies land in the same thread | Follower finals go through leader transport into follower thread | Outbound calls carry target and inject `message_thread_id` | Reply delivery and bus API tests |
|
|
300
|
+
| Previews/Rich Drafts | Draft previews use the active thread target | Follower previews use the same native draft lifecycle through the leader | Preview transport preserves target and draft id | Preview thread-target tests |
|
|
301
|
+
| Attachments/voice | Files and voice upload in the instance thread | Follower uploads route through leader multipart transport | Multipart calls are target-scoped and follower-authorized | Bus allowlist and outbound delivery tests |
|
|
302
|
+
| Native activity status | `sendChatAction(typing)` renders Telegram's native `…typing` indicator in the assigned thread and mirrors aggregate `All` for every agent run, including local and autonomous work; terminal status changes to `active` only for Telegram-owned turns | Followers route one thread action and one aggregate action through leader transport for any agent run while retaining their stable terminal role during non-Telegram work | Agent lifecycle targets the active Telegram turn when present and otherwise the instance binding; one keyed loop avoids duplicate aggregate sends/rate-limit pressure | Agent-start binding, typing-loop, target-routing, and terminal-status regressions |
|
|
303
|
+
| Leader election / promotion | Current leader keeps its thread across reload | A promoted follower keeps its existing thread, slot, and name when elected and after later reload | Promotion converts the current follower binding into the leader profile before forced lock acquisition, so leader startup reuses it instead of provisioning a new thread | Follower heartbeat recovery passes binding snapshot into promotion; own-topic provisioner reuses promoted bindings |
|
|
304
|
+
| `/start` command/menu bootstrap | Registers visible bot commands and opens the menu | Follower `/start` can refresh the bot command menu through the leader and open its local menu without warnings | Bot command registration is a validated global Bot API call allowed through trusted follower bus transport | Bus allowlist regression for `setMyCommands` |
|
|
305
|
+
| Follower reconnect | Existing leader binding is reused only when still usable | Same-process `/new` or `/reload` suspends the old follower socket/context and automatically re-registers the new session to the exact prior target; explicit reconnect to a genuinely closed/stale Telegram tab still recreates a visible thread before success | A short-lived handoff carries the assigned target across session replacement, and the leader transfers that binding to the new runtime instance id by stable manual-follower identity; stale Bot API errors remain the proof for fresh provisioning | Session handoff/refresh, leader binding-transfer, persisted leader-reload reuse, and stale-target replacement regressions |
|
|
306
|
+
| Unbound thread reroute/restore | New unbound thread can route to a live instance or replace the selected instance thread | Same chooser exposes all currently live bus leader/follower targets; restore is offered from concrete unbound threads, not historical snapshots | Live bus roster plus active target bindings define the selectable set; history/state snapshots are not authority | Routing chooser regressions for live target filtering and restore rows |
|
|
307
|
+
| Status/menu diagnostics | Status reflects leader role, queue, and target | Follower status reflects follower role, thread name, queue, and bus health | Status is local runtime truth plus bus registration state, not leader queue state | Status and bus diagnostics tests |
|
|
308
308
|
|
|
309
309
|
## Queue And State Scoping
|
|
310
310
|
|
package/index.ts
CHANGED
|
@@ -1176,14 +1176,6 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1176
1176
|
registrationState: telegramBusFollowerRegistrationState,
|
|
1177
1177
|
instanceId: telegramInstanceId,
|
|
1178
1178
|
suspendPolling: lockedPollingRuntime.suspend,
|
|
1179
|
-
onFollowerSessionDisconnect() {
|
|
1180
|
-
const target = telegramBusFollowerRegistrationState.getTarget();
|
|
1181
|
-
if (!target) return undefined;
|
|
1182
|
-
return BusFollower.markTelegramFollowerThreadStaleForSessionDisconnect({
|
|
1183
|
-
topicTargetStore: threadStore,
|
|
1184
|
-
target,
|
|
1185
|
-
});
|
|
1186
|
-
},
|
|
1187
1179
|
recordRuntimeEvent,
|
|
1188
1180
|
});
|
|
1189
1181
|
const queueSessionLifecycle = Queue.createTelegramSessionLifecycleRuntime({
|
|
@@ -1308,6 +1300,12 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1308
1300
|
telegramBusFollowerRegistrationState.isRegistered()
|
|
1309
1301
|
);
|
|
1310
1302
|
},
|
|
1303
|
+
canSendAgentActivity(ctx) {
|
|
1304
|
+
return (
|
|
1305
|
+
lockOwnershipGuard.ownsContext(ctx) ||
|
|
1306
|
+
telegramBusFollowerRegistrationState.isRegistered()
|
|
1307
|
+
);
|
|
1308
|
+
},
|
|
1311
1309
|
updateStatus,
|
|
1312
1310
|
recordRuntimeEvent,
|
|
1313
1311
|
});
|
package/lib/bindings.ts
CHANGED
|
@@ -268,6 +268,7 @@ interface TelegramLifecycleBindingDeps {
|
|
|
268
268
|
proactivePushTargetGetter: () => Queue.TelegramQueueTarget | undefined;
|
|
269
269
|
isProactivePushEnabled: () => boolean;
|
|
270
270
|
canSendProactivePush: (ctx: Pi.ExtensionContext) => boolean;
|
|
271
|
+
canSendAgentActivity: (ctx: Pi.ExtensionContext) => boolean;
|
|
271
272
|
updateStatus: TelegramBridgeStatusUpdater;
|
|
272
273
|
recordRuntimeEvent: TelegramRuntimeEventRecorder;
|
|
273
274
|
}
|
|
@@ -300,6 +301,7 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
300
301
|
proactivePushTargetGetter,
|
|
301
302
|
isProactivePushEnabled,
|
|
302
303
|
canSendProactivePush,
|
|
304
|
+
canSendAgentActivity,
|
|
303
305
|
updateStatus,
|
|
304
306
|
recordRuntimeEvent,
|
|
305
307
|
}: TelegramLifecycleBindingDeps): void {
|
|
@@ -396,6 +398,16 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
396
398
|
const agentStartWithDedupReset = Lifecycle.createAgentStartDedupHook(
|
|
397
399
|
agentLifecycleHooks.onAgentStart,
|
|
398
400
|
);
|
|
401
|
+
const startAgentActivityTypingLoop = (ctx: Pi.ExtensionContext): void => {
|
|
402
|
+
if (!canSendAgentActivity(ctx)) return;
|
|
403
|
+
const turn = activeTurnRuntime.get();
|
|
404
|
+
const target = turn?.target ?? proactivePushTargetGetter();
|
|
405
|
+
promptDispatchRuntime.startTypingLoop(
|
|
406
|
+
ctx,
|
|
407
|
+
turn?.chatId ?? target?.chatId ?? proactivePushChatIdGetter(),
|
|
408
|
+
{ target },
|
|
409
|
+
);
|
|
410
|
+
};
|
|
399
411
|
const startActiveTurnTypingLoop = (ctx: Pi.ExtensionContext): void => {
|
|
400
412
|
const turn = activeTurnRuntime.get();
|
|
401
413
|
promptDispatchRuntime.startTypingLoop(ctx, turn?.chatId, {
|
|
@@ -431,7 +443,10 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
431
443
|
},
|
|
432
444
|
onSessionBeforeCompact: compactionObserver.onSessionBeforeCompact,
|
|
433
445
|
onSessionCompact: compactionObserver.onSessionCompact,
|
|
434
|
-
onAgentStart
|
|
446
|
+
async onAgentStart(event, ctx) {
|
|
447
|
+
await agentStartWithDedupReset(event, ctx);
|
|
448
|
+
startAgentActivityTypingLoop(ctx);
|
|
449
|
+
},
|
|
435
450
|
async onToolExecutionStart(_event, _ctx) {
|
|
436
451
|
agentLifecycleHooks.onToolExecutionStart();
|
|
437
452
|
},
|
package/lib/bus-follower.ts
CHANGED
|
@@ -40,6 +40,9 @@ export interface TelegramFollowerSessionHandoff {
|
|
|
40
40
|
pid: number;
|
|
41
41
|
instanceId: string;
|
|
42
42
|
createdAtMs: number;
|
|
43
|
+
target: TelegramTarget;
|
|
44
|
+
slot?: string;
|
|
45
|
+
threadName?: string;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
export function getTelegramFollowerSessionHandoff():
|
|
@@ -53,7 +56,10 @@ export function getTelegramFollowerSessionHandoff():
|
|
|
53
56
|
if (
|
|
54
57
|
typeof handoff.pid !== "number" ||
|
|
55
58
|
typeof handoff.instanceId !== "string" ||
|
|
56
|
-
typeof handoff.createdAtMs !== "number"
|
|
59
|
+
typeof handoff.createdAtMs !== "number" ||
|
|
60
|
+
!handoff.target ||
|
|
61
|
+
typeof handoff.target !== "object" ||
|
|
62
|
+
typeof handoff.target.chatId !== "number"
|
|
57
63
|
) {
|
|
58
64
|
return undefined;
|
|
59
65
|
}
|
|
@@ -83,16 +89,19 @@ export interface TelegramBusFollowerRegistrationRuntime<TContext> {
|
|
|
83
89
|
registerWithLeader: (
|
|
84
90
|
ctx: TContext,
|
|
85
91
|
leader: { busSocketPath?: string; busSecret?: string },
|
|
92
|
+
options?: { target?: TelegramTarget },
|
|
86
93
|
) => Promise<boolean>;
|
|
87
94
|
setContext: (ctx: TContext) => void;
|
|
88
95
|
stop: () => void;
|
|
89
96
|
}
|
|
90
97
|
|
|
91
98
|
export interface TelegramBusFollowerSessionReplacementSuspenderDeps {
|
|
92
|
-
registrationState: Pick<
|
|
99
|
+
registrationState: Pick<
|
|
100
|
+
TelegramBusFollowerRegistrationState,
|
|
101
|
+
"isRegistered" | "getTarget" | "getSlot" | "getThreadName"
|
|
102
|
+
>;
|
|
93
103
|
instanceId: string;
|
|
94
104
|
suspendPolling: () => Promise<void>;
|
|
95
|
-
onFollowerSessionDisconnect?: () => Promise<void> | void;
|
|
96
105
|
recordRuntimeEvent: (
|
|
97
106
|
category: string,
|
|
98
107
|
error: unknown,
|
|
@@ -514,40 +523,30 @@ function isTelegramStaleContextError(error: unknown): boolean {
|
|
|
514
523
|
);
|
|
515
524
|
}
|
|
516
525
|
|
|
517
|
-
export async function markTelegramFollowerThreadStaleForSessionDisconnect(input: {
|
|
518
|
-
topicTargetStore: Threads.TelegramTopicTargetStore;
|
|
519
|
-
target: TelegramTarget;
|
|
520
|
-
}): Promise<void> {
|
|
521
|
-
await input.topicTargetStore.load();
|
|
522
|
-
const record = input.topicTargetStore.list().find(
|
|
523
|
-
(candidate) =>
|
|
524
|
-
candidate.target.chatId === input.target.chatId &&
|
|
525
|
-
candidate.target.threadId === input.target.threadId,
|
|
526
|
-
);
|
|
527
|
-
const marked = input.topicTargetStore.markStaleByTarget(
|
|
528
|
-
input.target,
|
|
529
|
-
"unknown",
|
|
530
|
-
"Follower session reloaded before reconnect.",
|
|
531
|
-
);
|
|
532
|
-
const forgotIdentity = record?.profileKey
|
|
533
|
-
? input.topicTargetStore.forgetIdentityByProfileKey(record.profileKey)
|
|
534
|
-
: false;
|
|
535
|
-
if (marked || forgotIdentity) await input.topicTargetStore.persist();
|
|
536
|
-
}
|
|
537
|
-
|
|
538
526
|
export function createTelegramBusFollowerSessionReplacementSuspender(
|
|
539
527
|
deps: TelegramBusFollowerSessionReplacementSuspenderDeps,
|
|
540
528
|
): () => Promise<void> {
|
|
529
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
530
|
+
const getPid = deps.getPid ?? (() => process.pid);
|
|
541
531
|
return async () => {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
532
|
+
const target = deps.registrationState.getTarget();
|
|
533
|
+
if (deps.registrationState.isRegistered() && target) {
|
|
534
|
+
setTelegramFollowerSessionHandoff({
|
|
535
|
+
pid: getPid(),
|
|
536
|
+
instanceId: deps.instanceId,
|
|
537
|
+
createdAtMs: getNowMs(),
|
|
538
|
+
target,
|
|
539
|
+
slot: deps.registrationState.getSlot(),
|
|
540
|
+
threadName: deps.registrationState.getThreadName(),
|
|
541
|
+
});
|
|
545
542
|
deps.recordRuntimeEvent(
|
|
546
543
|
"bus",
|
|
547
|
-
"Telegram follower registration
|
|
544
|
+
"Telegram follower registration suspended for session replacement",
|
|
548
545
|
{
|
|
549
|
-
phase: "follower-session-
|
|
546
|
+
phase: "follower-session-handoff",
|
|
550
547
|
instanceId: deps.instanceId,
|
|
548
|
+
chatId: target.chatId,
|
|
549
|
+
threadId: target.threadId,
|
|
551
550
|
},
|
|
552
551
|
);
|
|
553
552
|
}
|
|
@@ -568,6 +567,7 @@ export function createTelegramBusFollowerSessionRefreshHook<TContext>(
|
|
|
568
567
|
const restored = await deps.registrationRuntime.registerWithLeader(
|
|
569
568
|
ctx,
|
|
570
569
|
lockState.lock,
|
|
570
|
+
{ target: handoff.target },
|
|
571
571
|
);
|
|
572
572
|
if (restored) {
|
|
573
573
|
setTelegramFollowerSessionHandoff(undefined);
|
|
@@ -828,7 +828,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
828
828
|
heartbeatInterval.unref?.();
|
|
829
829
|
};
|
|
830
830
|
return {
|
|
831
|
-
registerWithLeader: async (ctx, leader) => {
|
|
831
|
+
registerWithLeader: async (ctx, leader, options) => {
|
|
832
832
|
const leaderSocketPath =
|
|
833
833
|
leader.busSocketPath ??
|
|
834
834
|
deps.getLeaderSocketPath?.() ??
|
|
@@ -853,7 +853,10 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
853
853
|
(ctx.cwd ? basename(ctx.cwd) : undefined),
|
|
854
854
|
cwd: ctx.cwd,
|
|
855
855
|
pid: getPid(),
|
|
856
|
-
target:
|
|
856
|
+
target:
|
|
857
|
+
options?.target ??
|
|
858
|
+
deps.registrationState?.getTarget() ??
|
|
859
|
+
lastKnownTarget,
|
|
857
860
|
busSocketPath:
|
|
858
861
|
deps.getFollowerBusSocketPath?.() ?? deps.followerBusSocketPath,
|
|
859
862
|
connectedAtMs: getNowMs(),
|
package/lib/bus-leader.ts
CHANGED
|
@@ -236,6 +236,8 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
|
|
|
236
236
|
});
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
export const TELEGRAM_BUS_RECENT_FOLLOWER_BINDING_GRACE_MS = 10_000;
|
|
240
|
+
|
|
239
241
|
export interface TelegramBusFollowerBindingRealityDeps {
|
|
240
242
|
topicTargetStore: Pick<
|
|
241
243
|
Threads.TelegramTopicTargetStore,
|
|
@@ -248,6 +250,8 @@ export interface TelegramBusFollowerBindingRealityDeps {
|
|
|
248
250
|
| "setBotState"
|
|
249
251
|
>;
|
|
250
252
|
followerRegistry: Pick<TelegramBusFollowerRegistry, "list">;
|
|
253
|
+
getNowMs?: () => number;
|
|
254
|
+
recentBindingGraceMs?: number;
|
|
251
255
|
recordRuntimeEvent: (
|
|
252
256
|
category: string,
|
|
253
257
|
error: unknown,
|
|
@@ -301,8 +305,13 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
|
|
|
301
305
|
export function createTelegramBusFollowerBindingRealityReconciler(
|
|
302
306
|
deps: TelegramBusFollowerBindingRealityDeps,
|
|
303
307
|
): () => Promise<number> {
|
|
308
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
309
|
+
const recentBindingGraceMs =
|
|
310
|
+
deps.recentBindingGraceMs ??
|
|
311
|
+
TELEGRAM_BUS_RECENT_FOLLOWER_BINDING_GRACE_MS;
|
|
304
312
|
return async () => {
|
|
305
313
|
await deps.topicTargetStore.load();
|
|
314
|
+
const nowMs = getNowMs();
|
|
306
315
|
const liveInstanceIds = new Set(
|
|
307
316
|
deps.followerRegistry.list().map((follower) => follower.instanceId),
|
|
308
317
|
);
|
|
@@ -310,7 +319,8 @@ export function createTelegramBusFollowerBindingRealityReconciler(
|
|
|
310
319
|
return (
|
|
311
320
|
record.owner?.kind === "manual-follower" &&
|
|
312
321
|
!!record.instanceId &&
|
|
313
|
-
!liveInstanceIds.has(record.instanceId)
|
|
322
|
+
!liveInstanceIds.has(record.instanceId) &&
|
|
323
|
+
nowMs - record.updatedAtMs > recentBindingGraceMs
|
|
314
324
|
);
|
|
315
325
|
});
|
|
316
326
|
const staleInstanceIds = new Set(
|
|
@@ -452,18 +462,19 @@ export function createTelegramBusFollowerTargetProvisioner(
|
|
|
452
462
|
claimPendingTargets: false,
|
|
453
463
|
});
|
|
454
464
|
const recordsBeforeProvision = deps.topicTargetStore.list();
|
|
465
|
+
const followerProfileKey =
|
|
466
|
+
registration.profileKey ?? `manual:${registration.instanceId}`;
|
|
455
467
|
const reconnectRecord = registration.target
|
|
456
468
|
? recordsBeforeProvision.find((record) => {
|
|
457
469
|
return (
|
|
458
470
|
record.owner?.kind === "manual-follower" &&
|
|
459
|
-
record.instanceId === registration.instanceId
|
|
471
|
+
(record.instanceId === registration.instanceId ||
|
|
472
|
+
record.profileKey === followerProfileKey) &&
|
|
460
473
|
record.target.chatId === registration.target?.chatId &&
|
|
461
474
|
record.target.threadId === registration.target.threadId
|
|
462
475
|
);
|
|
463
476
|
})
|
|
464
477
|
: undefined;
|
|
465
|
-
const followerProfileKey =
|
|
466
|
-
registration.profileKey ?? `manual:${registration.instanceId}`;
|
|
467
478
|
const followerOwner =
|
|
468
479
|
Threads.getTelegramThreadOwnerFromProfileKey(followerProfileKey);
|
|
469
480
|
const registrationKey = followerProfileKey || registration.instanceId;
|
|
@@ -494,6 +505,25 @@ export function createTelegramBusFollowerTargetProvisioner(
|
|
|
494
505
|
let result = reconnectRecord
|
|
495
506
|
? { target: reconnectRecord.target, reused: true, record: reconnectRecord }
|
|
496
507
|
: await provisionTarget();
|
|
508
|
+
if (reconnectRecord) {
|
|
509
|
+
const nowMs = getNowMs();
|
|
510
|
+
const transferredRecord = deps.topicTargetStore.upsert({
|
|
511
|
+
...reconnectRecord,
|
|
512
|
+
instanceId: registration.instanceId,
|
|
513
|
+
updatedAtMs: nowMs,
|
|
514
|
+
lastSyncObservedAtMs: nowMs,
|
|
515
|
+
lastReconcileAction:
|
|
516
|
+
reconnectRecord.instanceId === registration.instanceId
|
|
517
|
+
? "follower-register-reuse"
|
|
518
|
+
: "follower-session-handoff",
|
|
519
|
+
});
|
|
520
|
+
await deps.topicTargetStore.persist();
|
|
521
|
+
result = {
|
|
522
|
+
target: transferredRecord.target,
|
|
523
|
+
reused: true,
|
|
524
|
+
record: transferredRecord,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
497
527
|
deps.setSyncState(
|
|
498
528
|
Sync.markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
|
|
499
529
|
nowMs: getNowMs(),
|