@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +10 -0
- package/dist/lib/bus-follower.d.ts +4 -0
- package/dist/lib/bus-follower.js +7 -0
- package/dist/lib/bus-leader.js +8 -0
- package/dist/lib/bus.d.ts +35 -0
- package/dist/lib/bus.js +81 -3
- package/dist/lib/config.d.ts +0 -6
- package/dist/lib/config.js +0 -1
- package/dist/lib/extension.js +9 -2
- package/dist/lib/journal.d.ts +8 -0
- package/dist/lib/journal.js +60 -35
- package/dist/lib/locks.d.ts +7 -0
- package/dist/lib/locks.js +72 -35
- package/dist/lib/status.js +25 -5
- package/dist/package.json +1 -1
- package/docs/architecture.md +12 -12
- package/docs/multi-instance-bus.md +15 -15
- package/lib/bus-follower.ts +11 -0
- package/lib/bus-leader.ts +8 -0
- package/lib/bus.ts +124 -3
- package/lib/config.ts +0 -8
- package/lib/extension.ts +11 -2
- package/lib/journal.ts +76 -37
- package/lib/locks.ts +97 -38
- package/lib/status.ts +24 -5
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
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,
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
package/lib/bus-follower.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
5601
|
-
|
|
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
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
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
|
-
|
|
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(
|
|
7217
|
-
return
|
|
7218
|
-
? withSourceSerialization(() => appendWithEvidence())
|
|
7219
|
-
: appendWithEvidence();
|
|
7257
|
+
return withPairingAdmission(serializedAppend);
|
|
7258
|
+
return serializedAppend();
|
|
7220
7259
|
};
|
|
7221
7260
|
const admittedAppend = () => {
|
|
7222
7261
|
if (!workspaceAdmission) return append();
|