@llblab/pi-telegram 0.53.1 → 0.54.0

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.
@@ -54,12 +54,12 @@ The operator experience:
54
54
  - Do not couple the first design to Pi sessions if live instance ownership is the better runtime truth.
55
55
  - Do not expose arbitrary group participants to prompts, controls, or artifacts.
56
56
  - Do not require Threaded Mode for classic private-chat users; classic private-chat mode remains valid and should not receive slot/thread-name guidance.
57
- - Do not implement leader election through unsafe lock stealing without heartbeats or stale-owner checks.
57
+ - Do not implement leader election through unsafe lock stealing without stale-owner proof (dead PID, or a bus-proven unresponsive owner).
58
58
 
59
59
  ## Terms
60
60
 
61
61
  - `Telegram bus`: The singleton local capability to poll Telegram updates and send Telegram API calls for one bot token.
62
- - `Leader`: The live Pi instance that currently owns the Telegram bus and calls `getUpdates`; this is an ephemeral role transferable after stale heartbeat detection.
62
+ - `Leader`: The live Pi instance that currently owns the Telegram bus and calls `getUpdates`; this is an ephemeral role transferable after its PID dies or the bus proves it unresponsive.
63
63
  - `Follower`: A live Pi instance that wants Telegram presence but routes Telegram API access through the leader.
64
64
  - `Bus lifecycle`: Transient recovery state only. Stable identity is the bus role (`leader` / `follower`); lifecycle surfaces exceptional handoff states such as `electing`, not duplicate roles with labels like `leader-active`.
65
65
  - `Agent instance`: A running Pi process/session with its own extension state, queue, active turn, model, tools, and lifecycle hooks.
@@ -80,7 +80,7 @@ tmp/pi-telegram/state.json / profiles.<profile>.transport -> polling/control own
80
80
  Threaded Mode meaning:
81
81
 
82
82
  ```text
83
- tmp/pi-telegram/state.json / profiles.<profile>.transport -> bus leader identity + heartbeat
83
+ tmp/pi-telegram/state.json / profiles.<profile>.transport -> bus leader identity (no periodic heartbeat)
84
84
  ```
85
85
 
86
86
  Followers do not poll. They register with the leader and receive routed inbound updates from it. Followers still own their local queue, active-turn state, previews, final delivery planning, model switches, and Pi lifecycle. The leader owns only Telegram transport and update fanout. Same-session context refresh preserves bus membership; a changed Pi session ID suspends the old receiver/heartbeat and requires acknowledged successor registration before readiness, without an explicit Thread disconnect. A new follower process also attempts a restore-only registration at session startup when the selected profile's local state contains a Workspace binding for the exact `cwd` and session ID. The recent-event log distinguishes missing session bindings, disabled Threaded Mode, leader-side restore refusal, and a restore that returned without connecting; none of these refusals silently provisions a new Thread. The Telegram bus belongs to the local set of cooperating visible Pi instances rather than to the first terminal session forever: if the visible terminal leader exits, a live registered follower can take over leadership.
@@ -255,13 +255,13 @@ Routing authority remains the authenticated live owner plus exact numeric Telegr
255
255
 
256
256
  ## Leader Election
257
257
 
258
- Leader election is heartbeat-gated and lock-backed. The polling owner checks exact lock ownership every second, refreshes its durable lease every two seconds, and becomes stale after eight seconds; the serialized expected-owner transaction remains the final cross-platform election authority.
258
+ Leader election is liveness-proof-gated and lock-backed. Since 0.54 the leader writes no periodic heartbeat: the transport section changes only on acquire, release, takeover or entry migration, and the owner's one- and two-second ownership checks are lock-free reads. A dead PID is stale immediately. A live PID is replaceable only with bus proof: an authenticated side-effect-free `bus.probe` to the leader endpoint that either stays unanswered for the eight-second window, or finds a refusing endpoint twice one window apart (a just-started leader binds well within it). Any reply, including an older leader rejecting the unknown kind, proves it alive; a missing endpoint proves nothing, because a live classic-mode leader has none. Classic mode therefore never takes over a live PID automatically: `/telegram-connect` asks the operator. Entries written by older releases still carry `heartbeatMs` and keep the eight-second stale rule. The serialized expected-owner transaction, binding the proof to the exact owner epoch, remains the final cross-platform election authority.
259
259
 
260
260
  1. On startup, read the Telegram lock.
261
261
  2. If no leader exists, acquire leadership and start polling.
262
262
  3. If a live leader exists, restore a remembered profile-scoped exact-`cwd` follower binding automatically; otherwise wait for explicit `/telegram-connect` rather than provisioning a new Thread from startup alone.
263
- 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.
264
- 5. Heartbeat acknowledgements carry the authenticated live follower-slot roster. If several followers detect stale leadership, the lowest observed live slot attempts promotion immediately; higher slots defer one bounded election grace and re-check the lock. Atomic compare/write acquisition remains the final ownership authority, and a missing lower-slot follower cannot block a higher survivor beyond that grace.
263
+ 4. If the leader PID is dead, or its re-registration fails and the bus proves it unresponsive, attempt an atomic leadership takeover; ordinary `/telegram-connect` on a follower is not a leadership move while the leader is live.
264
+ 5. Heartbeat acknowledgements carry the authenticated live follower-slot roster. If several followers detect stale or unresponsive leadership, the lowest observed live slot attempts promotion immediately; higher slots defer one bounded election grace and re-check the lock. Atomic compare/write acquisition remains the final ownership authority, and a missing lower-slot follower cannot block a higher survivor beyond that grace.
265
265
 
266
266
  Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target, slot, and thread name so the new leader can reuse the same binding. Follower Bot API calls already admitted by the active Pi turn wait for that bounded re-registration and capture its new exact generation before entering transport; they do not fail merely because recovery temporarily cleared local registration, and they never replay after an ambiguous transport commit. After the grace window followers promote only when the exact observed leader lease has become stale or inactive; an unavailable IPC endpoint never authorizes replacing a still-live owner. If the exact carried target is absent from persisted bindings, the leader first runs the same synchronous visibility probe: success recovers it instead of creating another Telegram thread, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. An ambiguous absent-target probe persists only non-routable `probe-required` restoration evidence, so targetless retries, successor follower processes with the same exact Workspace claim, and leader reloads probe that exact target again instead of activating it or provisioning a speculative replacement. A carried slot survives only when that slot remains free. Every successful reuse refreshes the binding timestamp. The leader never restores persisted followers into the live registry speculatively. Absent follower records remain durable restart hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; only fresh authenticated registration creates live routing authority. This preserves real thread bindings through reload and process-absence gaps without allowing historical records or competing pollers to masquerade as live state.
267
267
 
@@ -269,7 +269,7 @@ Followers first try to re-register after leader reload or unknown-heartbeat resp
269
269
 
270
270
  ### Protocol identity and compatibility
271
271
 
272
- The session-native local wire contract has protocol version `2`, independent from the npm package version. Follower registration and the leader acknowledgement carry `{ protocolVersion, runtimeBuild, capabilities }`. Capability names are canonical, unique, and sorted. A leader rejects missing or mismatched protocol identity before provisioning a target or publishing the follower into live routing; a strict follower likewise rejects an acknowledgement without compatible leader identity. Different package builds remain compatible when their protocol versions agree. `durable-follower-admission-v1` gates source forwarding, while `queue-handoff-v1` independently gates live semantic queue transfer; every participant in a routed handoff must advertise it. `workspace-follower-auto-connect-v1` gates restore-only startup admission. Session identity is part of protocol v2 rather than an optional capability. Every cwd-scoped v2 registration carries the exact bounded session ID; missing identity is rejected before provisioning or publication. Reconnect, replacement, and promotion preserve it. Protocol-v1 peers are rejected by the base version check, avoiding mixed semantic branches. `thread-display-mode-v1` gates exact-generation follower display-setting requests and Letters/Directories require compatible connected followers; returning to Names allows legacy peers. Registration checks the current display-mode requirement before provisioning and again before live publication. The leader serializes config persistence and title application. `workspace-thread-rename-v1` independently gates follower rename requests whose exact registration generation is checked before the leader mutates Telegram and persists the Workspace binding. `session-replacement-intent-v1` gates Telegram `/new` from a follower Thread: followers never write `state.json`, so `follower.publishSessionReplacement` asks the leader to CAS-publish the durable intent only when the live registration generation, leader epoch, Profile, exact `cwd`, source session, Thread target, and the leader's own Workspace binding slot/name all match, with a bounded unexpired TTL. The intent records the source runtime instance; only a successor registration from that instance or its authenticated same-process handoff (`previousInstanceId`) may re-key the binding, and `follower.settleSessionReplacement` claims it once only after the leader's store shows that successor session bound to the same target. A missing capability, stale generation, or mismatch fails the command closed.
272
+ The session-native local wire contract has protocol version `3`, independent from the npm package version. Version 3 moved journal source serialization from the `telegram.json` transaction to the journal-owned `runtime/journals.transaction`; because v2 peers would not exclude v3 journal writers, the base version check rejects them instead of mixing lock semantics. Follower registration and the leader acknowledgement carry `{ protocolVersion, runtimeBuild, capabilities }`. Capability names are canonical, unique, and sorted. A leader rejects missing or mismatched protocol identity before provisioning a target or publishing the follower into live routing; a strict follower likewise rejects an acknowledgement without compatible leader identity. Different package builds remain compatible when their protocol versions agree. `durable-follower-admission-v1` gates source forwarding, while `queue-handoff-v1` independently gates live semantic queue transfer; every participant in a routed handoff must advertise it. `workspace-follower-auto-connect-v1` gates restore-only startup admission. Session identity has been part of the base protocol since v2 rather than an optional capability. Every cwd-scoped registration carries the exact bounded session ID; missing identity is rejected before provisioning or publication. Reconnect, replacement, and promotion preserve it. Older-protocol peers are rejected by the base version check, avoiding mixed semantic branches. `thread-display-mode-v1` gates exact-generation follower display-setting requests and Letters/Directories require compatible connected followers; returning to Names allows legacy peers. Registration checks the current display-mode requirement before provisioning and again before live publication. The leader serializes config persistence and title application. `workspace-thread-rename-v1` independently gates follower rename requests whose exact registration generation is checked before the leader mutates Telegram and persists the Workspace binding. `session-replacement-intent-v1` gates Telegram `/new` from a follower Thread: followers never write `state.json`, so `follower.publishSessionReplacement` asks the leader to CAS-publish the durable intent only when the live registration generation, leader epoch, Profile, exact `cwd`, source session, Thread target, and the leader's own Workspace binding slot/name all match, with a bounded unexpired TTL. The intent records the source runtime instance; only a successor registration from that instance or its authenticated same-process handoff (`previousInstanceId`) may re-key the binding, and `follower.settleSessionReplacement` claims it once only after the leader's store shows that successor session bound to the same target. A missing capability, stale generation, or mismatch fails the command closed.
273
273
 
274
274
  The current root advertises `workspace-restore-v1`; all participating peers and potential leader successors must be upgraded before operator-controlled use. The retired `leader.replaceFollowerTarget` envelope is no longer parsed or produced, even with current authentication and registration generation. Its `leader.workspaceRestore` envelope carries only an operation ID, exact recipient registration generation and `apply`/`inspect` mode; the receiver derives binding and destination from the retained intent, not caller-supplied target data. Authentication and an explicitly enabled receiver are mandatory. The controller checks both peers' capability and protocol compatibility, captures live registration/session/slot/endpoint authority, sends once without transport retry, then validates the request, operation, recipient, observed target, slot and boolean readiness against the current registration. A lost response leaves the durable issuance consumed; a separate read-only inspection can prove readiness without applying again. Neither the controller nor receiver settles source input or writes follower-owned canonical state. A successor registration/process may inspect only its actual same-session canonical binding and local target; readiness records the new observer separately without changing the original issuance. An old local target cannot be repaired through inspection, and successor apply is rejected. Interrupted startup, terminal source/cleanup settlement and live behavior were accepted in the operator's 0.52.0 live smoke.
275
275
 
@@ -287,7 +287,7 @@ As part of staging, the exact recipient presents the token and atomically receiv
287
287
 
288
288
  ### Live-Rebind Channel
289
289
 
290
- The 0.53.0 channel preserves protocol v2 and negotiates live rebinding independently from package version. Release 0.53.0 advertises the following capabilities; an instance still running an older build does not acquire them until it is reloaded. Compatible package versions alone cannot supply missing capabilities:
290
+ The 0.53.0 channel preserved protocol v2 and negotiates live rebinding independently from package version; 0.54 moves the base protocol to v3 without changing these capabilities. Release 0.53.0 advertises the following capabilities; an instance still running an older build does not acquire them until it is reloaded. Compatible package versions alone cannot supply missing capabilities:
291
291
 
292
292
  - `live-thread-rebind-save-v1`, `live-thread-rebind-apply-v1`, `live-thread-rebind-settle-v1`: Exact recipient save/apply/release and source settlement through the shared channel.
293
293
  - `live-thread-rebind-command-set-v1`: One `selectedCommand: { name, target }` descriptor and `observe-command` / `command-observed` completion observation, rather than separate status/control wire forms. Retired descriptors and capability names refuse without a compatibility shim.
@@ -513,7 +513,7 @@ Rules:
513
513
 
514
514
  Current state under the agent dir:
515
515
 
516
- - `tmp/pi-telegram/state.json` `profiles.<profile>.transport`: authoritative transport owner for `default` or a named profile, holding the bus leader identity, capability secret, heartbeat, generation, cleanup fencing epoch and polling-journal pointer. Mutations serialize through `runtime/state.json.transaction`; followers never write it. The local bus endpoint is derived from the agent directory; a `busSocketPath` field is tolerated but not required.
516
+ - `tmp/pi-telegram/state.json` `profiles.<profile>.transport`: authoritative transport owner for `default` or a named profile, holding the bus leader identity, capability secret, generation, cleanup fencing epoch and polling-journal pointer. Mutations serialize through `runtime/state.json.transaction`; followers never write it. The local bus endpoint is derived from the agent directory; a `busSocketPath` field is tolerated but not required.
517
517
  - `profiles.<profile>.workspace`: canonical Workspace/thread state; `profiles.<profile>.runtime`: volatile observable/debug projection, never routing authority, with `source: "snapshot"` and `writtenAtMs`. Every process on one profile reads this shared file, but only the active transport owner may publish either section; followers become writers only after promotion. Runtime publication never touches the canonical section, so a stale status writer cannot erase leader records. The `runtime` section mirrors `/telegram-status`-style projections: `runtime` identifies leader/follower role, lifecycle activity, and the exact polling phase/progress snapshot; `liveRoster` mirrors followers/current targets/reservations; `diagnostics` mirrors status/debug signals. The `workspace` section keeps top-level `bot` capability state such as `threadMode: "unknown" | "enabled" | "disabled"`; `threads` stores current routeable bindings; `workspaceBindings` stores dormant exact-`cwd` target/name/slot reuse hints; `bot.lastSlot` stores the compact slot cursor used when all current threads are gone; and `reservations` records short-lived slot collision guards.
518
518
  - Local bus endpoints: Unix-like platforms expose stable `tmp/pi-telegram/runtime/bus.<hash>.sock` (leader) and `runtime/f.<hash>.sock` (follower) symlinks backed by colocated private generation sockets, with bounded private external shortening only for over-long paths; native Windows uses deterministic named pipes under `\\.\pipe\pi-telegram-...`. These are transient IPC endpoints, not durable routing state.
519
519
 
@@ -528,13 +528,13 @@ All files containing routing, chat ids, thread ids, or process details use priva
528
528
  ### Leader exits cleanly
529
529
 
530
530
  - Leader stops polling and marks itself offline.
531
- - Followers detect missing heartbeat.
531
+ - Follower heartbeats go unanswered and the released or dead owner is stale.
532
532
  - One follower promotes itself after jitter/tie-break.
533
533
  - New leader resumes `getUpdates` from the persisted offset if safe.
534
534
 
535
535
  ### Leader crashes
536
536
 
537
- - Followers detect stale heartbeat.
537
+ - Followers see a dead PID, or prove a hung leader unresponsive over the bus.
538
538
  - One follower promotes itself.
539
539
  - Some updates may be delayed or skipped depending on offset persistence; dispatcher design must define this explicitly.
540
540
 
@@ -606,7 +606,7 @@ Leader command Restore now binds `updates.bindTelegramUpdateCompletionAcceptance
606
606
 
607
607
  A source-journal completion hint now also selects a ready leader Restore with retained completed or queued acceptance and unissued cleanup. Fresh admission and strict scoped ACK queries precede executor adoption. A read-only canonical observation requires one exact active binding/session/CWD/slot/target and one current leader owner with matching instance, CWD and profile key; local target equality alone cannot re-key the operation. The existing inspect-only controller preserves the original recipient/grants and observes the same-session current leader without applying identity, invoking a handler or sending follower RPC. After its await, immutable proofs are read again before settling only their proven originals. Live fences remain in storage authority callbacks; canonical observations stay outside the snapshot transaction and are repeated through cleanup awaits and before retirement. An owner change after close retains issued cleanup rather than deleting or retiring. Exact already-retained local readiness needs no republication. Native cold cases cover same-instance/successor continuation, partial/missing/foreign evidence, wrong local session/CWD/target/owner, post-await proof/recipient changes, both canonical publication rename boundaries and unknown/issued cleanup, preserving source bytes throughout. Queued ACK continuation uses the same pre-adoption proof and canonical-owner fences. After recipient inspection it rereads unproven scopes and publishes `queue-completed`, grouping only matching receipts/kinds; admission-only, missing or partial proof cannot release another original. Native single-/multi-receipt cases preserve issued grants and source bytes across canonical publication faults, with or without a prior admission fact. Producer execution, successor startup/ownership and accepted-work clearance are distinct preconditions; these fixtures do not prove actual interrupted Pi startup. Scoped ACKs still never expire, evict or release.
608
608
 
609
- The worker now has a prepared optional `beforeQueueReceiptPublished(receipt, owner, ctx, isCurrent)` barrier. Exact durable queue admission retains the original, but neither cached readiness nor its downstream observer publishes until the asynchronous acceptance publisher succeeds. Publication requires a captured exact receipt-inspection capability before and after the await, with current process/session/context/binding checks. Receipt and owner arguments are detached; grouped reports and concurrent snapshot refresh share one pending publication. Same-process cold receipt reconstruction uses that barrier without running the original handler. Callback failures hold queued work; readiness observers remain diagnostic after successful publication. Ordinary commit failures still release deferred claims synchronously. `waitForDrain` covers in-flight publication. Serialized production journal bindings now compose `inspectQueuedReceipt(expected)` with their existing strict private sibling and derive `isQueueReceiptCurrent` from the exact active recovery key. The read-only v1 observer requires every canonical ordered source ID, receipt/kind, full owner/acquisition and absence of a handoff offer. It returns detached source digests of the retained **queued** originals plus normalized-owner SHA-256; those digests are not pre-queue pending hashes or removal ACKs. Query mismatch yields no proof; malformed, corrupt, foreign or unprepared evidence throws without repair, rebinding or source mutation. Inspection uses lock-only source/config serialization, not writer admission, so observing exact accepted work does not borrow persistence permission. Native fixtures cover whole groups, owner/acquisition mismatch, offers, retained byte continuity, cold/detached proof, post-await completion/corruption and read-only authority. The production worker now wires routing's `beforeQueueReceiptPublished` publisher. It selects only retained operations overlapping that exact active-journal receipt, then reacquires fresh profile Workspace admission; ordinary receipts remain a no-op for Restore policy. A selected operation must be ready with its issued routing, exact current executor/operator and leader recipient. Canonical read-only binding/owner checks and live session/CWD/slot/target establish recipient authority before each `queued` acceptance. Exact receipt/source and owner-hash evidence is retained in the existing operation before readiness. Storage's authority predicate checks only live fences to avoid reacquiring its held snapshot transaction; canonical CAS plus pre/post snapshot reads preserve binding protection. Lost publication reply reconciles only an identical retained request/executor/operator/proof. After publication the publisher re-reads the whole receipt, and after admission returns it rechecks captured recipient guards; changed proof or authority suppresses readiness without rolling back accepted local queue work. No apply, prompt enqueue, deletion or cleanup is repeated by this publisher. Same-process worker reconstruction can republish only acceptance from its still-exact receipt; it is not actual interrupted Pi startup or a foreign-session successor grant.
609
+ The worker now has a prepared optional `beforeQueueReceiptPublished(receipt, owner, ctx, isCurrent)` barrier. Exact durable queue admission retains the original, but neither cached readiness nor its downstream observer publishes until the asynchronous acceptance publisher succeeds. Publication requires a captured exact receipt-inspection capability before and after the await, with current process/session/context/binding checks. Receipt and owner arguments are detached; grouped reports and concurrent snapshot refresh share one pending publication. Same-process cold receipt reconstruction uses that barrier without running the original handler. Callback failures hold queued work; readiness observers remain diagnostic after successful publication. Ordinary commit failures still release deferred claims synchronously. `waitForDrain` covers in-flight publication. Serialized production journal bindings now compose `inspectQueuedReceipt(expected)` with their existing strict private sibling and derive `isQueueReceiptCurrent` from the exact active recovery key. The read-only v1 observer requires every canonical ordered source ID, receipt/kind, full owner/acquisition and absence of a handoff offer. It returns detached source digests of the retained **queued** originals plus normalized-owner SHA-256; those digests are not pre-queue pending hashes or removal ACKs. Query mismatch yields no proof; malformed, corrupt, foreign or unprepared evidence throws without repair, rebinding or source mutation. Inspection uses lock-only journal source serialization, not writer admission, so observing exact accepted work does not borrow persistence permission. Native fixtures cover whole groups, owner/acquisition mismatch, offers, retained byte continuity, cold/detached proof, post-await completion/corruption and read-only authority. The production worker now wires routing's `beforeQueueReceiptPublished` publisher. It selects only retained operations overlapping that exact active-journal receipt, then reacquires fresh profile Workspace admission; ordinary receipts remain a no-op for Restore policy. A selected operation must be ready with its issued routing, exact current executor/operator and leader recipient. Canonical read-only binding/owner checks and live session/CWD/slot/target establish recipient authority before each `queued` acceptance. Exact receipt/source and owner-hash evidence is retained in the existing operation before readiness. Storage's authority predicate checks only live fences to avoid reacquiring its held snapshot transaction; canonical CAS plus pre/post snapshot reads preserve binding protection. Lost publication reply reconciles only an identical retained request/executor/operator/proof. After publication the publisher re-reads the whole receipt, and after admission returns it rechecks captured recipient guards; changed proof or authority suppresses readiness without rolling back accepted local queue work. No apply, prompt enqueue, deletion or cleanup is repeated by this publisher. Same-process worker reconstruction can republish only acceptance from its still-exact receipt; it is not actual interrupted Pi startup or a foreign-session successor grant.
610
610
 
611
611
  Native full-capacity leader Restore cases exercise positive queued publication, both rename sides, a forged returned intent with no proof, missing/unreadable receipt or a changed source digest on proof reread, authority loss, recipient change before publication/after the proof reread/after admission, and same-process worker reconstruction. Each original stays queued under its durable owner, exactly one prompt is accepted, and uncertain acceptance never publishes readiness or triggers cleanup. A native two-original media-group producer now exercises the same contract through its real unbound-group debounce, shared chooser/Restore selection, journal and worker. At full A–Z capacity, it relocates the same binding/slot and enqueues one combined prompt with one full receipt. A failed or unproven second acceptance retains the first proof but holds the entire receipt; lost post-rename reply reconciles both. A changed second digest on reread or an actual native whole-group handoff offer withholds readiness without undoing accepted work. Same-process worker reconstruction completes partial proof, preserves the first acceptance byte-for-byte, and emits one readiness observation without journal mutation or another enqueue/apply. Duplicate acceptance publication leaves canonical bytes unchanged. These are grouped prompt fixtures, not all remaining control/local outcomes or actual Pi startup acceptance.
612
612
 
@@ -643,7 +643,7 @@ This inspection authorizes no reload/restart, live-state mutation, fault injecti
643
643
  ### Split brain
644
644
 
645
645
  - Two leaders calling `getUpdates` is the main safety failure.
646
- - Lock heartbeat/takeover must be atomic enough to prevent this under normal local concurrency.
646
+ - Lock takeover must be atomic enough to prevent this under normal local concurrency.
647
647
  - Persistent competing `getUpdates` clients trigger a full transport stand-down even when the local lock still appears owned; the stopped runtime preserves accepted local work and releases only its exact ownership. See [Runtime Ownership](./architecture.md#runtime-ownership) for the threshold, diagnostic, and reconnect contract.
648
648
 
649
649
  ## Security Boundaries
@@ -657,7 +657,7 @@ This inspection authorizes no reload/restart, live-state mutation, fault injecti
657
657
 
658
658
  ## Acceptance Criteria
659
659
 
660
- - [x] The lock semantics are redesigned as Telegram bus leadership with heartbeat and stale takeover rules.
660
+ - [x] The lock semantics are redesigned as Telegram bus leadership with liveness proof and stale takeover rules.
661
661
  - [x] A first-class `TelegramTarget` can represent classic private chats and thread destinations.
662
662
  - [x] The bridge can run in classic mode with unchanged private-chat behavior.
663
663
  - [x] A live Pi instance can register as a follower when another live instance is leader.
@@ -676,7 +676,7 @@ Open live client and native Windows evidence gates for future behavior belong in
676
676
  - Bus semantics are the feature frame: Telegram threads are one Telegram UI substrate for a local multi-instance bus.
677
677
  - `TelegramTarget` and target-key helpers represent classic private chats and thread destinations.
678
678
  - Outbound ports, previews, replies, voice, attachments, chat actions, menus, sections, buttons, queue mutations, and direct local delivery carry target metadata where needed.
679
- - The transport lock distinguishes live bus leadership from ordinary classic ownership through heartbeat, leader epoch, and stale takeover rules.
679
+ - The transport lock distinguishes live bus leadership from ordinary classic ownership through leader epoch, liveness proof, and stale takeover rules.
680
680
  - The leader records live follower registration, heartbeat, thread identity, slot, and target mapping; followers do not poll `getUpdates`.
681
681
  - Local IPC is the default internal bus. Registered followers receive normalized inbound updates and send allowlisted, target-scoped Bot API calls through the leader.
682
682
  - Thread targets are current-state bindings, not historical delivery addresses. Stable restart hints require a fresh authenticated follower registration before they become live routing authority; stale/offline/failed entries remain reconciliation evidence only.
@@ -385,6 +385,8 @@ export function createTelegramManualFollowerProfileKeyResolver(input: {
385
385
 
386
386
  export interface TelegramBusFollowerElection {
387
387
  expectedOwner?: TelegramLockEntry;
388
+ /** The expected owner is bus-proven unresponsive; the lock CAS still requires it to be the current owner. */
389
+ unresponsive?: boolean;
388
390
  }
389
391
 
390
392
  export type TelegramBusFollowerPromotionHandler<TContext> = (
@@ -568,6 +570,8 @@ export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
568
570
  scheduleRetry?: (retry: () => void, delayMs: number) => void;
569
571
  getActiveContext?: () => TContext | undefined;
570
572
  promotionGraceMs?: number;
573
+ /** Bus liveness proof for a live-PID leader that no longer answers; replaces the retired file heartbeat. */
574
+ proveLeaderUnresponsive?: (owner: TelegramLockEntry) => Promise<boolean>;
571
575
  recordRuntimeEvent: (
572
576
  category: string,
573
577
  error: unknown,
@@ -2410,6 +2414,13 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
2410
2414
  ) {
2411
2415
  return;
2412
2416
  }
2417
+ if (await deps.proveLeaderUnresponsive?.(graceState.lock)) {
2418
+ await promoteToLeader(error, ctx, initialBinding, {
2419
+ expectedOwner: graceState.lock,
2420
+ unresponsive: true,
2421
+ });
2422
+ return;
2423
+ }
2413
2424
  deps.setLifecyclePhase(undefined);
2414
2425
  safeUpdateStatus(ctx);
2415
2426
  deps.recordRuntimeEvent(
package/lib/bus-leader.ts CHANGED
@@ -3371,6 +3371,14 @@ export function createTelegramBusLeaderEnvelopeHandler(
3371
3371
  return createUnauthorizedBusAck(envelope.requestId);
3372
3372
  }
3373
3373
  switch (envelope.kind) {
3374
+ case "bus.probe":
3375
+ // Liveness only: no registry, routing, journal or Bot API effect.
3376
+ return {
3377
+ kind: "bus.ack" as const,
3378
+ requestId: envelope.requestId,
3379
+ ok: true,
3380
+ protocol: deps.protocolIdentity,
3381
+ };
3374
3382
  case "follower.register":
3375
3383
  case "follower.restoreWorkspace": {
3376
3384
  const compatibility = getTelegramBusProtocolCompatibility({
package/lib/bus.ts CHANGED
@@ -45,7 +45,12 @@ import {
45
45
  type TelegramBusTransportRetryPolicy,
46
46
  } from "./bus-transport.ts";
47
47
  import type { TelegramThreadDisplayMode } from "./config.ts";
48
- import { TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS } from "./locks.ts";
48
+ import {
49
+ isSameTelegramLockOwner,
50
+ TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS,
51
+ type TelegramLockEntry,
52
+ type TelegramLockState,
53
+ } from "./locks.ts";
49
54
  import { resolveAgentDir } from "./paths.ts";
50
55
  import { getTelegramProcessBirthIdentity } from "./process-identity.ts";
51
56
  import {
@@ -131,7 +136,8 @@ export function createTelegramBusAuthSecret(): string {
131
136
  return randomBytes(32).toString("base64url");
132
137
  }
133
138
 
134
- const TELEGRAM_BUS_PROTOCOL_VERSION = 2 as const;
139
+ // v3: journal source serialization left the config transaction; mixed v2/v3 peers would not exclude each other.
140
+ const TELEGRAM_BUS_PROTOCOL_VERSION = 3 as const;
135
141
  export const TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION =
136
142
  "durable-follower-admission-v1" as const;
137
143
  export const TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF =
@@ -1222,6 +1228,11 @@ export type TelegramBusEnvelope = (
1222
1228
  args: unknown[];
1223
1229
  sentAtMs: number;
1224
1230
  }
1231
+ | {
1232
+ /** Side-effect-free leader liveness probe; any acknowledgement proves a responsive event loop. */
1233
+ kind: "bus.probe";
1234
+ requestId: string;
1235
+ }
1225
1236
  | {
1226
1237
  kind: "bus.ack";
1227
1238
  requestId: string;
@@ -1269,7 +1280,8 @@ export function getTelegramBusEnvelopeTrafficClass(
1269
1280
  ): TelegramBusEnvelopeTrafficClass {
1270
1281
  if (
1271
1282
  envelope.kind === "follower.register" ||
1272
- envelope.kind === "follower.restoreWorkspace"
1283
+ envelope.kind === "follower.restoreWorkspace" ||
1284
+ envelope.kind === "bus.probe"
1273
1285
  )
1274
1286
  return "bootstrap";
1275
1287
  if (envelope.kind === "bus.ack") return "response";
@@ -1579,6 +1591,9 @@ export function parseTelegramBusEnvelope(
1579
1591
  case "follower.callApi":
1580
1592
  envelope = parseCallApiEnvelope(value, requestId);
1581
1593
  break;
1594
+ case "bus.probe":
1595
+ envelope = { kind, requestId };
1596
+ break;
1582
1597
  case "bus.ack":
1583
1598
  envelope = parseAckEnvelope(value, requestId);
1584
1599
  break;
@@ -1590,6 +1605,112 @@ export function parseTelegramBusEnvelope(
1590
1605
  return envelope;
1591
1606
  }
1592
1607
 
1608
+ /**
1609
+ * One leader liveness observation. `silent` (connected or connecting, no answer within the window) and
1610
+ * `unreachable` (an endpoint that refuses connections: nobody listens) can support a takeover. A missing endpoint is
1611
+ * `unknown`: a live classic-mode leader has no socket at all. Any bytes or an orderly close prove the peer's event
1612
+ * loop handled the connection, so even an older leader rejecting the unknown kind is `responsive`.
1613
+ */
1614
+ export type TelegramBusLeaderProbeResult =
1615
+ | "responsive"
1616
+ | "silent"
1617
+ | "unreachable"
1618
+ | "unknown";
1619
+
1620
+ export function probeTelegramBusLeader(input: {
1621
+ socketPath: TelegramBusSocketPathSource;
1622
+ secret?: string;
1623
+ timeoutMs?: number;
1624
+ }): Promise<TelegramBusLeaderProbeResult> {
1625
+ const timeoutMs = input.timeoutMs ?? TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS;
1626
+ return new Promise((resolve) => {
1627
+ let settled = false;
1628
+ let socket: Socket | undefined;
1629
+ const finish = (result: TelegramBusLeaderProbeResult) => {
1630
+ if (settled) return;
1631
+ settled = true;
1632
+ clearTimeout(timer);
1633
+ socket?.destroy();
1634
+ resolve(result);
1635
+ };
1636
+ const timer = setTimeout(() => finish("silent"), timeoutMs);
1637
+ let connected = false;
1638
+ try {
1639
+ socket = createConnection(resolveTelegramBusSocketPath(input.socketPath));
1640
+ } catch {
1641
+ finish("unknown");
1642
+ return;
1643
+ }
1644
+ socket.once("connect", () => {
1645
+ connected = true;
1646
+ socket!.write(
1647
+ encodeTelegramBusEnvelope({
1648
+ kind: "bus.probe",
1649
+ requestId: `probe:${randomBytes(8).toString("hex")}`,
1650
+ ...(input.secret ? { auth: input.secret } : {}),
1651
+ }),
1652
+ );
1653
+ });
1654
+ socket.once("data", () => finish("responsive"));
1655
+ socket.once("end", () => finish("responsive"));
1656
+ socket.once("error", (error) => {
1657
+ const code = (error as NodeJS.ErrnoException).code;
1658
+ finish(
1659
+ !connected && code === "ECONNREFUSED" ? "unreachable" : "unknown",
1660
+ );
1661
+ });
1662
+ });
1663
+ }
1664
+
1665
+ /**
1666
+ * Takeover evidence replacing the retired file heartbeat: a full silent window, or an unreachable endpoint
1667
+ * confirmed again after one more window (a just-started leader binds its socket well within it). The owner must
1668
+ * stay the same throughout; the lock acquisition then CAS-checks that exact owner.
1669
+ */
1670
+ export async function proveTelegramBusLeaderUnresponsive(input: {
1671
+ probe: () => Promise<TelegramBusLeaderProbeResult>;
1672
+ isSameOwner: () => boolean;
1673
+ sleep: (ms: number) => Promise<void>;
1674
+ windowMs?: number;
1675
+ }): Promise<boolean> {
1676
+ const first = await input.probe();
1677
+ if (first === "silent") return input.isSameOwner();
1678
+ if (first !== "unreachable") return false;
1679
+ await input.sleep(input.windowMs ?? TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS);
1680
+ if (!input.isSameOwner()) return false;
1681
+ const second = await input.probe();
1682
+ return (second === "silent" || second === "unreachable") && input.isSameOwner();
1683
+ }
1684
+
1685
+ /** Composition-ready takeover proof for one profile's leader lock and endpoint. */
1686
+ export function createTelegramBusLeaderUnresponsivenessProof(deps: {
1687
+ getLeaderState: () => TelegramLockState;
1688
+ getLeaderSocketPath: () => string;
1689
+ probe?: typeof probeTelegramBusLeader;
1690
+ sleep?: (ms: number) => Promise<void>;
1691
+ }): (owner: TelegramLockEntry) => Promise<boolean> {
1692
+ const probe = deps.probe ?? probeTelegramBusLeader;
1693
+ const sleep =
1694
+ deps.sleep ??
1695
+ ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
1696
+ return (owner) =>
1697
+ proveTelegramBusLeaderUnresponsive({
1698
+ probe: () =>
1699
+ probe({
1700
+ socketPath: owner.busSocketPath ?? deps.getLeaderSocketPath(),
1701
+ secret: owner.busSecret,
1702
+ }),
1703
+ isSameOwner: () => {
1704
+ const state = deps.getLeaderState();
1705
+ return (
1706
+ state.kind === "active-elsewhere" &&
1707
+ isSameTelegramLockOwner(state.lock, owner)
1708
+ );
1709
+ },
1710
+ sleep,
1711
+ });
1712
+ }
1713
+
1593
1714
  interface TelegramBusLocalServer {
1594
1715
  start: () => Promise<void>;
1595
1716
  stop: () => Promise<void>;
package/lib/config.ts CHANGED
@@ -289,12 +289,6 @@ export interface TelegramConfigStore {
289
289
  assertExecutionCurrent?: () => void,
290
290
  commitIfOwned?: (commit: () => void) => boolean,
291
291
  ) => Promise<boolean>;
292
- /** Lock-only serialization for trusted synchronous source operations, not authorization.
293
- * Does not read/adopt config. Acquire required Workspace admission first; never
294
- * acquire owners or nest config admission here. Do not pass async callbacks:
295
- * returned promises are not protected after their synchronous prefix.
296
- */
297
- withSourceSerialization: <T>(operation: () => T) => T;
298
292
  /** Trusted synchronous publication only; caller acquires Workspace admission before this config transaction. */
299
293
  withPairingAdmission: <T>(
300
294
  profileName: string,
@@ -779,8 +773,6 @@ export function createTelegramConfigStore(
779
773
  nextConfig.allowedUserId = userId;
780
774
  setEffectiveConfig(nextConfig);
781
775
  },
782
- withSourceSerialization: (operation) =>
783
- withTelegramFileTransaction(`${configPath}.transaction`, operation),
784
776
  withPairingAdmission: (profileName, tokenSha256, publish) =>
785
777
  withPersistedPairingProfile(
786
778
  profileName,
package/lib/extension.ts CHANGED
@@ -181,13 +181,15 @@ export default function (pi: Pi.ExtensionAPI) {
181
181
  },
182
182
  getProfileName: configStore.getActiveProfileName,
183
183
  });
184
+ const withTelegramJournalSourceSerialization =
185
+ Journal.createTelegramJournalSourceSerialization();
184
186
  const telegramJournalBindingRuntime =
185
187
  Journal.createTelegramUpdateJournalBindingRuntime({
186
188
  base: {
187
189
  getProfileName: configStore.getActiveProfileName,
188
190
  getBotToken: configStore.getBotToken,
189
191
  getBotId: getTelegramBotId,
190
- withSourceSerialization: configStore.withSourceSerialization,
192
+ withSourceSerialization: withTelegramJournalSourceSerialization,
191
193
  onRecovery(event) {
192
194
  recordRuntimeEvent(
193
195
  "recovery",
@@ -259,6 +261,11 @@ export default function (pi: Pi.ExtensionAPI) {
259
261
  return telegramLeaderJournalPath.createJournalPath();
260
262
  },
261
263
  });
264
+ const proveTelegramLeaderUnresponsive =
265
+ Bus.createTelegramBusLeaderUnresponsivenessProof({
266
+ getLeaderState: lockRuntime.getState,
267
+ getLeaderSocketPath: getTelegramBusSocketPath,
268
+ });
262
269
  const telegramSessionContextStore =
263
270
  Lifecycle.createTelegramSessionContextStore<Pi.ExtensionContext>({
264
271
  getIdentity(ctx) {
@@ -1368,7 +1375,7 @@ export default function (pi: Pi.ExtensionAPI) {
1368
1375
  botToken,
1369
1376
  botId: getTelegramBotId(),
1370
1377
  });
1371
- return configStore.withSourceSerialization(function () {
1378
+ return withTelegramJournalSourceSerialization(function () {
1372
1379
  return Journal.isTelegramThreadCleanupJournalNamespaceClear({
1373
1380
  directory: Paths.resolveTelegramTempDir(),
1374
1381
  profile,
@@ -1988,6 +1995,7 @@ export default function (pi: Pi.ExtensionAPI) {
1988
1995
  },
1989
1996
  recovery: {
1990
1997
  getLeaderState: lockRuntime.getState,
1998
+ proveLeaderUnresponsive: proveTelegramLeaderUnresponsive,
1991
1999
  setLifecyclePhase: telegramBusFollowerControlState.setLifecyclePhase,
1992
2000
  updateStatus,
1993
2001
  promoteToLeader: promoteTelegramBusFollowerToLeader,
@@ -2347,6 +2355,7 @@ export default function (pi: Pi.ExtensionAPI) {
2347
2355
  const threadAwarePollingPorts = telegramThreadCapabilityRuntime.pollingPorts;
2348
2356
  const lockedPollingRuntime = Locks.createTelegramLockedPollingRuntime({
2349
2357
  lock: lockRuntime,
2358
+ proveOwnerUnresponsive: proveTelegramLeaderUnresponsive,
2350
2359
  resetDamagedState() {
2351
2360
  return Locks.resetDamagedTelegramRuntimeState(
2352
2361
  Paths.resolveTelegramStatePath(),
package/lib/journal.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  import {
47
47
  decodeTelegramSessionDirectoryName,
48
48
  getTelegramProfilePathSuffix,
49
+ resolveTelegramRuntimeDir,
49
50
  TELEGRAM_DEFAULT_PROFILE_NAME,
50
51
  } from "./paths.ts";
51
52
  import {
@@ -4889,6 +4890,21 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
4889
4890
  return runtime;
4890
4891
  }
4891
4892
 
4893
+ /**
4894
+ * Journal-owned cross-family source serializer, lock-only and never authorization. Its guard lives
4895
+ * in the runtime service directory, so journal work never touches `telegram.json` or its
4896
+ * transaction. Acquire Workspace admission and any sender (config) admission first; never acquire
4897
+ * owners inside it. Callbacks must be synchronous: a returned promise is unprotected after its
4898
+ * synchronous prefix.
4899
+ */
4900
+ export function createTelegramJournalSourceSerialization(
4901
+ getTransactionPath: () => string = () =>
4902
+ join(resolveTelegramRuntimeDir(), "journals.transaction"),
4903
+ ): <T>(operation: () => T) => T {
4904
+ return (operation) =>
4905
+ withTelegramFileTransaction(getTransactionPath(), operation);
4906
+ }
4907
+
4892
4908
  export function createTelegramUpdateJournalStore(
4893
4909
  options: TelegramUpdateJournalStoreOptions,
4894
4910
  ): TelegramUpdateJournalStore {
@@ -5591,36 +5607,40 @@ function createJournalStoreCore(
5591
5607
  }
5592
5608
  };
5593
5609
 
5610
+ const acquireSource = (): JournalSourceAcquisition | undefined =>
5611
+ sourceAccess
5612
+ ? acquireTelegramUpdateJournalFamily(
5613
+ {
5614
+ ...sourceAccess,
5615
+ path,
5616
+ profile,
5617
+ botIdentity: expectedIdentity,
5618
+ },
5619
+ version,
5620
+ )
5621
+ : undefined;
5622
+ const readAcquiredSource = (
5623
+ source: JournalSourceAcquisition,
5624
+ ): ReadTelegramUpdateJournalResult => {
5625
+ const file =
5626
+ source.evidence.kind === "present" ? source.evidence.file : emptyFile();
5627
+ return {
5628
+ file,
5629
+ exists: source.evidence.kind === "present",
5630
+ serializedBytes: assertCapacity(file),
5631
+ source,
5632
+ };
5633
+ };
5634
+
5594
5635
  const runJournalTransaction = <T>(
5595
5636
  operation: (read: typeof readCurrent) => T,
5596
5637
  ): T => {
5597
5638
  try {
5598
5639
  // Acquire before the transaction helper can create parents or staging names.
5599
5640
  // The config continuation excludes participating writers through consumption.
5600
- const source = sourceAccess
5601
- ? acquireTelegramUpdateJournalFamily(
5602
- {
5603
- ...sourceAccess,
5604
- path,
5605
- profile,
5606
- botIdentity: expectedIdentity,
5607
- },
5608
- version,
5609
- )
5610
- : undefined;
5611
- const readSource = (): ReadTelegramUpdateJournalResult => {
5612
- if (!source) return readCurrent();
5613
- const file =
5614
- source.evidence.kind === "present"
5615
- ? source.evidence.file
5616
- : emptyFile();
5617
- return {
5618
- file,
5619
- exists: source.evidence.kind === "present",
5620
- serializedBytes: assertCapacity(file),
5621
- source,
5622
- };
5623
- };
5641
+ const source = acquireSource();
5642
+ const readSource = (): ReadTelegramUpdateJournalResult =>
5643
+ source ? readAcquiredSource(source) : readCurrent();
5624
5644
  return withTelegramFileTransaction(`${path}.transaction`, () =>
5625
5645
  operation(readSource),
5626
5646
  );
@@ -7024,16 +7044,30 @@ function createJournalStoreCore(
7024
7044
  assertCurrent();
7025
7045
  return { issued, entries: result };
7026
7046
  });
7047
+ const projectRead = (current: ReadTelegramUpdateJournalResult) => ({
7048
+ ...cloneFile(current.file),
7049
+ exists: current.exists,
7050
+ serializedBytes: current.serializedBytes,
7051
+ });
7027
7052
  const journal: TelegramUpdateJournalStore = {
7028
7053
  read() {
7029
- return runMutation((readCurrent) => {
7030
- const current = readCurrent();
7031
- return {
7032
- ...cloneFile(current.file),
7033
- exists: current.exists,
7034
- serializedBytes: current.serializedBytes,
7035
- };
7036
- });
7054
+ // Atomic publication makes a repair-free read safe without serialization, so idle
7055
+ // status and polling cursor reads create no guards. Any read that needs reconciliation,
7056
+ // or races a compaction, falls back to the serialized path that owns repair.
7057
+ const attempt = (): ReadTelegramUpdateJournalResult | undefined => {
7058
+ try {
7059
+ const source = acquireSource();
7060
+ return source ? readAcquiredSource(source) : readCurrentStrict(false);
7061
+ } catch {
7062
+ return undefined;
7063
+ }
7064
+ };
7065
+ const current = options.withWriterAdmission
7066
+ ? options.withWriterAdmission(attempt)
7067
+ : attempt();
7068
+ return current
7069
+ ? projectRead(current)
7070
+ : runMutation((readCurrent) => projectRead(readCurrent()));
7037
7071
  },
7038
7072
  appendBatch(updates, requestedAcceptedThroughUpdateId) {
7039
7073
  const canonicalUpdates = updates.map((update) =>
@@ -7199,10 +7233,17 @@ function createJournalStoreCore(
7199
7233
  serializedBytes: published.serializedBytes,
7200
7234
  };
7201
7235
  });
7236
+ // Sender admission is the outer config authority; journal serialization nests inside it.
7237
+ const serializedAppend = (preApprovalExcluded?: boolean) =>
7238
+ withSourceSerialization
7239
+ ? withSourceSerialization(() =>
7240
+ appendWithEvidence(preApprovalExcluded),
7241
+ )
7242
+ : appendWithEvidence(preApprovalExcluded);
7202
7243
  const append = () => {
7203
7244
  if (withPairedAdmission) {
7204
7245
  const result = withPairedAdmission(normalizedUpdates, () =>
7205
- appendWithEvidence(),
7246
+ serializedAppend(),
7206
7247
  );
7207
7248
  if (!result.admitted)
7208
7249
  throw createJournalError(
@@ -7213,10 +7254,8 @@ function createJournalStoreCore(
7213
7254
  return result.value;
7214
7255
  }
7215
7256
  if (withPairingAdmission)
7216
- return withPairingAdmission(appendWithEvidence);
7217
- return withSourceSerialization
7218
- ? withSourceSerialization(() => appendWithEvidence())
7219
- : appendWithEvidence();
7257
+ return withPairingAdmission(serializedAppend);
7258
+ return serializedAppend();
7220
7259
  };
7221
7260
  const admittedAppend = () => {
7222
7261
  if (!workspaceAdmission) return append();