@llblab/pi-telegram 0.52.0 → 0.52.2
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 +6 -5
- package/BACKLOG.md +18 -1
- package/CHANGELOG.md +12 -0
- package/README.md +5 -2
- package/dist/lib/bindings.js +1 -0
- package/dist/lib/bus-api.js +4 -3
- package/dist/lib/extension.js +4 -0
- package/dist/lib/journal.d.ts +7 -1
- package/dist/lib/journal.js +72 -11
- package/dist/lib/locks.d.ts +5 -0
- package/dist/lib/locks.js +29 -11
- package/dist/lib/polling.d.ts +2 -0
- package/dist/lib/polling.js +12 -2
- package/dist/lib/queue.d.ts +2 -0
- package/dist/lib/queue.js +25 -3
- package/dist/lib/routing.d.ts +1 -0
- package/dist/lib/routing.js +118 -44
- package/dist/lib/sync.d.ts +5 -0
- package/dist/lib/sync.js +48 -5
- package/dist/lib/telegram-api.d.ts +2 -0
- package/dist/lib/telegram-api.js +22 -1
- package/dist/lib/threads.d.ts +6 -4
- package/dist/lib/threads.js +45 -3
- package/dist/lib/updates.d.ts +11 -4
- package/dist/lib/updates.js +34 -18
- package/dist/package.json +1 -1
- package/docs/architecture.md +14 -8
- package/docs/ui-style.md +1 -1
- package/lib/bindings.ts +1 -0
- package/lib/bus-api.ts +4 -2
- package/lib/extension.ts +5 -0
- package/lib/journal.ts +80 -11
- package/lib/locks.ts +28 -11
- package/lib/polling.ts +21 -2
- package/lib/queue.ts +26 -3
- package/lib/routing.ts +91 -36
- package/lib/sync.ts +47 -5
- package/lib/telegram-api.ts +26 -1
- package/lib/threads.ts +46 -7
- package/lib/updates.ts +43 -21
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -84,21 +84,22 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
|
|
|
84
84
|
|
|
85
85
|
- Admission is journal-first: validate and persist the complete `getUpdates` response before one monotonic offset commit, then signal an independent worker without awaiting semantic execution. Missing cursor with a non-empty journal, malformed/foreign authority, or capacity exhaustion fails closed. “Durable” means process-crash recovery after atomic rename, not unflushed host/kernel/filesystem/device/power-loss survival.
|
|
86
86
|
- Storage cutovers must reconcile actual consumer locations before correcting path adapters. Never normalize a relative historical reference into new authority or treat equal reference strings / empty canonical storage as source completeness. Exact-path preflight is lexical only; physical identity, historical coverage, writer closure and migration remain separate proofs.
|
|
87
|
-
- Workspace mutations acquire cross-process admission before their shared process-local gate and hold it through asynchronous API work and durable settlement. Topic lifecycle, complete unbound/reroute target handling, manual disconnect, and session-restart cleanup use profile-wide scope; either retained retirement-fence phase rejects them before state access. Cleanup
|
|
87
|
+
- Workspace mutations acquire cross-process admission before their shared process-local gate and hold it through asynchronous API work and durable settlement. Topic lifecycle, complete unbound/reroute target handling, manual disconnect, and session-restart cleanup use profile-wide scope; either retained retirement-fence phase rejects them before state access. Cleanup admission spans intent publication, target mutation, persistence, and successful transport release. Explicit manual disconnect may stop only its captured local transport after cleanup admission/effects fail; it must not bypass Thread mutation fences, release a replacement connection, claim unconfirmed deletion, or retry an uncertain stop. Session-restart cleanup retains its admission-required behavior. Detached reconciliation that mutates Thread state must reacquire fresh profile admission through the same gate; it cannot inherit a caller lease that ended before its timer runs. A live operation ID has one process-local caller: concurrent reuse is rejected before lease acquisition, while retry after the caller exits may resume exact durable authority.
|
|
88
88
|
- Workspace retirement is capacity-pressure-only. Elapsed time and heartbeat silence never trigger deletion; only complete `A`–`Z` exhaustion may propose the oldest continuously proven inactive, fully unprotected binding. Exact deletion, durable retirement, and fence completion precede slot reuse. Authorized demand-driven rotation retries one failed fresh allocation after retirement; restore-only follower startup never evicts. Release ordinary registration/provisioning leases before acquiring the destructive fence, retain the shared mutation gate across retirement, and validate the exact permit immediately before one non-retried deletion. Persist an exact method/target-matched rejection before withdrawing its intent; release that fence only after durable withdrawal, retaining the binding. A later attempt requires fresh operation authority. Protection reads must never repair, quarantine, or reset journals. Non-destructive owner detachment must atomically retain one exact Workspace binding and its letter while removing only its uniquely matched owner record and stamping first inactivity; it never manufactures Thread-deletion evidence or clears accepted work. During authenticated follower registration, the exact Workspace claim owns the letter: a mismatched retained target record is repaired to that claim before binding commit, while any unrelated binding that owns the stale letter remains untouched. Retained prune observations are bounded, non-routing and registration/profile/epoch/runtime-fenced. One unfinished preservation operation retains its admission identity across fresh-PID-proof retries; it can never become deletion authority. Leader quit requires completed delivery/polling/worker teardown under the captured session generation, profile and epoch; reload/new/resume/fork never establish inactivity. Unknown deletion outcomes retain their fence; only confirmed durable completion permits reuse.
|
|
89
|
-
- Foreign forwarding settles as `accepted`, `retryable`, or `terminal-rejected`. Only an authenticated acknowledgement carrying the expected `deliveryId` and `sourceUpdateId` releases leader journal authority. Negative, missing, stale, mismatched, or capacity-failed settlement remains durable; callback error answers are side effects only.
|
|
89
|
+
- Foreign forwarding settles as `accepted`, `retryable`, or `terminal-rejected`. Only an authenticated acknowledgement carrying the expected `deliveryId` and `sourceUpdateId` releases leader journal authority. Negative, missing, stale, mismatched, or capacity-failed settlement remains durable; callback error answers are side effects only. Exception: the operator-approved fixed chooser deadline may discard its exact unclaimed donor pending source without a body archive or delivery-success claim. Accepted recipient queue/work and bound Threads are untouched; old donor controls and late reports cannot replay it. The lifetime and temporary cleanup contract lives in `docs/architecture.md`.
|
|
90
90
|
- A forwarding delivery id is stable across registration replacement and derives from envelope kind, source `update_id`, and stable recipient binding. Runtime instance and registration generation remain separate attempt fences. Persisted message ownership carries the stable binding so replay can rebind only to its current authenticated registration.
|
|
91
91
|
- A queued receipt persists its acquiring runtime instance, OS pid/process-birth identity, session generation, acquisition id, and acquisition time. Only exact authority may settle or discard it. Cached presence is not current execution proof: prepared custody must revalidate the exact queued owner/group without recovery and refuse offers or uncertain reads. Completion requires an exact removal acknowledgement, never merely `!ready`; a retained acknowledgement permits local cleanup only, not replay. Same-process session replacement may reconstruct the claim and the original process may settle after transport ownership moves; a foreign process may neither replay nor settle it through generic removal or a copied acquisition id.
|
|
92
92
|
- Startup and elapsed time are not owner-death proof; queued authority has no time lease. Dead-owner cleanup groups the complete receipt and transactionally rechecks pid liveness plus process-birth identity: only an absent PID or mismatched stable Linux/macOS birth proof discards all session-owned sources without replay; a matching proof is `alive`, while Windows or inaccessible birth metadata is `unverifiable`, and both non-dead outcomes keep authority queued. Under actual `A`–`Z` allocation pressure, retirement may invoke that journal-owned CAS only for a current inactive binding after complete strict source inspection, no local work/live owner/delivery authority, whole unoffered receipt groups, and a preflight proving every grouped owner dead. It must then recapture all protection before preparing deletion; partial progress never grants deletion authority. The live-transfer contract is authenticated offer → exact-generation bounded payload staging → recipient CAS acceptance → exact receipt-and-owner ACK → donor removal → recipient readiness. The offer freezes donor settlement/recovery; controls rebuild local closures; negative/mismatched pre-acceptance ACK cancels only an unaccepted offer and retains donor work; a lost post-acceptance ACK cannot cancel recipient authority and leaves donor memory frozen for explicit reconciliation.
|
|
93
93
|
- Execution failures persist bounded diagnostics and attempt state as `retry-wait`, except that an exact Telegram HTTP 400 stale/deleted-thread API failure with a proven `{chatId, threadId}` terminally settles the currently executing source after best-effort shared binding invalidation. Automatic retry continues indefinitely with exponential `1s → 2s → 4s → 8s → 16s → 32s → 60s` delay capped at 60 seconds; later independent updates continue draining, durable authority is never silently discarded, and legacy `failed` entries resume automatically at startup. Snapshot-plus-segment journals compact only after 256 unapplied revisions or 4 MiB; snapshot-first cleanup tolerates redundant segments, and empty authority may atomically rebind bot/profile identity. Missing snapshots left by the retired broad temp cleanup rebuild only from a complete provably empty segment chain, while revisionless snapshots may recover from a validated later segment predecessor; otherwise the transaction-locked reset deletes damaged segments before publishing a fresh journal, with accepted input loss and informational evidence, not a quarantine copy. Unsupported versions remain untouched; strict protection reads never reset. Startup best-effort removes obsolete current-runtime root/session `recovery/` folders without touching pre-0.52.0 storage.
|
|
94
94
|
- Business connection chats are a separate namespace even when their chat/message IDs match bot-chat IDs. Default DM routing must never infer private-queue deletion intent from `deleted_business_messages`; raw companion handlers remain separate owners.
|
|
95
|
+
- Clock-bearing chooser sources survive cold spending until their fixed deadline; new-world forgetting preserves their positively unbound temporary frame without restoring routing intent. Expiry is a donor discard, not recipient cancellation or source execution completion. Confirmed expiry releases worker custody before UI/cleanup awaits; metadata retries retain no prompt bodies and cannot repeat an issued delete.
|
|
95
96
|
- An unresolved reaction delays only the exact governed queue item identified by chat/message sources, not unrelated queue work. An explicit immutable `preApprovalExcluded: true` cannot govern accepted work, even after re-pairing; missing or false exclusion evidence must not bypass the dependency guard. Prepared v3 drain may dispose of excluded pending input only through journal-owned `removeExcluded`, never generic raw completion; mixed requests containing non-excluded input must fail atomically. Queue receipt publication follows in-memory append and precedes dispatch request; receipt-bearing turns remain queued until every exact source commits.
|
|
96
97
|
- The detailed implementation and release gates live in [`docs/architecture.md`](./docs/architecture.md), [`docs/multi-instance-bus.md`](./docs/multi-instance-bus.md), and [`BACKLOG.md`](./BACKLOG.md).
|
|
97
98
|
|
|
98
99
|
### 4.4 Queue, Delivery, And User Surfaces
|
|
99
100
|
|
|
100
|
-
- Queue lane/kind admission is explicit. Dispatch waits for active-turn, pending-dispatch, control, compaction, `ctx.isIdle()`, and Pi pending-message guards; a dispatched prompt stays queued until `agent_start` consumes it. The terminal `+N` suffix is a yellow count of executable prompts still waiting, excludes the dispatched head immediately, and never counts current agent work from any source. Each prompt is one object with one active lane and no reserved return slot. Normal and Priority are separate FIFO lanes: crossing lanes removes it from the source and appends it at the destination tail, while Keep/Skip and same-category emoji changes preserve lane position.
|
|
101
|
-
- `/stop`, `/abort`, `/next`, and `/continue` respectively reset+abort, abort while preserving queue, force the next turn, and enqueue a control-lane continuation. Abort-history folding applies only to Telegram-owned active turns. A busy `/next` marks only its exact active Telegram turn: terminal abort settlement attempts one explicit abort notice before the exact selected queued prompt receives its dispatch notice. Notice failure is diagnostic and cannot block dispatch; a later `/abort` or `/stop` cancels both pending transition notices before taking ownership. Target command adapters must forward these transition ports. A successful dispatch notice keeps the queued prompt's one reply-header claim across agent start, so later messages in that turn never repeat it.
|
|
101
|
+
- Queue lane/kind admission is explicit. Dispatch waits for active-turn, pending-dispatch, control, compaction, `ctx.isIdle()`, and Pi pending-message guards; a dispatched prompt stays queued until `agent_start` consumes it. The terminal `+N` suffix is a yellow count of executable prompts still waiting, excludes the dispatched head immediately, and never counts current agent work from any source. Each prompt is one object with one active lane and no reserved return slot. Normal and Priority are separate FIFO lanes: crossing lanes removes it from the source and appends it at the destination tail, while Keep/Skip and same-category emoji changes preserve lane position. For ordinary Normal/Priority prompts, complete reaction sets independently derive Priority from recognized positive emoji and Skip from recognized negative emoji; both may coexist, suppressed turns retain durable receipts while waiting, and Skip settles them only when the prompt reaches dispatch before dropping it without inference. Suppressed turns remain visible at a struck-through physical ordinal without contributing to executable queue counters, while graceful session shutdown discards all remaining queue authority before clearing memory.
|
|
102
|
+
- `/stop`, `/abort`, `/next`, and `/continue` respectively reset+abort, abort while preserving queue, force the next turn, and enqueue a control-lane continuation. A negative reaction to an original standalone `/continue` settles and removes only its exact waiting source-addressed continuation; positive reactions do not change its control lane. Protect the Pi-owned pending head and already-started work, preserve other queue positions, and never address synthetic model continuations by their reply anchor. Settlement failure retains the continuation. Abort-history folding applies only to Telegram-owned active turns. A busy `/next` marks only its exact active Telegram turn: terminal abort settlement attempts one explicit abort notice before the exact selected queued prompt receives its dispatch notice. Notice failure is diagnostic and cannot block dispatch; a later `/abort` or `/stop` cancels both pending transition notices before taking ownership. Target command adapters must forward these transition ports. A successful dispatch notice keeps the queued prompt's one reply-header claim across agent start, so later messages in that turn never repeat it.
|
|
102
103
|
- A model selection from an authorized Telegram target may stop and continue any interruptible active agent run in that same Pi session, including local/TUI work. Queue the synthetic control-lane continuation before aborting; when no Telegram prompt owns the run, bind continuation and reply ownership to the exact model-menu chat/Thread/message. Delay abort until every active tool execution settles, and clear both selection and fallback target on cancellation, agent start, settlement, or session replacement.
|
|
103
104
|
- Telegram extension side effects must not hold Pi's core lifecycle hostage after semantic completion. Preserve ordering in extension-owned background work, record failures, and fence target/profile/transport/session authority.
|
|
104
105
|
- Complete assistant/guest model answers use Telegram-native Rich Markdown. Harness-owned menus, status, diagnostics, thinking, and tool evidence remain explicit HTML/plain or their documented native surface. Before Telegram preview or final delivery, strip every assistant-authored HTML comment regardless of Markdown position while keeping action activation top-level-only; a comment-only result sends no text message. Preserve literal code outside comments and structurally safe chunking; never split invalid markup.
|
|
@@ -136,7 +137,7 @@ The detailed map is canonical in [`docs/architecture.md`](./docs/architecture.md
|
|
|
136
137
|
- Keep interfaces consistent with their owning exported contract. Use local structural `*Like`/view types only for deliberate narrow projections, not duplicate source-of-truth models.
|
|
137
138
|
- Remove dead code immediately. Reachability from composition roots, public exports, tests, registered surfaces, and documented APIs—not recent usefulness—determines whether code is live. `node scripts/audit-exports.mjs` provides a read-only TypeScript-symbol inventory including aliases, JS consumers and public `api/` reexports (including namespaces); `namespaceEscapes` flags whole-module alias usage. No-external-reference candidates are not deletion proof. Review generated text, dynamic namespace access and public declaration reachability before removing or hiding a symbol.
|
|
138
139
|
- Treat every meaningful `lib/extension.ts` edit as a composition-pressure check, but keep one-off live adapter wiring there when extraction would only hide cross-domain state.
|
|
139
|
-
- Follow [`docs/ui-style.md`](./docs/ui-style.md) for interface copy, emoji, buttons, menus, and dialogs. Update the registry before assigning a new UI emoji meaning. Standalone notices use one fully bold emoji-led sentence with a terminal period; menu or chooser headings use the same hierarchy with a terminal colon. Material names may add nested italic emphasis without breaking the outer bold span.
|
|
140
|
+
- Follow [`docs/ui-style.md`](./docs/ui-style.md) for interface copy, emoji, buttons, menus, and dialogs. Update the registry before assigning a new UI emoji meaning. Standalone notices use one fully bold emoji-led sentence with a terminal period; menu or chooser headings use the same hierarchy with a terminal colon. Material names may add nested italic emphasis without breaking the outer bold span. Typed callback toasts preserve equivalent emoji-led plain text without a terminal sentence period; question/exclamation marks and ellipses remain. In-chat notices and explicit raw API payloads keep their punctuation.
|
|
140
141
|
- Markdown lists never contain blank lines between adjacent items; list items are not paragraphs. Use blank lines only between paragraphs or independently separated blocks. Markdown tables use compact source formatting with `---` separator cells and one surrounding space per cell. Preserve vendored references unchanged.
|
|
141
142
|
- Treat Windows filesystem, named-pipe, lock, heartbeat, and atomic-rename reports as high-signal evidence; reduce them to regressions or explicit platform caveats.
|
|
142
143
|
- Route significant runtime failures through the redacted recent-event recorder. Keep the compact TUI status at generic `error`; details belong in diagnostics.
|
package/BACKLOG.md
CHANGED
|
@@ -2,4 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
_This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Post-Release Transport Recovery Acceptance
|
|
6
|
+
|
|
7
|
+
- Release boundary: The operator authorized the hotfix on native regression and CI evidence; real Telegram smoke/fault acceptance remains open below, not a claimed completed check. Installing/reloading Pi or injecting live faults needs separate authorization.
|
|
8
|
+
- CI boundary: Native Linux/macOS/Windows checks exercise capped poll/admission backoff, preserved persistent-conflict stand-down and generation-fenced manual disconnect. Existing skipped strict journal witnesses remain a separate Windows boundary; green CI does not replace operator acceptance.
|
|
9
|
+
- [ ] Obtain operator acceptance for ordinary disconnect, unconfirmed-cleanup reporting and outage recovery without manual reconnect on a disposable setup. Local fake-transport/storage regressions do not certify real Telegram or platform fault behavior; installation/reload and live fault injection require separate authorization.
|
|
10
|
+
- Acceptance: Captured manual stop never affects a replacement connection or bypasses Thread deletion fences, uncertain stop is not retried, and accepted local work is preserved. Session-restart cleanup remains admission-required.
|
|
11
|
+
|
|
12
|
+
## Windows Reliability
|
|
13
|
+
|
|
14
|
+
- [ ] Strengthen native Windows reliability beyond the 0.52.0 ordinary-queue fix. Audit filesystem paths, named pipes, locking, owner fencing, heartbeat and atomic publication; cover interrupted writes, damaged state, ownership changes and lost-ACK retries with focused regressions.
|
|
15
|
+
- [ ] Investigate Windows-compatible strict journal evidence for Restore and cleanup without weakening source identity, scoped ACK or fail-closed protections. Inventory witnesses currently skipped on Windows and enable them only when equivalent native evidence exists.
|
|
16
|
+
- Acceptance: Local synthetic tests distinguish ordinary delivery from strict proofs; native Windows CI exercises supported paths and refusal boundaries. Linux emulation or a green matrix with skipped Restore witnesses does not establish Windows Restore acceptance.
|
|
17
|
+
|
|
18
|
+
## Consistent Callback Notification Copy Across Other Extensions
|
|
19
|
+
|
|
20
|
+
- [ ] Inventory Telegram button callback acknowledgements across all local extensions, including companion callbacks. Standardize the transient non-interactive notification bubbles (callback answers/toasts) to concise, consistent text with no trailing period.
|
|
21
|
+
- [ ] Distinguish transient callback bubbles from in-chat notices, chooser headings and blocking alerts; reconcile the owning UI-copy contracts before implementation rather than stripping punctuation from every message.
|
|
22
|
+
- Acceptance: All participating extensions follow the same callback-notification format, with focused copy checks for success, no-op and rejection outcomes; in-chat notice formatting and callback behavior remain unchanged.
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.52.2: Polling recovery, safe disconnect and Windows downloads
|
|
8
|
+
|
|
9
|
+
- `Polling recovery`: Non-conflict poll/admission failures back off from 1 to 30 seconds and keep retrying, allowing recovery after prolonged outages without manual reconnect. Backoff resets only after durable admission succeeds; persistent competing-client conflicts retain their existing terminal stand-down.
|
|
10
|
+
- `Disconnect safety`: Failed or unavailable Thread cleanup still attempts one captured local transport stop without bypassing Workspace fences. Reconnect revokes stale cleanup/stop continuations; delayed teardown cannot release replacement ownership. Unconfirmed cleanup is reported without claiming it was skipped, while uncertain stop/release outcomes remain incomplete and are never blindly retried ([#310](https://github.com/llblab/pi-telegram/pull/310)).
|
|
11
|
+
- `Windows downloads`: Publishing a complete attachment retries transient Windows `EPERM`/`EACCES` sharing failures for at most six rename attempts, without replaying HTTP. Cancellation stops the bounded wait; permanent/non-sharing failures still propagate and remove the private partial file. Concurrent downloads retain deterministic names and complete-file publication.
|
|
12
|
+
|
|
13
|
+
## 0.52.1: Finite chooser lifetime, continue cancellation and callback copy
|
|
14
|
+
|
|
15
|
+
- `Chooser expiry`: Fixed 60-minute clocks end unselected/unacknowledged donor routing without archives or replay. Fully resolved disposable tabs use fenced one-shot cleanup; accepted queues, independent work and restored Threads survive. Confirmed expiry clears worker custody even if cleanup fails; metadata retries use body-free proof and never repeat an issued delete. Cold startup preserves deadlines, and queue admission retains selected lifetime metadata.
|
|
16
|
+
- `Continue cancellation`: Negative reactions to an original standalone `/continue` now settle and remove its exact waiting continuation, even while other work runs. Other prompts, the Pi-owned dispatched head, active work and synthetic model continuations remain untouched. Positive reactions preserve the control lane; failed receipt settlement keeps the item waiting, and duplicate reactions cannot cancel twice.
|
|
17
|
+
- `Callback copy`: Shared typed callback toasts omit a terminal sentence period on direct and follower delivery. Questions, exclamations, ellipses, in-chat notices and explicit raw API payloads keep their punctuation.
|
|
18
|
+
|
|
7
19
|
## 0.52.0: Workspace Restore, routing lifetime and resilient runtime storage
|
|
8
20
|
|
|
9
21
|
- `Restore foundation`: One snapshot owns binding, slot, targets and one-shot grants. Publication and late creation receipts share a transaction; unnamed owners round-trip. Unknown or contradictory creation evidence blocks new Restore; absence and expiry never prove availability. Loading validates receipts before replacing working state, preserving disk, memory and source protection on conflict. Recovered targets survive expiry; conflicting creation stays blocked.
|
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ The connected Pi instance owns Telegram polling. Use `/telegram-connect <profile
|
|
|
61
61
|
|
|
62
62
|
An eligible single-text unbound Thread chooser offers **⛔️ Cancel routing**: the original stays private and is not sent to Pi; its Telegram message and Thread are not deleted. This does not cancel already selected or accepted work. When protected cancellation attempts remain, **Status → ❌ Pending cancellations** lets the owner review and finish them, including after restart. Historical unbound startup text has no main-menu entry or historical-review submenu. Eligible raw unsupported command/media/unknown historical private-Thread originals use a separate retain-only path, including on bound Threads without surviving Restore/temporary membership. Each worker generation reclassifies them after cold forgetting, blocking handler replay, generic Cancel and cold spending. Ordinary bound startup dispatch stops for these originals; same-kind live arrivals and owner/custody paths remain unchanged. Old historical-review buttons are refused without routing or abandoning the original; removal of the UI does not cancel previously accepted work.
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
Eligible routing choosers and their donor sources share a **fixed 60-minute lifetime** from the first acknowledged publication, preserved across refresh/restart. Expiry drops unselected or unacknowledged routing attempts without privately archiving prompt bodies; nondelivery is accepted, so resend manually if needed. Old controls and late replies cannot replay the source. An expired disposable unbound tab is removed after all its owned groups resolve and fresh protection checks pass. Accepted queues, independent work and restored bound Threads remain intact. Confirmed expiry releases worker custody even if tab metadata fails; retries use only body-free tab/source proof, and uncertain deletion is never repeated. Explicit Cancel retains its separate private-original policy. Historical originals without a saved clock and unconfirmed publication receive no invented deadline. Local candidate checks do not certify live deletion.
|
|
65
65
|
|
|
66
66
|
`/telegram-connect` keeps working when the shared runtime `state.json` is damaged. If it is malformed, foreign or contains invalid evidence, the connecting leader replaces it with a fresh empty state and continues. Every profile then forgets old Thread bindings, slots, unfinished Restore/temporary work and polling continuity; `telegram.json`, Pi history and the released `tmp/telegram` are untouched. Permission errors still stop connection. See [Damaged-State Reset](./docs/architecture.md#damaged-state-reset-operator-approved).
|
|
67
67
|
|
|
@@ -69,6 +69,8 @@ The prompt queue is session-local. Before a reload or restart, let it drain or k
|
|
|
69
69
|
|
|
70
70
|
Journal recovery is a separate existing loss policy, not shared-envelope recovery. A snapshot removed by older broad temp cleanup is rebuilt when its complete segment history proves an empty result, while a revisionless snapshot is repaired from the first surviving segment's exact predecessor when the reconstructed tail validates. Otherwise the damaged snapshot and segments are deleted (their inputs are lost), a fresh journal is published, and startup continues with an informational diagnostic. Unsupported journal versions block recovery without rewriting or quarantining retained files; use a compatible runtime rather than deleting journals. Saved `telegram.json` configuration and runtime diagnostics are outside that journal reset. Startup best-effort removes obsolete current-runtime root/session `recovery/` folders, so those directories are not a preservation location.
|
|
71
71
|
|
|
72
|
+
Ordinary network or admission failures use exponential retry pauses capped at 30 seconds, without permanently stopping after an arbitrary attempt count. The poller recovers when the outage clears. Explicit `/telegram-disconnect` still attempts to stop its captured local transport if Thread cleanup is unavailable; it reports unconfirmed cleanup rather than successful deletion, and an old disconnect cannot stop a new connection. Incomplete stop/release remains visible; check `/telegram-status --debug` and keep Pi open.
|
|
73
|
+
|
|
72
74
|
Persistent competing `getUpdates` clients cause a bounded transport stand-down rather than endless retries. Accepted local work remains queued/executable, but Telegram delivery stops. Inspect `/telegram-status --debug`, stop the competing client, then reconnect. See [Runtime Ownership](./docs/architecture.md#runtime-ownership).
|
|
73
75
|
|
|
74
76
|
### 4. Pair your Telegram account
|
|
@@ -214,11 +216,12 @@ Queue policy:
|
|
|
214
216
|
- Priority and Normal are separate FIFO lanes; Priority dispatches first.
|
|
215
217
|
- Moving `Normal → Priority` removes the prompt from Normal and places it at the Priority tail. Moving `Priority → Normal` removes it from Priority and places it at the Normal tail; no former position is restored.
|
|
216
218
|
- Keep/Skip never changes lane position. Skip preserves durable authority while waiting so Keep remains reversible, then settles that authority and drops the prompt without a model turn when dispatch reaches it. Skipped prompts stay visible at their physical queue position with a struck-through ordinal, but are excluded immediately from the executable queue count shown in both the Pi status bar and Telegram main menu. Graceful session shutdown discards all remaining queued authority, so a new session starts empty.
|
|
217
|
-
-
|
|
219
|
+
- For ordinary prompts, reactions control two independent dimensions; changing one category preserves the other:
|
|
218
220
|
- `Positive`: `👍`, `⚡️`, `❤️`, `🕊`, `🔥` — controls Priority.
|
|
219
221
|
- `Negative`: `👎`, `👻`, `💔`, `💩`, `🗑` — controls Skip.
|
|
220
222
|
- Priority and Skip can coexist—for example `👍 + 💩`. Skip wins at dispatch, regardless of which negative emoji is selected.
|
|
221
223
|
- Menu selectors and reactions share queue state, but the bot cannot remove a user's reaction; Keep may clear internal Skip while the user's emoji remains visible until they remove it.
|
|
224
|
+
- A negative reaction to your original standalone `/continue` immediately cancels its exact waiting continuation, even while other work is active. This is irreversible cancellation, not reversible Skip. Positive reactions do not change its control lane; already-dispatched or running work and synthetic model-switch continuations are untouched.
|
|
222
225
|
|
|
223
226
|
The detailed contract lives in [Priority, Reactions, Keep, and Skip](./docs/architecture.md#priority-reactions-keep-and-skip). If Pi automatically retries a transient provider failure, the active Telegram turn stays bound until the successful reply arrives or Pi confirms that the run has settled.
|
|
224
227
|
|
package/dist/lib/bindings.js
CHANGED
|
@@ -34,6 +34,7 @@ export function createTelegramQueueBindingRuntime(deps) {
|
|
|
34
34
|
};
|
|
35
35
|
const mutation = Queue.createTelegramQueueMutationController({
|
|
36
36
|
...deps.store,
|
|
37
|
+
hasPendingDispatch: deps.lifecycle.hasDispatchPending,
|
|
37
38
|
allocateLaneOrder: deps.queue.allocateItemOrder,
|
|
38
39
|
onItemsDiscarded(items, ctx) {
|
|
39
40
|
if (!settleDiscardedItems(items, ctx)) {
|
package/dist/lib/bus-api.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Wraps the direct Telegram Bot API runtime so follower instances can route outbound calls through the bus leader
|
|
5
5
|
*/
|
|
6
6
|
import { markTelegramBusCrossTargetDelivery, stripTelegramBusApiMetadata, } from "./bus.js";
|
|
7
|
-
import { buildTelegramAnswerGuestQueryBody, isTelegramMessageNotModifiedError, } from "./telegram-api.js";
|
|
7
|
+
import { buildTelegramAnswerGuestQueryBody, formatTelegramCallbackAnswerText, isTelegramMessageNotModifiedError, } from "./telegram-api.js";
|
|
8
8
|
function asRecord(value) {
|
|
9
9
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
10
10
|
? value
|
|
@@ -201,15 +201,16 @@ export function createTelegramBusAwareApiRuntime(deps) {
|
|
|
201
201
|
]);
|
|
202
202
|
},
|
|
203
203
|
async answerCallbackQuery(callbackQueryId, text) {
|
|
204
|
+
const answer = formatTelegramCallbackAnswerText(text);
|
|
204
205
|
if (deps.ownsDirect()) {
|
|
205
|
-
await deps.directRuntime.answerCallbackQuery(callbackQueryId,
|
|
206
|
+
await deps.directRuntime.answerCallbackQuery(callbackQueryId, answer);
|
|
206
207
|
return;
|
|
207
208
|
}
|
|
208
209
|
await deps.callFollowerApi("call", [
|
|
209
210
|
"answerCallbackQuery",
|
|
210
211
|
{
|
|
211
212
|
callback_query_id: callbackQueryId,
|
|
212
|
-
...(
|
|
213
|
+
...(answer !== undefined ? { text: answer } : {}),
|
|
213
214
|
},
|
|
214
215
|
]);
|
|
215
216
|
},
|
package/dist/lib/extension.js
CHANGED
|
@@ -943,6 +943,9 @@ export default function (pi) {
|
|
|
943
943
|
return telegramJournalBindingRuntime.inspectSourceAbandonment(journalBindingKey, updateId);
|
|
944
944
|
});
|
|
945
945
|
},
|
|
946
|
+
inspectRoutingInputGroupExpiry(input) {
|
|
947
|
+
return telegramJournalReferenceRegistry.withReference({ referenceClass: "operator-disposition", recoveryKey: input.journalBindingKey }, function () { return telegramJournalBindingRuntime.inspectSourceGroupExpiry(input.journalBindingKey, input.updateIds); });
|
|
948
|
+
},
|
|
946
949
|
inspectTemporaryThreadSources(_target, requiredJournalBindingKeys) {
|
|
947
950
|
const binding = resolveTelegramUpdateJournalBinding();
|
|
948
951
|
const botToken = configStore.getBotToken();
|
|
@@ -1548,6 +1551,7 @@ export default function (pi) {
|
|
|
1548
1551
|
getSyncState: telegramSyncStateRuntime.getState,
|
|
1549
1552
|
setSyncState: telegramSyncStateRuntime.setState,
|
|
1550
1553
|
stopPolling: lockedPollingRuntime.stop,
|
|
1554
|
+
captureStopPolling: lockedPollingRuntime.captureStop,
|
|
1551
1555
|
suspendPolling: lockedPollingRuntime.suspend,
|
|
1552
1556
|
recordRuntimeEvent,
|
|
1553
1557
|
runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
|
package/dist/lib/journal.d.ts
CHANGED
|
@@ -374,6 +374,9 @@ export interface TelegramRoutingInputAuthority {
|
|
|
374
374
|
operatorUserId: number;
|
|
375
375
|
isCurrent(): boolean;
|
|
376
376
|
}
|
|
377
|
+
/** Expiry drops a donor attempt without retaining its body or claiming recipient execution/cancellation. */
|
|
378
|
+
export type TelegramRoutingInputExpiryResult = Omit<TelegramUpdateJournalPendingAbandonmentResult, "retainedPath">;
|
|
379
|
+
export type TelegramRoutingInputExpiryEvidence = Omit<TelegramUpdateJournalAbandonedPendingEvidence, "retainedPath">;
|
|
377
380
|
/** Package-private v1 capability; raw input custody does not expose it. */
|
|
378
381
|
export interface TelegramRoutingInputJournal {
|
|
379
382
|
arm(input: TelegramRoutingInputAuthority & {
|
|
@@ -385,7 +388,9 @@ export interface TelegramRoutingInputJournal {
|
|
|
385
388
|
};
|
|
386
389
|
expire(input: Omit<TelegramRoutingInputAuthority, "entries"> & {
|
|
387
390
|
entry: TelegramUpdateJournalEntry;
|
|
388
|
-
}):
|
|
391
|
+
}): TelegramRoutingInputExpiryResult;
|
|
392
|
+
inspectExpiry(updateId: number): TelegramRoutingInputExpiryEvidence | undefined;
|
|
393
|
+
inspectGroupExpiry(updateIds: readonly number[]): TelegramRoutingInputExpiryEvidence[] | undefined;
|
|
389
394
|
}
|
|
390
395
|
export interface TelegramUpdateJournalStore {
|
|
391
396
|
routingInputs?: TelegramRoutingInputJournal;
|
|
@@ -804,6 +809,7 @@ export interface TelegramUpdateJournalBindingRuntime {
|
|
|
804
809
|
getActiveRecoveryKey: () => string | undefined;
|
|
805
810
|
/** Exact historical proof lookup; no store, admission, execution, recovery or mutation port. */
|
|
806
811
|
inspectSourceAbandonment: (journalBindingKey: string, updateId: number) => TelegramUpdateJournalAbandonedPendingEvidence | undefined;
|
|
812
|
+
inspectSourceGroupExpiry: (journalBindingKey: string, updateIds: readonly number[]) => TelegramRoutingInputExpiryEvidence[] | undefined;
|
|
807
813
|
inspectSourceCompletion: (journalBindingKey: string, expected: TelegramUpdateJournalSourceCompletion) => TelegramUpdateJournalSourceCompletion | undefined;
|
|
808
814
|
inspectQueuedReceipt: (journalBindingKey: string, expected: TelegramUpdateJournalQueuedCompletion) => TelegramUpdateJournalQueuedReceiptEvidence | undefined;
|
|
809
815
|
/** In-process `/new` succession: move unclaimed predecessor pending inputs into the successor session journal. */
|
package/dist/lib/journal.js
CHANGED
|
@@ -285,6 +285,27 @@ export const TELEGRAM_ROUTING_INPUT_TTL_MS = 60 * 60 * 1000;
|
|
|
285
285
|
export function createTelegramUpdateJournalEntryDigest(entry) {
|
|
286
286
|
return { updateId: entry.updateId, sourceSha256: createHash("sha256").update(JSON.stringify(entry)).digest("hex") };
|
|
287
287
|
}
|
|
288
|
+
function inspectRoutingInputExpiry(file, journalBindingKey, updateId) {
|
|
289
|
+
const disposition = file.operatorDispositions?.find(value => value.updateId === updateId &&
|
|
290
|
+
"dispositionKind" in value && value.dispositionKind === "legacy-custody" && value.action === "discard" &&
|
|
291
|
+
value.failureId === `routing-expiry:${value.evidenceSha256}`);
|
|
292
|
+
if (!disposition || !("operatorAuthorityId" in disposition) || file.entries.some(entry => entry.updateId === updateId))
|
|
293
|
+
return undefined;
|
|
294
|
+
return { journalBindingKey, updateId, operatorAuthorityId: disposition.operatorAuthorityId };
|
|
295
|
+
}
|
|
296
|
+
function inspectRoutingGroupExpiry(file, journalBindingKey, updateIds) {
|
|
297
|
+
if (!Array.isArray(updateIds) || !updateIds.length || updateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
|
|
298
|
+
updateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= updateIds[index - 1])))
|
|
299
|
+
return undefined;
|
|
300
|
+
if (file.entries.some(entry => updateIds.includes(entry.updateId)))
|
|
301
|
+
return undefined;
|
|
302
|
+
const expired = updateIds.map(id => inspectRoutingInputExpiry(file, journalBindingKey, id)).filter(value => value !== undefined);
|
|
303
|
+
if (!expired.length || expired.some(value => value.operatorAuthorityId !== expired[0].operatorAuthorityId))
|
|
304
|
+
return undefined;
|
|
305
|
+
// Some members may have been acknowledged earlier. Whole donor absence plus one exact expiry ends this known cohort,
|
|
306
|
+
// not recipient custody; fresh Thread protection still independently gates deletion.
|
|
307
|
+
return updateIds.map(updateId => ({ journalBindingKey, updateId, operatorAuthorityId: expired[0].operatorAuthorityId }));
|
|
308
|
+
}
|
|
288
309
|
function createJournalError(code, path, detail, cause) {
|
|
289
310
|
return new TelegramUpdateJournalError(code, path, `Telegram update journal ${detail}: ${path}`, cause === undefined ? undefined : { cause });
|
|
290
311
|
}
|
|
@@ -2064,6 +2085,9 @@ export function createTelegramUpdateJournalBindingRuntime(deps) {
|
|
|
2064
2085
|
return { journalBindingKey, updateId, retainedPath: original.path, operatorAuthorityId: disposition.operatorAuthorityId };
|
|
2065
2086
|
});
|
|
2066
2087
|
},
|
|
2088
|
+
inspectSourceGroupExpiry(journalBindingKey, updateIds) {
|
|
2089
|
+
return inspectHistoricalSource(journalBindingKey, file => inspectRoutingGroupExpiry(file, journalBindingKey, updateIds));
|
|
2090
|
+
},
|
|
2067
2091
|
inspectSourceCompletion(journalBindingKey, expected) {
|
|
2068
2092
|
const completion = validateJournalSourceCompletion(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
|
|
2069
2093
|
return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalSourceCompletion(file, completion, path));
|
|
@@ -3401,6 +3425,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
3401
3425
|
update: entry.update,
|
|
3402
3426
|
admittedAtMs: entry.admittedAtMs,
|
|
3403
3427
|
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
3428
|
+
...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
|
|
3404
3429
|
state: "queued",
|
|
3405
3430
|
queueKind: receipt.queueKind,
|
|
3406
3431
|
queueReceiptId: receipt.receiptId,
|
|
@@ -4069,22 +4094,58 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
4069
4094
|
});
|
|
4070
4095
|
},
|
|
4071
4096
|
};
|
|
4072
|
-
const abandonRoutingPending = journal.abandonPending.bind(journal);
|
|
4073
4097
|
if (isTelegramUpdateJournalLegacyFamilyVersion(version))
|
|
4074
4098
|
journal.routingInputs = {
|
|
4075
4099
|
arm(input) { return mutateRoutingInputs(input, false, input.publishedAtMs).entries; },
|
|
4076
4100
|
select(input) { return mutateRoutingInputs(input, true); },
|
|
4101
|
+
inspectExpiry(updateId) {
|
|
4102
|
+
return runMutation(readCurrent => {
|
|
4103
|
+
if (!isSafeNonNegativeInteger(updateId))
|
|
4104
|
+
throw createJournalError("invalid", path, "invalid routing expiry update ID");
|
|
4105
|
+
return inspectRoutingInputExpiry(readCurrent().file, createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateId);
|
|
4106
|
+
});
|
|
4107
|
+
},
|
|
4108
|
+
inspectGroupExpiry(updateIds) {
|
|
4109
|
+
return runMutation(readCurrent => inspectRoutingGroupExpiry(readCurrent().file, createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateIds));
|
|
4110
|
+
},
|
|
4077
4111
|
expire(input) {
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4112
|
+
return runMutation(readCurrent => {
|
|
4113
|
+
const original = validateJournalEntry(input.entry, path, version), lifetime = original.routingInput;
|
|
4114
|
+
const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
4115
|
+
if (!lifetime || original.state !== "pending" || original.inputClaim || original.inputProvenance || original.queueOwner ||
|
|
4116
|
+
original.queueReceiptId || original.queueHandoff || original.failure || input.journalBindingKey !== binding ||
|
|
4117
|
+
lifetime.operatorUserId !== input.operatorUserId || !isSafePositiveInteger(input.operatorUserId) || typeof input.isCurrent !== "function") {
|
|
4118
|
+
throw createJournalError("conflict", path, "routing expiry requires an exact unclaimed chooser source");
|
|
4119
|
+
}
|
|
4120
|
+
const assertCurrent = () => {
|
|
4121
|
+
const now = getNowMs();
|
|
4122
|
+
if (!isSafeNonNegativeInteger(now) || now < lifetime.expiresAtMs || !input.isCurrent())
|
|
4123
|
+
throw createJournalError("conflict", path, "routing source has not expired under current authority");
|
|
4124
|
+
};
|
|
4125
|
+
assertCurrent();
|
|
4126
|
+
const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
|
|
4127
|
+
const evidenceSha256 = createHash("sha256").update(JSON.stringify(original)).digest("hex");
|
|
4128
|
+
const failureId = `routing-expiry:${evidenceSha256}`;
|
|
4129
|
+
const existing = current.file.operatorDispositions?.find(value => value.updateId === original.updateId);
|
|
4130
|
+
const entry = current.file.entries.find(value => value.updateId === original.updateId);
|
|
4131
|
+
if (existing) {
|
|
4132
|
+
if (entry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" || existing.action !== "discard" ||
|
|
4133
|
+
existing.failureId !== failureId || existing.evidenceSha256 !== evidenceSha256 || existing.operatorAuthorityId !== `telegram-owner:${input.operatorUserId}`)
|
|
4134
|
+
throw createJournalError("conflict", path, "routing source has contradictory expiry evidence");
|
|
4135
|
+
assertCurrent();
|
|
4136
|
+
return { disposition: existing, duplicate: true, entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
4137
|
+
}
|
|
4138
|
+
if (!entry || !isDeepStrictEqual(entry, original))
|
|
4139
|
+
throw createJournalError("conflict", path, "routing source changed before expiry");
|
|
4140
|
+
// A discard tombstone prevents old readers and delayed reports from replaying; no prompt-body archive is written.
|
|
4141
|
+
const now = getNowMs();
|
|
4142
|
+
const disposition = { dispositionKind: "legacy-custody", failureId,
|
|
4143
|
+
updateId: original.updateId, action: "discard", committedAtMs: now, authorizedAtMs: now, evidenceSha256,
|
|
4144
|
+
operatorAuthorityId: `telegram-owner:${input.operatorUserId}` };
|
|
4145
|
+
const published = publishMutation(current, current.file.entries.filter(value => value.updateId !== original.updateId), true, [...(current.file.operatorDispositions ?? []), disposition], current.file.acceptedThroughUpdateId, (boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
|
|
4146
|
+
assertCurrent();
|
|
4147
|
+
return { disposition, duplicate: false, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
4148
|
+
});
|
|
4088
4149
|
},
|
|
4089
4150
|
};
|
|
4090
4151
|
if (!getInputContext)
|
package/dist/lib/locks.d.ts
CHANGED
|
@@ -215,6 +215,11 @@ type TelegramLockedPollingStartResult = {
|
|
|
215
215
|
export interface TelegramLockedPollingRuntime<TContext extends TelegramLockContext> {
|
|
216
216
|
start: (ctx: TContext, options?: TelegramLockedPollingStartOptions) => Promise<TelegramLockedPollingStartResult>;
|
|
217
217
|
stop: () => Promise<string>;
|
|
218
|
+
/** Capture one disconnect attempt before cleanup awaits; a newer start revokes it. */
|
|
219
|
+
captureStop: () => {
|
|
220
|
+
isCurrent: () => boolean;
|
|
221
|
+
stop: () => Promise<string>;
|
|
222
|
+
};
|
|
218
223
|
suspend: () => Promise<void>;
|
|
219
224
|
isSuspended: () => boolean;
|
|
220
225
|
/** Fence one owned polling generation; suspension, restart, conflict or lock loss revokes it. */
|
package/dist/lib/locks.js
CHANGED
|
@@ -1210,6 +1210,8 @@ export function createTelegramLockedPollingRuntime(deps) {
|
|
|
1210
1210
|
stopOwnershipWatcher();
|
|
1211
1211
|
if (sessionAutoStartRun) {
|
|
1212
1212
|
await sessionAutoStartRun;
|
|
1213
|
+
if (generation !== pollingGeneration)
|
|
1214
|
+
return;
|
|
1213
1215
|
deps.stopFollowerRegistration?.();
|
|
1214
1216
|
}
|
|
1215
1217
|
if (ownershipStop) {
|
|
@@ -1325,6 +1327,21 @@ export function createTelegramLockedPollingRuntime(deps) {
|
|
|
1325
1327
|
const canStartPolling = (ctx) => deps.canStartPolling?.(ctx) ?? true;
|
|
1326
1328
|
const formatStartBlockedMessage = (ctx) => deps.formatStartBlockedMessage?.(ctx) ??
|
|
1327
1329
|
"Telegram polling is unavailable in this Pi run mode.";
|
|
1330
|
+
const stop = async () => {
|
|
1331
|
+
const generation = pollingGeneration + 1;
|
|
1332
|
+
await suspendPolling();
|
|
1333
|
+
if (generation !== pollingGeneration)
|
|
1334
|
+
throw new Error("Telegram disconnect was superseded by a new connection.");
|
|
1335
|
+
const state = deps.lock.release();
|
|
1336
|
+
deps.onTransportAvailabilityChanged?.();
|
|
1337
|
+
if (state.kind === "active-elsewhere") {
|
|
1338
|
+
return `Telegram bridge is active in another Pi instance (${formatTelegramLockEntry(state.lock)}).`;
|
|
1339
|
+
}
|
|
1340
|
+
if (state.kind === "stale") {
|
|
1341
|
+
return `Removed stale Telegram bridge lock (${formatTelegramLockEntry(state.lock)}).`;
|
|
1342
|
+
}
|
|
1343
|
+
return "Telegram bridge disconnected.";
|
|
1344
|
+
};
|
|
1328
1345
|
return {
|
|
1329
1346
|
start: async (ctx, options = {}) => {
|
|
1330
1347
|
if (!deps.hasBotToken()) {
|
|
@@ -1440,17 +1457,18 @@ export function createTelegramLockedPollingRuntime(deps) {
|
|
|
1440
1457
|
const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
|
|
1441
1458
|
return { ok: true, message: `Telegram bridge connected.${staleSuffix}` };
|
|
1442
1459
|
},
|
|
1443
|
-
stop
|
|
1444
|
-
|
|
1445
|
-
const
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1460
|
+
stop,
|
|
1461
|
+
captureStop() {
|
|
1462
|
+
const generation = pollingGeneration;
|
|
1463
|
+
const isCurrent = () => generation === pollingGeneration;
|
|
1464
|
+
return {
|
|
1465
|
+
isCurrent,
|
|
1466
|
+
async stop() {
|
|
1467
|
+
if (!isCurrent())
|
|
1468
|
+
throw new Error("Telegram disconnect was superseded by a new connection.");
|
|
1469
|
+
return stop();
|
|
1470
|
+
},
|
|
1471
|
+
};
|
|
1454
1472
|
},
|
|
1455
1473
|
suspend: suspendPolling,
|
|
1456
1474
|
captureTransportAuthority(ctx) {
|
package/dist/lib/polling.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export declare class TelegramPersistentGetUpdatesConflictError extends Error {
|
|
|
28
28
|
readonly count: number;
|
|
29
29
|
constructor(count: number);
|
|
30
30
|
}
|
|
31
|
+
/** Exponential backoff for non-conflict polling/admission failures. */
|
|
32
|
+
export declare function getTelegramPollingRetryDelayMs(consecutiveFailures: number): number;
|
|
31
33
|
export declare class TelegramGetUpdatesTimeoutError extends Error {
|
|
32
34
|
readonly timeoutMs: number;
|
|
33
35
|
constructor(timeoutMs: number);
|
package/dist/lib/polling.js
CHANGED
|
@@ -14,7 +14,8 @@ export const TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT = 10;
|
|
|
14
14
|
const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
|
|
15
15
|
const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
|
|
16
16
|
const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
|
|
17
|
-
const
|
|
17
|
+
const TELEGRAM_POLLING_RETRY_BASE_MS = 1_000;
|
|
18
|
+
const TELEGRAM_POLLING_RETRY_MAX_MS = 30_000;
|
|
18
19
|
const TELEGRAM_GET_UPDATES_GRACE_MS = 10_000;
|
|
19
20
|
// Standard Telegram DM polling does not expose ordinary message-deletion events.
|
|
20
21
|
// Business deletions belong to a separate namespace and default routing ignores them.
|
|
@@ -51,6 +52,11 @@ export class TelegramPersistentGetUpdatesConflictError extends Error {
|
|
|
51
52
|
this.count = count;
|
|
52
53
|
}
|
|
53
54
|
}
|
|
55
|
+
/** Exponential backoff for non-conflict polling/admission failures. */
|
|
56
|
+
export function getTelegramPollingRetryDelayMs(consecutiveFailures) {
|
|
57
|
+
const exponent = Math.max(0, Math.min(consecutiveFailures - 1, 16));
|
|
58
|
+
return Math.min(TELEGRAM_POLLING_RETRY_BASE_MS * 2 ** exponent, TELEGRAM_POLLING_RETRY_MAX_MS);
|
|
59
|
+
}
|
|
54
60
|
export class TelegramGetUpdatesTimeoutError extends Error {
|
|
55
61
|
timeoutMs;
|
|
56
62
|
constructor(timeoutMs) {
|
|
@@ -1103,6 +1109,7 @@ export async function runTelegramPollLoop(deps) {
|
|
|
1103
1109
|
if (!deps.config.botToken)
|
|
1104
1110
|
return;
|
|
1105
1111
|
let consecutiveGetUpdatesConflicts = 0;
|
|
1112
|
+
let consecutiveFailures = 0;
|
|
1106
1113
|
const retryConflict = async () => {
|
|
1107
1114
|
consecutiveGetUpdatesConflicts += 1;
|
|
1108
1115
|
if (consecutiveGetUpdatesConflicts >= TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT) {
|
|
@@ -1171,6 +1178,7 @@ export async function runTelegramPollLoop(deps) {
|
|
|
1171
1178
|
onPhaseChange: deps.onPhaseChange,
|
|
1172
1179
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
1173
1180
|
});
|
|
1181
|
+
consecutiveFailures = 0;
|
|
1174
1182
|
currentUpdateId = undefined;
|
|
1175
1183
|
}
|
|
1176
1184
|
catch (error) {
|
|
@@ -1190,8 +1198,10 @@ export async function runTelegramPollLoop(deps) {
|
|
|
1190
1198
|
: {}),
|
|
1191
1199
|
});
|
|
1192
1200
|
consecutiveGetUpdatesConflicts = 0;
|
|
1201
|
+
consecutiveFailures += 1;
|
|
1193
1202
|
deps.onErrorStatus(getTelegramPollingErrorMessage(error));
|
|
1194
|
-
|
|
1203
|
+
// Cap the pause, not retry count: outages must recover without manual reconnect.
|
|
1204
|
+
await deps.sleep(getTelegramPollingRetryDelayMs(consecutiveFailures), deps.signal);
|
|
1195
1205
|
if (deps.signal.aborted)
|
|
1196
1206
|
return;
|
|
1197
1207
|
deps.onStatusReset();
|
package/dist/lib/queue.d.ts
CHANGED
|
@@ -613,11 +613,13 @@ export type TelegramSessionLifecycleHookEvent = unknown;
|
|
|
613
613
|
export declare function createTelegramSessionStateApplier<TQueueItem, TModel>(deps: TelegramSessionStateApplierDeps<TQueueItem, TModel>): TelegramSessionStateApplier<TQueueItem, TModel>;
|
|
614
614
|
export interface TelegramQueueMutationRuntimeDeps<TContext> extends TelegramQueueStore<TContext>, TelegramRuntimeEventRecorderPort {
|
|
615
615
|
ctx: TContext;
|
|
616
|
+
hasPendingDispatch?: () => boolean;
|
|
616
617
|
allocateLaneOrder?: () => number;
|
|
617
618
|
onItemsDiscarded?: (items: readonly TelegramQueueItem<TContext>[], ctx: TContext) => void;
|
|
618
619
|
updateStatus: (ctx: TContext) => void;
|
|
619
620
|
}
|
|
620
621
|
export interface TelegramQueueMutationControllerDeps<TContext> extends TelegramQueueStore<TContext>, TelegramRuntimeEventRecorderPort {
|
|
622
|
+
hasPendingDispatch?: () => boolean;
|
|
621
623
|
allocateLaneOrder?: () => number;
|
|
622
624
|
onItemsDiscarded?: (items: readonly TelegramQueueItem<TContext>[], ctx: TContext) => void;
|
|
623
625
|
updateStatus: (ctx: TContext) => void;
|
package/dist/lib/queue.js
CHANGED
|
@@ -582,7 +582,7 @@ function removeTelegramQueuedGuestPromptByOrder(items, queueOrder) {
|
|
|
582
582
|
export function applyTelegramQueuePromptReactionDisposition(items, messageId, disposition, destinationLaneOrder, scope) {
|
|
583
583
|
let nextItems = items;
|
|
584
584
|
for (const [index, item] of items.entries()) {
|
|
585
|
-
if (!isPendingTelegramTurn(item) ||
|
|
585
|
+
if (!isPendingTelegramTurn(item) || item.queueLane === "control" ||
|
|
586
586
|
!isTelegramQueueItemInMessageScope(item, scope) ||
|
|
587
587
|
!item.sourceMessageIds.includes(messageId)) {
|
|
588
588
|
continue;
|
|
@@ -1550,7 +1550,15 @@ function updateTelegramQueueStatusRuntime(deps) {
|
|
|
1550
1550
|
}
|
|
1551
1551
|
}
|
|
1552
1552
|
function commitReorderedTelegramQueueItemsRuntime(items, deps) {
|
|
1553
|
-
deps.
|
|
1553
|
+
const pendingHead = deps.hasPendingDispatch?.() ? deps.getQueuedItems()[0] : undefined;
|
|
1554
|
+
const ordered = [...items].sort(compareTelegramQueueItems);
|
|
1555
|
+
if (pendingHead) {
|
|
1556
|
+
const index = ordered.findIndex(item => item.queueOrder === pendingHead.queueOrder && item.kind === pendingHead.kind &&
|
|
1557
|
+
item.chatId === pendingHead.chatId && item.replyToMessageId === pendingHead.replyToMessageId);
|
|
1558
|
+
if (index > 0)
|
|
1559
|
+
ordered.unshift(ordered.splice(index, 1)[0]);
|
|
1560
|
+
}
|
|
1561
|
+
deps.setQueuedItems(ordered);
|
|
1554
1562
|
updateTelegramQueueStatusRuntime(deps);
|
|
1555
1563
|
}
|
|
1556
1564
|
function appendTelegramQueueItemRuntime(item, deps) {
|
|
@@ -1602,8 +1610,22 @@ function removeTelegramQueuedGuestPromptByOrderRuntime(queueOrder, deps) {
|
|
|
1602
1610
|
}
|
|
1603
1611
|
export function applyTelegramQueuePromptReactionDispositionRuntime(messageId, disposition, deps, scope) {
|
|
1604
1612
|
const queuedItems = deps.getQueuedItems();
|
|
1613
|
+
const suppression = disposition.kind === "reaction-transition" ? disposition.suppressionEmoji :
|
|
1614
|
+
disposition.kind === "suppressed" ? disposition.emoji : disposition.kind === "priority-suppressed" ? disposition.suppressionEmoji : undefined;
|
|
1615
|
+
// Source-addressed control-lane prompts are explicit continuations. Synthetic model-switch turns have no source IDs.
|
|
1616
|
+
// A Pi-owned dispatched head cannot be removed: agent_start must consume that exact item, not its successor.
|
|
1617
|
+
const cancelled = suppression && deps.hasPendingDispatch ? queuedItems.filter((item, index) => isPendingTelegramTurn(item) && item.queueLane === "control" && isTelegramQueueItemInMessageScope(item, scope) &&
|
|
1618
|
+
item.sourceMessageIds.includes(messageId) && !(index === 0 && deps.hasPendingDispatch())) : [];
|
|
1619
|
+
if (cancelled.length) {
|
|
1620
|
+
if (cancelled.some(item => (item.admissionReceipts?.length ?? 0) > 0) && !deps.onItemsDiscarded)
|
|
1621
|
+
return false;
|
|
1622
|
+
deps.onItemsDiscarded?.(cancelled, deps.ctx);
|
|
1623
|
+
deps.setQueuedItems(queuedItems.filter(item => !cancelled.includes(item)));
|
|
1624
|
+
updateTelegramQueueStatusRuntime(deps);
|
|
1625
|
+
return true;
|
|
1626
|
+
}
|
|
1605
1627
|
const changesLane = queuedItems.some((item) => {
|
|
1606
|
-
if (!isPendingTelegramTurn(item) ||
|
|
1628
|
+
if (!isPendingTelegramTurn(item) || item.queueLane === "control" ||
|
|
1607
1629
|
!isTelegramQueueItemInMessageScope(item, scope) ||
|
|
1608
1630
|
!item.sourceMessageIds.includes(messageId)) {
|
|
1609
1631
|
return false;
|
package/dist/lib/routing.d.ts
CHANGED
|
@@ -121,6 +121,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
|
|
|
121
121
|
captureWorkspaceExternalProtection?: ReturnType<typeof createTelegramWorkspaceExternalProtectionCapture>;
|
|
122
122
|
/** Strict committed abandonment plus retained original; shared by Restore and temporary-input cancellation. */
|
|
123
123
|
inspectRestoreSourceAbandonment?: (updateId: number, journalBindingKey: string) => Threads.TelegramTemporaryThreadCancellationEvidence | undefined;
|
|
124
|
+
inspectRoutingInputGroupExpiry?: (input: Threads.TelegramTemporaryThreadInput) => readonly Threads.TelegramTemporaryThreadCancellationEvidence[] | undefined;
|
|
124
125
|
/** Strict active-journal observation only; a hint or missing source never substitutes for this ACK. */
|
|
125
126
|
inspectRestoreSourceCompletion?: (expected: Updates.TelegramDeferredSourceEvidence & {
|
|
126
127
|
completionSha256: string;
|