@llblab/pi-telegram 0.54.1 → 0.54.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.54.3: One routing tab on mobile
8
+
9
+ - `Mobile routing tab`: Sending a command or prompt from `All` in mobile Telegram no longer leaves an extra tab named after the input. The mobile client creates that native tab itself before delivering the input to `All`; the bridge now adopts it as the routing tab and renames it, instead of creating a second tab beside it. Desktop behavior is unchanged.
10
+
11
+ ## 0.54.2: Diagnosable ownership checks and safe app cancellation
12
+
13
+ - `Ownership checks`: An ownership check that cannot be verified no longer silently stops a live transport. Each such miss is recorded (`ownership-check-failed`) and polling stands down only after two consecutive tolerated misses; a confirmed loss is recorded as `ownership-lost` and stands down at once (#319).
14
+ - `Generative Apps`: A cancelled app method can no longer start a process: an abort that arrives before the method calls `run` previously let the worker spawn a child that its own termination could orphan.
15
+
7
16
  ## 0.54.1: Followers go offline when Threaded Mode is turned off
8
17
 
9
18
  - `Threaded Mode downgrade`: Turning Threaded Mode off in BotFather now takes followers offline instead of looping between `electing` and `disconnected` (and filling the log) against a leader that switched to classic polling; reconnect with `/telegram-connect` after turning it back on. `/telegram-disconnect` on a follower without a live leader now disconnects locally and reports the Thread as kept, and a pending recovery retry no longer re-registers a disconnected follower.
@@ -22,6 +22,11 @@ parentPort.on("message", (message) => {
22
22
  });
23
23
 
24
24
  function runBoundedProcess(input) {
25
+ // After an abort the parent may terminate this worker before execFile's own abort kill runs, which
26
+ // would orphan a freshly spawned child; never start a process once cancelled.
27
+ if (controller.signal.aborted) {
28
+ return Promise.reject(new Error("Generative App method was cancelled."));
29
+ }
25
30
  if (!input || typeof input !== "object") {
26
31
  throw new Error("Generative App run input must be an object.");
27
32
  }
@@ -8,6 +8,8 @@ export declare const TELEGRAM_LOCK_KEY = "default";
8
8
  export declare const TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS = 8000;
9
9
  export declare const TELEGRAM_OWNERSHIP_CHECK_MS = 1000;
10
10
  export declare const TELEGRAM_OWNERSHIP_REFRESH_MS = 2000;
11
+ /** Consecutive unverified ownership checks tolerated before standing down; a concurrent shared-state replacement must not stop an owned transport. */
12
+ export declare const TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE = 2;
11
13
  /**
12
14
  * Resolve the extension-local owner slot for the active Telegram profile.
13
15
  * Default profile → default
package/dist/lib/locks.js CHANGED
@@ -14,6 +14,8 @@ export const TELEGRAM_LOCK_KEY = "default";
14
14
  export const TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS = 8_000;
15
15
  export const TELEGRAM_OWNERSHIP_CHECK_MS = 1_000;
16
16
  export const TELEGRAM_OWNERSHIP_REFRESH_MS = 2_000;
17
+ /** Consecutive unverified ownership checks tolerated before standing down; a concurrent shared-state replacement must not stop an owned transport. */
18
+ export const TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE = 2;
17
19
  const TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS = 5;
18
20
  const TELEGRAM_LOCK_WRITE_RETRY_DELAY_MS = 25;
19
21
  const TELEGRAM_LOCK_TRANSACTION_ATTEMPTS = 80;
@@ -1379,6 +1381,7 @@ export function createTelegramLockedPollingRuntime(deps) {
1379
1381
  let ownershipRefreshInterval;
1380
1382
  let ownershipStop;
1381
1383
  let activeContext;
1384
+ let ownershipCheckFailures = 0;
1382
1385
  let takeoverCandidate;
1383
1386
  let sessionAutoStartRun;
1384
1387
  let pollingGeneration = 0;
@@ -1442,26 +1445,42 @@ export function createTelegramLockedPollingRuntime(deps) {
1442
1445
  const startOwnershipWatcher = (ctx) => {
1443
1446
  const owner = snapshotLockContext(ctx);
1444
1447
  stopOwnershipWatcher();
1445
- ownershipCheckInterval = setInterval(() => {
1448
+ ownershipCheckFailures = 0;
1449
+ // A serialized "not the owner" answer is definitive and stands down at once; only an observation that
1450
+ // cannot be verified at all (both reads failed) counts toward the bounded tolerance.
1451
+ const verifyOwnership = (observe) => {
1452
+ let owned;
1453
+ let failure;
1446
1454
  try {
1447
- // A lock-free miss can be a strict read racing an atomic replace; refresh confirms through the serialized path.
1448
- if (deps.lock.owns(owner) || deps.lock.refresh(owner))
1449
- return;
1455
+ owned = observe();
1450
1456
  }
1451
1457
  catch (error) {
1452
- deps.recordRuntimeEvent?.("lock", error, { phase: "check" });
1458
+ failure = error;
1453
1459
  }
1454
- stopAfterOwnershipLoss();
1455
- }, ownershipCheckMs);
1456
- ownershipRefreshInterval = setInterval(() => {
1457
- try {
1458
- if (deps.lock.refresh(owner))
1459
- return;
1460
+ if (owned === true) {
1461
+ ownershipCheckFailures = 0;
1462
+ return;
1460
1463
  }
1461
- catch (error) {
1462
- deps.recordRuntimeEvent?.("lock", error, { phase: "refresh" });
1464
+ if (owned === false) {
1465
+ deps.recordRuntimeEvent?.("lock", new Error("Telegram bridge ownership moved away from this instance."), { phase: "ownership-lost" });
1466
+ stopAfterOwnershipLoss();
1467
+ return;
1463
1468
  }
1464
- stopAfterOwnershipLoss();
1469
+ ownershipCheckFailures += 1;
1470
+ deps.recordRuntimeEvent?.("lock", failure, {
1471
+ phase: "ownership-check-failed",
1472
+ consecutiveFailures: ownershipCheckFailures,
1473
+ tolerance: TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE,
1474
+ });
1475
+ if (ownershipCheckFailures > TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE)
1476
+ stopAfterOwnershipLoss();
1477
+ };
1478
+ ownershipCheckInterval = setInterval(() => {
1479
+ // A lock-free miss can be a strict read racing an atomic replace; refresh confirms through the serialized path.
1480
+ verifyOwnership(() => deps.lock.owns(owner) || deps.lock.refresh(owner));
1481
+ }, ownershipCheckMs);
1482
+ ownershipRefreshInterval = setInterval(() => {
1483
+ verifyOwnership(() => deps.lock.refresh(owner));
1465
1484
  }, ownershipRefreshMs);
1466
1485
  ownershipCheckInterval.unref?.();
1467
1486
  ownershipRefreshInterval.unref?.();
@@ -910,6 +910,8 @@ export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
910
910
  export const TELEGRAM_ROUTING_CHOICE_EXPIRED = "Routing choice expired";
911
911
  /** Every temporary routing tab carries this name, whether the bot created it or adopted Telegram's native tab. */
912
912
  const TELEGRAM_TEMPORARY_THREAD_NAME = "🚦 Routing";
913
+ /** A mobile client's native tab creation precedes the All input it is named after by about a second. */
914
+ const TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC = 10;
913
915
  export function isTelegramAllTabCommandExpired(message, nowMs = Date.now()) {
914
916
  return (message.message_thread_id === undefined &&
915
917
  typeof message.date === "number" &&
@@ -6854,6 +6856,30 @@ export function createTelegramInboundRouteRuntime(deps) {
6854
6856
  }
6855
6857
  return undefined;
6856
6858
  };
6859
+ /** A fresh implicit native tab whose name the All input starts with, observed just before it in the same exact scope. */
6860
+ const findImplicitCreationForAllInput = (message, updateId, cap, ctx) => {
6861
+ const text = (message.text ?? message.caption ?? "").trim();
6862
+ let match;
6863
+ for (const [key, implicit] of implicitThreadCreations) {
6864
+ if (implicit.name &&
6865
+ text.startsWith(implicit.name) &&
6866
+ implicit.updateId < updateId &&
6867
+ implicit.createdAtSec > 0 &&
6868
+ message.date !== undefined &&
6869
+ message.date >= implicit.createdAtSec &&
6870
+ message.date - implicit.createdAtSec <=
6871
+ TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC &&
6872
+ implicit.ctx === ctx &&
6873
+ implicit.operatorUserId === cap.operatorUserId &&
6874
+ implicit.journalBindingKey === cap.journalBindingKey &&
6875
+ isDeepStrictEqual(implicit.executor, cap.authority.executor) &&
6876
+ implicit.generation === deps.getSessionGeneration?.() &&
6877
+ implicit.scope === deps.getAdmissionScope?.() &&
6878
+ (!match || implicit.updateId > match.implicit.updateId))
6879
+ match = { key, implicit };
6880
+ }
6881
+ return match;
6882
+ };
6857
6883
  /**
6858
6884
  * An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
6859
6885
  * and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
@@ -6893,6 +6919,34 @@ export function createTelegramInboundRouteRuntime(deps) {
6893
6919
  entry = found;
6894
6920
  return;
6895
6921
  }
6922
+ // Mobile clients sending from All first create a native tab named after the input, then deliver
6923
+ // the input itself to All: adopt that tab rather than leaving it behind next to a second one.
6924
+ const native = findImplicitCreationForAllInput(message, updateId, cap, ctx);
6925
+ if (native) {
6926
+ const adopted = store.registerImplicitTemporaryThread({ journalBindingKey, updateIds: [updateId] }, native.implicit.target, randomBytes(16).toString("hex"), {
6927
+ ...authority,
6928
+ isCurrent: () => current() &&
6929
+ implicitThreadCreations.get(native.key) === native.implicit,
6930
+ });
6931
+ if (adopted && current()) {
6932
+ implicitThreadCreations.delete(native.key);
6933
+ entry = adopted;
6934
+ // Adopted native tabs share the routing name; a failed rename never blocks routing.
6935
+ try {
6936
+ await deps.callApi("editForumTopic", {
6937
+ chat_id: native.implicit.target.chatId,
6938
+ message_thread_id: native.implicit.target.threadId,
6939
+ name: TELEGRAM_TEMPORARY_THREAD_NAME,
6940
+ });
6941
+ }
6942
+ catch (error) {
6943
+ deps.recordRuntimeEvent?.("routing", error, {
6944
+ phase: "temporary-thread-name",
6945
+ });
6946
+ }
6947
+ return;
6948
+ }
6949
+ }
6896
6950
  const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
6897
6951
  entry = reservation?.entry;
6898
6952
  if (!reservation?.reserved || !current())
@@ -7253,6 +7307,12 @@ export function createTelegramInboundRouteRuntime(deps) {
7253
7307
  executor: structuredClone(cap.authority.executor),
7254
7308
  generation: deps.getSessionGeneration?.(),
7255
7309
  scope: deps.getAdmissionScope?.(),
7310
+ target: {
7311
+ chatId: lifecycle.target.chatId,
7312
+ threadId: lifecycle.target.threadId,
7313
+ },
7314
+ name: "name" in created && typeof created.name === "string" ? created.name : "",
7315
+ createdAtSec: message.date ?? 0,
7256
7316
  });
7257
7317
  }
7258
7318
  else
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.54.1",
3
+ "version": "0.54.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -362,7 +362,7 @@ Read-only migration probes constrain that comparison. Publishing a v2 snapshot o
362
362
  - Session replacement through `reload`, `new`, `resume`, or `fork` suspends polling/watchers without releasing ownership or publishing inactivity so the next session in the same process can resume. Late shutdown cannot clear a replacement context even when its identity is reused: the captured session generation must still match. For replacements other than `/resume`, a registered follower snapshots its assigned target into a short-lived same-process handoff, stops the old receiver/heartbeat, and re-registers through the live leader without marking or replacing its Telegram thread. Local `/resume` suppresses that source-target handoff and selects the destination session's binding instead. Hard process termination cannot run graceful teardown, so stale recovery retains its restart-hint path.
363
363
  - Live external owners require explicit takeover confirmation. Long-lived timers compare against snapshotted owner identity and stop local transport work when the slot no longer matches.
364
364
  - The profile's `transport` section owns only Telegram transport control. Local extension and accepted queue state remain per Pi instance when ownership moves, but previews, final delivery, dispatch transport mutations, and delayed Bot API work fail closed until exact direct or follower authority becomes valid again.
365
- - Exact ownership remains checked every second and re-confirmed every two seconds by lock-free reads; the leader writes no heartbeat, and a live owner is replaceable only by dead-PID or bus liveness proof (see [Leader Election](./multi-instance-bus.md)). Every acquisition, entry migration, release, takeover, and stale recovery is one `transport`-section transaction through the shared `runtime/state.json.transaction` guard (see [Consolidated Runtime Root](#consolidated-runtime-root)); it fences stale recovery and delayed release against replacement-owner ABA and fails closed on malformed state, unverifiable ownership, contention timeout, or unsupported filesystem behavior. Publication uses private staging below `runtime/` and atomic rename.
365
+ - Exact ownership remains checked every second and re-confirmed every two seconds by lock-free reads. A lock-free miss is confirmed through the serialized refresh path: a serialized not-owner answer records `ownership-lost` and stands down at once, while an observation that cannot be verified at all records `ownership-check-failed` and stands down only after two consecutive tolerated misses; the leader writes no heartbeat, and a live owner is replaceable only by dead-PID or bus liveness proof (see [Leader Election](./multi-instance-bus.md)). Every acquisition, entry migration, release, takeover, and stale recovery is one `transport`-section transaction through the shared `runtime/state.json.transaction` guard (see [Consolidated Runtime Root](#consolidated-runtime-root)); it fences stale recovery and delayed release against replacement-owner ABA and fails closed on malformed state, unverifiable ownership, contention timeout, or unsupported filesystem behavior. Publication uses private staging below `runtime/` and atomic rename.
366
366
  - `state.json` is authoritative and private: `transport` (ownership), `workspace` (canonical Workspace/Restore evidence) and `admission` per profile. The non-canonical runtime/roster/diagnostics projection is the `runtime` section, published only by the transport owner without touching canonical sections; it and `logs.jsonl` never grant routing authority. The leader reads optional follower slot hints from it; a missing or malformed projection is ignored. Followers remain authenticated bus registrations rather than transport/Workspace/runtime writers; they may publish their process-owned admission leases.
367
367
  - Ordinary ownership and state mutations fail closed on a malformed or foreign `state.json`; read-only ownership queries report no owner, not proof of process absence. The legacy whole-file reset is not wired for the shared envelope. A non-election leader start performs the [damaged-state reset](#damaged-state-reset-operator-approved); section-scoped recovery is not planned. Startup best-effort removes obsolete current-runtime root/session `recovery/` directories, never the pre-0.52.0 tree.
368
368
 
@@ -422,7 +422,7 @@ The reset deliberately forgets every profile's polling cursor/journal pointer, b
422
422
 
423
423
  The runtime sections and log have different authority and write pressure. Preserve that distinction when optimizing them:
424
424
 
425
- - The `transport` section is safety-critical authority. Acquire, release, takeover, stale recovery, and two-second leader lease refresh mutate it. The steady-state baseline is one cached atomic rewrite every two seconds per active profile, or 43,200 refreshes/day; one-second ownership checks are read-only. Every mutation serializes the full cross-process read/check/write through the shared `runtime/state.json.transaction` guard and replaces only that profile's section.
425
+ - The `transport` section is safety-critical authority. Acquire, release, takeover, stale recovery, and entry migration mutate it. Since 0.54 the steady-state baseline is zero rewrites: the one- and two-second ownership checks are lock-free reads. Every mutation serializes the full cross-process read/check/write through the shared `runtime/state.json.transaction` guard and replaces only that profile's section.
426
426
  - The `workspace` section holds recovery-critical thread/capability state. Every explicit thread-store `persist()` builds a semantic snapshot, but an unchanged payload skips directory preparation, temporary-name generation, file creation, and rename after comparing JSON values independently of object-key order and ignoring `writtenAtMs`; array order, value types, and explicit null remain significant, and changed snapshots retain the full atomic replacement path. No-op saves still read and compare current disk state, rather than trusting a cache that could hide another owner's publication. Diagnostics scheduling coalesces requests across a bounded 100 ms window and writes only the `runtime` section (`persistStatus()`, skipped when the JSON value is unchanged), so an idle leader's polling snapshots never rewrite the canonical `workspace` section. Only the exact transport owner commits either section; non-owners reload current state instead of publishing.
427
427
  - `logs.jsonl` is fail-soft observational evidence, never routing authority. Runtime events admitted in one JavaScript turn batch by captured profile path into one size check, one profile-wide file transaction, and one append while preserving event order. Batching adds no timer or shutdown-loss window; separate profiles remain isolated, and one failed group does not drop another. Scope reset and rotation retain their serialized copy/replace path. The 5 MiB value is a rotation threshold: an authorized writer rotates between batched records before the next record crosses it, so overshoot is bounded to one admitted record plus reset metadata; a writer without reset authority defers rotation to the owner.
428
428
 
@@ -476,7 +476,7 @@ With live bindings or a retained temporary entry, a known command or a fresh pri
476
476
 
477
477
  #### Temporary Thread Multi-Input Lifecycle (Approved Design)
478
478
 
479
- Every temporary routing tab carries one fixed routing name ([copy](./ui-style.md#route-chooser)), never its first input, because later prompts may join it: the bot creates its own tabs with that name and renames a native tab once, best-effort, when it adopts it. Commands and prompts share the same mode chooser. A command delivered with a Telegram-provided unbound private target uses the same chooser and fresh exact-source Cancel capability in that tab, without renaming it or creating another one. A received target or successful rename alone never manufactures temporary authority. A fresh owner-authenticated private `forum_topic_created` with literal `is_name_implicit: true` supplies native creation evidence: routing retains a bounded process-local observation tied to the exact context, session generation, admission scope, operator, journal binding and executor. The next live input group in that exact target, of any content kind and either one message or an album, registers it through Threads `registerImplicitTemporaryThread` under profile admission, with that group as the entry's first input (its smallest update ID is the source), without calling or fabricating `createForumTopic` (adoption renames it once, best-effort); the publication CAS refuses any binding, owner record, reservation, provisioning, cleanup or Restore conflict. Normal grouped membership, one-shot Forward, source disposition, last-Cancel quiet period and cleanup issuance then apply identically. Historical creation signals, context/epoch changes, manual names, string flags, foreign creators and missing observations grant nothing. The observation is consumed on registration, never reconstructed from a title or restart. Existing bindings/reservations and acknowledged temporary targets retain their protections. Their root chooser offers Reroute, Restore and eligible Cancel; each mode opens its own submenu of live thread targets without dispatching the held input. A submenu Back returns to the mode chooser with its exact original description, retained on that pending chooser; navigation never substitutes or recomputes the root copy. Copy, emoji and button layout live in [UI Style](./ui-style.md#route-chooser). Cancel appears only at the root. After cancellation, the immediate cleanup remains proof-gated. Private-chat temporary tabs skip the unsupported `closeForumTopic` call and issue only one `deleteForumTopic` attempt; literal positive deletion acknowledgement is still required, and uncertainty never authorizes a retry. A menu-picked command arrives threadless, so its original stays in All while the bot-created tab carries the chooser; once that input is consumed (Forward finalization, Restore dispatch, explicit or Restore-sibling Cancel, or chooser expiry, including after restart), the bot makes one best-effort `deleteMessage` of the All copy. Failure is recorded and never retried. Pending-cancellation recovery deletes nothing, and typed All inputs already live in their own tab and leave with it.
479
+ Every temporary routing tab carries one fixed routing name ([copy](./ui-style.md#route-chooser)), never its first input, because later prompts may join it: the bot creates its own tabs with that name and renames a native tab once, best-effort, when it adopts it. Commands and prompts share the same mode chooser. A command delivered with a Telegram-provided unbound private target uses the same chooser and fresh exact-source Cancel capability in that tab, without renaming it or creating another one. Mobile clients sending from `All` first create an implicitly named native tab and then deliver the input itself to `All`: an `All` input whose text starts with the name of such an observation from the same exact scope, created at most ten seconds earlier, registers that native tab as its temporary tab through `registerImplicitTemporaryThread` instead of creating a second one, and renames it once to the routing name. A received target or successful rename alone never manufactures temporary authority. A fresh owner-authenticated private `forum_topic_created` with literal `is_name_implicit: true` supplies native creation evidence: routing retains a bounded process-local observation tied to the exact context, session generation, admission scope, operator, journal binding and executor. The next live input group in that exact target, of any content kind and either one message or an album, registers it through Threads `registerImplicitTemporaryThread` under profile admission, with that group as the entry's first input (its smallest update ID is the source), without calling or fabricating `createForumTopic` (adoption renames it once, best-effort); the publication CAS refuses any binding, owner record, reservation, provisioning, cleanup or Restore conflict. Normal grouped membership, one-shot Forward, source disposition, last-Cancel quiet period and cleanup issuance then apply identically. Historical creation signals, context/epoch changes, manual names, string flags, foreign creators and missing observations grant nothing. The observation is consumed on registration, never reconstructed from a title or restart. Existing bindings/reservations and acknowledged temporary targets retain their protections. Their root chooser offers Reroute, Restore and eligible Cancel; each mode opens its own submenu of live thread targets without dispatching the held input. A submenu Back returns to the mode chooser with its exact original description, retained on that pending chooser; navigation never substitutes or recomputes the root copy. Copy, emoji and button layout live in [UI Style](./ui-style.md#route-chooser). Cancel appears only at the root. After cancellation, the immediate cleanup remains proof-gated. Private-chat temporary tabs skip the unsupported `closeForumTopic` call and issue only one `deleteForumTopic` attempt; literal positive deletion acknowledgement is still required, and uncertainty never authorizes a retry. A menu-picked command arrives threadless, so its original stays in All while the bot-created tab carries the chooser; once that input is consumed (Forward finalization, Restore dispatch, explicit or Restore-sibling Cancel, or chooser expiry, including after restart), the bot makes one best-effort `deleteMessage` of the All copy. Failure is recorded and never retried. Pending-cancellation recovery deletes nothing, and typed All inputs already live in their own tab and leave with it.
480
480
 
481
481
  This multi-input lifecycle is implemented in the local candidate, not authority for live deletion. It supersedes unconditional per-chooser tab removal. The reject-only guard prevents cleanup/retirement while another known same-tab chooser remains, including unresolved cleanup-only work. `temporaryThreads.inputs` now records bounded append-only journal source groups through the unbound producer before chooser publication; duplicates are read-only, partial/foreign/overlapping groups refuse, and faults retain the source without a new chooser. Cold reads preserve this protection without process-local chooser memory. A group has no readiness or disposition flag, and an absent legacy field does not prove complete coverage. `cancelledInputs` records exact whole known donor groups after either same-journal/operator retained-cancellation evidence or the separate body-free expiry tombstone. Expiry recording may clear a group's unknown Forward issuance or its expired Restore intent, but never mutates a canonical binding, recipient queue or immutable scoped settlement proof. If Restore already bound the tab, expiry releases the temporary frame without deletion. Otherwise fully resolved groups enter the existing one-second quiet-period cleanup. A partially acknowledged known cohort may finish donor disposition when every member is absent from that exact source journal and at least one member has same-operator expiry proof; this is not recipient completion or non-delivery proof. Fresh strict namespace/protection evidence still gates deletion; independent work and bound Threads remain protected. Store publication and returned/retained acknowledgement recheck that proof and operator/executor authority; a lost reply reconciles the exact fact read-only, while missing/foreign/corrupt proof stays protective. The producer composes this into explicit Cancel before temporary cleanup or chooser retirement. Cancelled groups cannot Restore; independent sources remain untouched. More than one known group still blocks cleanup and raw retirement, including all-cancelled membership: these facts grant no deletion. Each successful Cancel retires only its own chooser. If durable membership is fully resolved by exact cancellation/completion facts and no chooser remains, a process-local cleanup is queued with no grace delay (it still waits behind the resolving operation's Workspace admission, which Cancel releases right after its notice, answering the toast and deleting any All copy alongside the cleanup; new input in the tab before it runs cancels it). Its single attempt, under profile admission, rechecks authority, whole-group cancellation/expiry evidence, exact entry CAS, a strict per-Thread journal census (every namespace source must read strictly; any retained update naming the Thread in any state, any of its own groups, and any custody-bearing, failed or non-plain entry anywhere still protect; only an unrelated waiting plain message, edit, callback or reaction elsewhere is exempt, so a chooser held in another tab cannot pin every temporary tab, and so is a plain button tap in this tab, since it carries no input and the in-flight Cancel itself may outlast the quiet period), absence of choosers and the normal reconciler protections. `issueTemporaryThreadCleanup` publishes canonical `cleanupIssued: true` only for an exact fully resolved entry with strict unbound canonical evidence checked inside the publication transaction; the normal target/source guards precede issuance and run again at API boundaries. The reconciler requires that exact retained issued frame and limits its plan to this tab; unrelated expired-provision cleanup never borrows the grant. Close/delete calls disable retries and transport fallback and require a positive acknowledgement. Confirmed current deletion alone permits entry retirement; skip, negative/lost reply, authority loss or post-rename publication interruption retains the marker. Cold reads classify already-issued entries as unknown, new membership refuses, and executor adoption never renews the grant. Missing evidence only refuses; an unpublished grant issues no API call and may still receive its first grant. The quiet period remains process-local, but issued uncertainty survives restart. The census cannot see updates in transit before journal append. Restore from a multi-input tab is allowed only when each other known group is durably cancelled, Forward-completed or a live unselected chooser that supports private abandonment. A previously completed Forward never supplies a new selection or cancellation grant. After positive settlement of every selected source, such siblings are abandoned with cancellation facts and their controls edited to cancelled, then `retireTemporaryThread(expected, authority, completed)` releases the entry (one newly completed selected group, all others durably cancelled or previously Forward-completed). Missing authority/proof or an unproven Restore leaves siblings pending and protected. On startup, a retained journal input recorded in a created temporary tab whose target a Workspace binding now owns is held as historical, never rerouted; an exact saved chooser clock may expire its unclaimed donor attempt without affecting the binding. Live pending sources and due retry-wait sources (including startup retries) use the same fresh membership/binding predicate through `shouldHoldPendingInput` before execution, with source/context/binding/transport checks after classification awaits. The hold grants neither cancellation nor delivery and does not block unrelated inputs. Recipient custody never uses this direct-source classifier. A native production witness admits a recorded command after the worker has already processed an unrelated command, proving it cannot fall through to ordinary bound dispatch. A production retry witness keeps recorded failure metadata intact without command execution, cancellation or deletion. Claimed retries cannot schedule repeated due-time wakes; independent due and future retries remain executable. Real-IPC Forward/Restore sibling witnesses now include recipient journal drain through the real worker and follower router: only the selected message executes, source disposal is observed, and repeated controls do not replay the command. Existing message-ownership composition keeps a locally published chooser's callbacks at its leader publisher after follower rebinding; the native fixture now records publication ownership rather than relying only on target ownership. Four native delivery-reply-loss witnesses cover both routing actions (Forward/Restore), before recipient execution and after worker disposal: the leader source remains unresolved without ACK, independent sibling Cancel preserves the selected group/tab, and a late reply or repeated issued Restore click cannot settle/replay it. Explicit Forward retry after a lost ACK reproduced command duplication when the recipient worker had already disposed its journal record; stable delivery identity alone was not terminal deduplication. The follower's bounded process-local delivery window now acknowledges such a retry without re-execution while it retains that delivery. Ordinary Forward from a temporary tab first publishes a durable per-group `forwardedInputs` issuance fact in the canonical Threads owner under current authority, before either local command handling/prompt preparation/queue admission or follower RPC. Only positively unpublished issuance may receive its first grant; a published fact (even if its reply is ambiguous) is never retried, including after reload, restart or leader replacement. Local repeated selection refuses the retained grant before handler or queue re-entry. Captured All-tab provenance and acknowledged typed-input membership remain protective if the metadata reader disappears; unavailable evidence never falls back to unguarded generic dispatch. Restore retains its separate canonical routing grant. Native local fixtures interrupt source completion before/after commit, prompt preparation and issuance publication/authority; issued sources stay nonterminal, independent siblings remain cancellable and cold restart never manufactures completion or another attempt. A process-local `forward-unknown` chooser phase gates the live chooser. An issued group is refused by repeat Forward, explicit Cancel and Restore (the store also rejects them); before its fixed chooser deadline it keeps sibling accounting protective, while body-free donor expiry or positive `completedInputs` may resolve it; independent siblings remain operable and a restarted chooser cannot resend. Issuance is not proof of delivery, and recipient-side deduplication is limited to the bounded process-local delivery window; the chooser deadline supplies a loss-accepting exit for unclaimed unknown issued groups; generic journal forwarding and non-chooser retry policy are unchanged. Canonical Restore keeps its existing durable grants. Mixed native witnesses prove that a completed Forward allows Restore of another group without replay/recancellation, while an unknown Forward prevents sibling Restore before an intent is created. Prompt Restore leaves a command sibling pending at queue admission, then privately cancels it only after exact scoped worker receipt disposition. Independently Forwarded queued input is neither unassigned nor cancellation-capable: either receipt may dispose first, the other retains custody, and only both positive dispositions release temporary membership while keeping the bound tab/slot. An ordinary queued ACK emits the existing source-completion hint only after whole acknowledged removal and fresh context, owner, generation and originating journal-binding checks; it creates no immutable Restore marker. A published independent Forward group fact wakes pure inspection of already settled Restore only after its Workspace admission releases. The store refuses both Forward facts and cancellation for any source overlapping a retained Restore grant, regardless of target; malformed partial selected groups stay protective. Native ordinary prompt Forward plus sibling Cancel also delays deletion until receipt disposition and fresh all-resolved cleanup. Pi handoff is supplied by the fixture, not observed model/task completion. The retained All-command fixture registers 84 scenarios across creation, Forward, Cancel, cleanup/membership and Restore, including full slots, publication faults, sibling protection, native Unix IPC and recipient worker drain through the real non-reentrant Workspace gate. Full-slot follower cases hold a real Unix response before apply, after apply, and after read-only inspection until client timeout. Fresh same-session registration/context generations reconcile only through inspection; late replies never publish readiness, repeat apply or dispatch, cancel siblings, release independent queued recipient work or delete the tab. Successful continuation retains exact scoped source-disposition proof. A separate cold-successor witness loads fresh stores and a journal binding after an issued command lost acceptance authority, then starts a fresh context/generation/epoch under supplied same-session owner publication. Original command and sibling stay pending, spent routing/canonical facts remain unchanged, and stale Restore/Forward/Cancel controls cannot replay or dispose them. No chooser is reconstructed: the protective hold is proven, not a usable cold recovery surface. Pi effects and startup registration remain supplied adapters/preconditions; actual Pi startup and live behavior were accepted in the operator's 0.52.0 live smoke. Forward completes only its own input: no Forward removes the tab, the worker's completion ACK records a durable `completedInputs` group (process-local accumulation across the group's sources), and when every known group is cancelled or completed the delayed, fully rechecked cleanup removes the tab once. Cancelled and completed groups are disjoint, cannot Restore, and a completed Forward queued prompt holds the tab until its receipt completes. The tab chooser discloses removal only when no other input remains; a failed removal no longer edits the original chooser. Source-only prompt Cancel remains unchanged. The candidate's temporary-tab chooser is the default under the owned leader epoch, not a configurable feature flag. Upgrade all prospective canonical readers/writers before live activation; the metadata (including `forwardProtocol`, `forwardedInputs` and `cleanupIssued`) is not downgrade-safe through older snapshot writers, which could drop issuance facts.
482
482
 
@@ -22,6 +22,11 @@ parentPort.on("message", (message) => {
22
22
  });
23
23
 
24
24
  function runBoundedProcess(input) {
25
+ // After an abort the parent may terminate this worker before execFile's own abort kill runs, which
26
+ // would orphan a freshly spawned child; never start a process once cancelled.
27
+ if (controller.signal.aborted) {
28
+ return Promise.reject(new Error("Generative App method was cancelled."));
29
+ }
25
30
  if (!input || typeof input !== "object") {
26
31
  throw new Error("Generative App run input must be an object.");
27
32
  }
package/lib/locks.ts CHANGED
@@ -38,6 +38,8 @@ export const TELEGRAM_LOCK_KEY = "default";
38
38
  export const TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS = 8_000;
39
39
  export const TELEGRAM_OWNERSHIP_CHECK_MS = 1_000;
40
40
  export const TELEGRAM_OWNERSHIP_REFRESH_MS = 2_000;
41
+ /** Consecutive unverified ownership checks tolerated before standing down; a concurrent shared-state replacement must not stop an owned transport. */
42
+ export const TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE = 2;
41
43
  const TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS = 5;
42
44
  const TELEGRAM_LOCK_WRITE_RETRY_DELAY_MS = 25;
43
45
  const TELEGRAM_LOCK_TRANSACTION_ATTEMPTS = 80;
@@ -2039,6 +2041,7 @@ export function createTelegramLockedPollingRuntime<
2039
2041
  let ownershipRefreshInterval: ReturnType<typeof setInterval> | undefined;
2040
2042
  let ownershipStop: Promise<void> | undefined;
2041
2043
  let activeContext: TContext | undefined;
2044
+ let ownershipCheckFailures = 0;
2042
2045
  let takeoverCandidate: TelegramLockEntry | undefined;
2043
2046
  let sessionAutoStartRun: Promise<void> | undefined;
2044
2047
  let pollingGeneration = 0;
@@ -2102,22 +2105,45 @@ export function createTelegramLockedPollingRuntime<
2102
2105
  const startOwnershipWatcher = (ctx: TContext) => {
2103
2106
  const owner = snapshotLockContext(ctx);
2104
2107
  stopOwnershipWatcher();
2105
- ownershipCheckInterval = setInterval(() => {
2108
+ ownershipCheckFailures = 0;
2109
+ // A serialized "not the owner" answer is definitive and stands down at once; only an observation that
2110
+ // cannot be verified at all (both reads failed) counts toward the bounded tolerance.
2111
+ const verifyOwnership = (observe: () => boolean): void => {
2112
+ let owned: boolean | undefined;
2113
+ let failure: unknown;
2106
2114
  try {
2107
- // A lock-free miss can be a strict read racing an atomic replace; refresh confirms through the serialized path.
2108
- if (deps.lock.owns(owner) || deps.lock.refresh(owner)) return;
2115
+ owned = observe();
2109
2116
  } catch (error) {
2110
- deps.recordRuntimeEvent?.("lock", error, { phase: "check" });
2117
+ failure = error;
2118
+ }
2119
+ if (owned === true) {
2120
+ ownershipCheckFailures = 0;
2121
+ return;
2122
+ }
2123
+ if (owned === false) {
2124
+ deps.recordRuntimeEvent?.(
2125
+ "lock",
2126
+ new Error("Telegram bridge ownership moved away from this instance."),
2127
+ { phase: "ownership-lost" },
2128
+ );
2129
+ stopAfterOwnershipLoss();
2130
+ return;
2111
2131
  }
2112
- stopAfterOwnershipLoss();
2132
+ ownershipCheckFailures += 1;
2133
+ deps.recordRuntimeEvent?.("lock", failure, {
2134
+ phase: "ownership-check-failed",
2135
+ consecutiveFailures: ownershipCheckFailures,
2136
+ tolerance: TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE,
2137
+ });
2138
+ if (ownershipCheckFailures > TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE)
2139
+ stopAfterOwnershipLoss();
2140
+ };
2141
+ ownershipCheckInterval = setInterval(() => {
2142
+ // A lock-free miss can be a strict read racing an atomic replace; refresh confirms through the serialized path.
2143
+ verifyOwnership(() => deps.lock.owns(owner) || deps.lock.refresh(owner));
2113
2144
  }, ownershipCheckMs);
2114
2145
  ownershipRefreshInterval = setInterval(() => {
2115
- try {
2116
- if (deps.lock.refresh(owner)) return;
2117
- } catch (error) {
2118
- deps.recordRuntimeEvent?.("lock", error, { phase: "refresh" });
2119
- }
2120
- stopAfterOwnershipLoss();
2146
+ verifyOwnership(() => deps.lock.refresh(owner));
2121
2147
  }, ownershipRefreshMs);
2122
2148
  ownershipCheckInterval.unref?.();
2123
2149
  ownershipRefreshInterval.unref?.();
package/lib/routing.ts CHANGED
@@ -1330,6 +1330,8 @@ export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
1330
1330
  export const TELEGRAM_ROUTING_CHOICE_EXPIRED = "Routing choice expired";
1331
1331
  /** Every temporary routing tab carries this name, whether the bot created it or adopted Telegram's native tab. */
1332
1332
  const TELEGRAM_TEMPORARY_THREAD_NAME = "🚦 Routing";
1333
+ /** A mobile client's native tab creation precedes the All input it is named after by about a second. */
1334
+ const TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC = 10;
1333
1335
 
1334
1336
  export function isTelegramAllTabCommandExpired(
1335
1337
  message: { date?: number; message_thread_id?: number },
@@ -1945,6 +1947,9 @@ export function createTelegramInboundRouteRuntime<
1945
1947
  executor: Threads.TelegramWorkspaceRestoreExecutor;
1946
1948
  generation: number | undefined;
1947
1949
  scope: string | undefined;
1950
+ target: { chatId: number; threadId: number };
1951
+ name: string;
1952
+ createdAtSec: number;
1948
1953
  }
1949
1954
  >();
1950
1955
  const guidedUnboundTopicKeys = new Set<string>();
@@ -10075,6 +10080,44 @@ export function createTelegramInboundRouteRuntime<
10075
10080
  }
10076
10081
  return undefined;
10077
10082
  };
10083
+ /** A fresh implicit native tab whose name the All input starts with, observed just before it in the same exact scope. */
10084
+ const findImplicitCreationForAllInput = (
10085
+ message: TMessage,
10086
+ updateId: number,
10087
+ cap: NonNullable<ReturnType<typeof captureTemporaryThreadAuthority>>,
10088
+ ctx: TContext,
10089
+ ) => {
10090
+ const text = (message.text ?? message.caption ?? "").trim();
10091
+ let match:
10092
+ | {
10093
+ key: string;
10094
+ implicit: typeof implicitThreadCreations extends Map<string, infer V>
10095
+ ? V
10096
+ : never;
10097
+ }
10098
+ | undefined;
10099
+ for (const [key, implicit] of implicitThreadCreations) {
10100
+ if (
10101
+ implicit.name &&
10102
+ text.startsWith(implicit.name) &&
10103
+ implicit.updateId < updateId &&
10104
+ implicit.createdAtSec > 0 &&
10105
+ message.date !== undefined &&
10106
+ message.date >= implicit.createdAtSec &&
10107
+ message.date - implicit.createdAtSec <=
10108
+ TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC &&
10109
+ implicit.ctx === ctx &&
10110
+ implicit.operatorUserId === cap.operatorUserId &&
10111
+ implicit.journalBindingKey === cap.journalBindingKey &&
10112
+ isDeepStrictEqual(implicit.executor, cap.authority.executor) &&
10113
+ implicit.generation === deps.getSessionGeneration?.() &&
10114
+ implicit.scope === deps.getAdmissionScope?.() &&
10115
+ (!match || implicit.updateId > match.implicit.updateId)
10116
+ )
10117
+ match = { key, implicit };
10118
+ }
10119
+ return match;
10120
+ };
10078
10121
  /**
10079
10122
  * An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
10080
10123
  * and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
@@ -10130,6 +10173,39 @@ export function createTelegramInboundRouteRuntime<
10130
10173
  entry = found;
10131
10174
  return;
10132
10175
  }
10176
+ // Mobile clients sending from All first create a native tab named after the input, then deliver
10177
+ // the input itself to All: adopt that tab rather than leaving it behind next to a second one.
10178
+ const native = findImplicitCreationForAllInput(message, updateId, cap, ctx);
10179
+ if (native) {
10180
+ const adopted = store.registerImplicitTemporaryThread(
10181
+ { journalBindingKey, updateIds: [updateId] },
10182
+ native.implicit.target,
10183
+ randomBytes(16).toString("hex"),
10184
+ {
10185
+ ...authority,
10186
+ isCurrent: () =>
10187
+ current() &&
10188
+ implicitThreadCreations.get(native.key) === native.implicit,
10189
+ },
10190
+ );
10191
+ if (adopted && current()) {
10192
+ implicitThreadCreations.delete(native.key);
10193
+ entry = adopted;
10194
+ // Adopted native tabs share the routing name; a failed rename never blocks routing.
10195
+ try {
10196
+ await deps.callApi!("editForumTopic", {
10197
+ chat_id: native.implicit.target.chatId,
10198
+ message_thread_id: native.implicit.target.threadId,
10199
+ name: TELEGRAM_TEMPORARY_THREAD_NAME,
10200
+ });
10201
+ } catch (error) {
10202
+ deps.recordRuntimeEvent?.("routing", error, {
10203
+ phase: "temporary-thread-name",
10204
+ });
10205
+ }
10206
+ return;
10207
+ }
10208
+ }
10133
10209
  const reservation = store.reserveTemporaryThread(
10134
10210
  source,
10135
10211
  randomBytes(16).toString("hex"),
@@ -10625,6 +10701,12 @@ export function createTelegramInboundRouteRuntime<
10625
10701
  executor: structuredClone(cap.authority.executor),
10626
10702
  generation: deps.getSessionGeneration?.(),
10627
10703
  scope: deps.getAdmissionScope?.(),
10704
+ target: {
10705
+ chatId: lifecycle.target.chatId,
10706
+ threadId: lifecycle.target.threadId!,
10707
+ },
10708
+ name: "name" in created && typeof created.name === "string" ? created.name : "",
10709
+ createdAtSec: message.date ?? 0,
10628
10710
  });
10629
10711
  } else implicitThreadCreations.delete(key);
10630
10712
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.54.1",
3
+ "version": "0.54.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"