@llblab/pi-telegram 0.52.1 → 0.52.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -84,7 +84,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
84
84
 
85
85
  - Admission is journal-first: validate and persist the complete `getUpdates` response before one monotonic offset commit, then signal an independent worker without awaiting semantic execution. Missing cursor with a non-empty journal, malformed/foreign authority, or capacity exhaustion fails closed. “Durable” means process-crash recovery after atomic rename, not unflushed host/kernel/filesystem/device/power-loss survival.
86
86
  - Storage cutovers must reconcile actual consumer locations before correcting path adapters. Never normalize a relative historical reference into new authority or treat equal reference strings / empty canonical storage as source completeness. Exact-path preflight is lexical only; physical identity, historical coverage, writer closure and migration remain separate proofs.
87
- - Workspace mutations acquire cross-process admission before their shared process-local gate and hold it through asynchronous API work and durable settlement. Topic lifecycle, complete unbound/reroute target handling, manual disconnect, and session-restart cleanup use profile-wide scope; either retained retirement-fence phase rejects them before state access. Cleanup scope spans intent publication, target mutation, persistence, and transport release. Detached reconciliation that mutates Thread state must reacquire fresh profile admission through the same gate; it cannot inherit a caller lease that ended before its timer runs. A live operation ID has one process-local caller: concurrent reuse is rejected before lease acquisition, while retry after the caller exits may resume exact durable authority.
87
+ - Workspace mutations acquire cross-process admission before their shared process-local gate and hold it through asynchronous API work and durable settlement. Topic lifecycle, complete unbound/reroute target handling, manual disconnect, and session-restart cleanup use profile-wide scope; either retained retirement-fence phase rejects them before state access. Cleanup admission spans intent publication, target mutation, persistence, and successful transport release. Explicit manual disconnect may stop only its captured local transport after cleanup admission/effects fail; it must not bypass Thread mutation fences, release a replacement connection, claim unconfirmed deletion, or retry an uncertain stop. Session-restart cleanup retains its admission-required behavior. Detached reconciliation that mutates Thread state must reacquire fresh profile admission through the same gate; it cannot inherit a caller lease that ended before its timer runs. A live operation ID has one process-local caller: concurrent reuse is rejected before lease acquisition, while retry after the caller exits may resume exact durable authority.
88
88
  - Workspace retirement is capacity-pressure-only. Elapsed time and heartbeat silence never trigger deletion; only complete `A`–`Z` exhaustion may propose the oldest continuously proven inactive, fully unprotected binding. Exact deletion, durable retirement, and fence completion precede slot reuse. Authorized demand-driven rotation retries one failed fresh allocation after retirement; restore-only follower startup never evicts. Release ordinary registration/provisioning leases before acquiring the destructive fence, retain the shared mutation gate across retirement, and validate the exact permit immediately before one non-retried deletion. Persist an exact method/target-matched rejection before withdrawing its intent; release that fence only after durable withdrawal, retaining the binding. A later attempt requires fresh operation authority. Protection reads must never repair, quarantine, or reset journals. Non-destructive owner detachment must atomically retain one exact Workspace binding and its letter while removing only its uniquely matched owner record and stamping first inactivity; it never manufactures Thread-deletion evidence or clears accepted work. During authenticated follower registration, the exact Workspace claim owns the letter: a mismatched retained target record is repaired to that claim before binding commit, while any unrelated binding that owns the stale letter remains untouched. Retained prune observations are bounded, non-routing and registration/profile/epoch/runtime-fenced. One unfinished preservation operation retains its admission identity across fresh-PID-proof retries; it can never become deletion authority. Leader quit requires completed delivery/polling/worker teardown under the captured session generation, profile and epoch; reload/new/resume/fork never establish inactivity. Unknown deletion outcomes retain their fence; only confirmed durable completion permits reuse.
89
89
  - Foreign forwarding settles as `accepted`, `retryable`, or `terminal-rejected`. Only an authenticated acknowledgement carrying the expected `deliveryId` and `sourceUpdateId` releases leader journal authority. Negative, missing, stale, mismatched, or capacity-failed settlement remains durable; callback error answers are side effects only. Exception: the operator-approved fixed chooser deadline may discard its exact unclaimed donor pending source without a body archive or delivery-success claim. Accepted recipient queue/work and bound Threads are untouched; old donor controls and late reports cannot replay it. The lifetime and temporary cleanup contract lives in `docs/architecture.md`.
90
90
  - A forwarding delivery id is stable across registration replacement and derives from envelope kind, source `update_id`, and stable recipient binding. Runtime instance and registration generation remain separate attempt fences. Persisted message ownership carries the stable binding so replay can rebind only to its current authenticated registration.
package/BACKLOG.md CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  _This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
4
4
 
5
- ## Routing Lifetime External Report
5
+ ## Post-Release Transport Recovery Acceptance
6
6
 
7
- - [ ] Report the patch's precise shipped routing-lifetime scope externally only after separate authorization and verified release evidence. Do not claim a universal TTL or cleanup of historical originals lacking saved chooser clocks; native Windows destructive cleanup remains gated on strict evidence.
8
- - Evidence boundary: The operator reports passing TTL live acceptance. New continuation-reaction and callback-copy behavior has native regression evidence, not a separate live-client claim. Neither establishes native Windows Restore/destructive-cleanup support. Completed implementation is recorded in `CHANGELOG.md`; durable contracts remain in `/docs`.
7
+ - Release boundary: The operator authorized the hotfix on native regression and CI evidence; real Telegram smoke/fault acceptance remains open below, not a claimed completed check. Installing/reloading Pi or injecting live faults needs separate authorization.
8
+ - CI boundary: Native Linux/macOS/Windows checks exercise capped poll/admission backoff, preserved persistent-conflict stand-down and generation-fenced manual disconnect. Existing skipped strict journal witnesses remain a separate Windows boundary; green CI does not replace operator acceptance.
9
+ - [ ] Obtain operator acceptance for ordinary disconnect, unconfirmed-cleanup reporting and outage recovery without manual reconnect on a disposable setup. Local fake-transport/storage regressions do not certify real Telegram or platform fault behavior; installation/reload and live fault injection require separate authorization.
10
+ - Acceptance: Captured manual stop never affects a replacement connection or bypasses Thread deletion fences, uncertain stop is not retried, and accepted local work is preserved. Session-restart cleanup remains admission-required.
9
11
 
10
12
  ## Windows Reliability
11
13
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.52.2: Polling recovery, safe disconnect and Windows downloads
8
+
9
+ - `Polling recovery`: Non-conflict poll/admission failures back off from 1 to 30 seconds and keep retrying, allowing recovery after prolonged outages without manual reconnect. Backoff resets only after durable admission succeeds; persistent competing-client conflicts retain their existing terminal stand-down.
10
+ - `Disconnect safety`: Failed or unavailable Thread cleanup still attempts one captured local transport stop without bypassing Workspace fences. Reconnect revokes stale cleanup/stop continuations; delayed teardown cannot release replacement ownership. Unconfirmed cleanup is reported without claiming it was skipped, while uncertain stop/release outcomes remain incomplete and are never blindly retried ([#310](https://github.com/llblab/pi-telegram/pull/310)).
11
+ - `Windows downloads`: Publishing a complete attachment retries transient Windows `EPERM`/`EACCES` sharing failures for at most six rename attempts, without replaying HTTP. Cancellation stops the bounded wait; permanent/non-sharing failures still propagate and remove the private partial file. Concurrent downloads retain deterministic names and complete-file publication.
12
+
7
13
  ## 0.52.1: Finite chooser lifetime, continue cancellation and callback copy
8
14
 
9
15
  - `Chooser expiry`: Fixed 60-minute clocks end unselected/unacknowledged donor routing without archives or replay. Fully resolved disposable tabs use fenced one-shot cleanup; accepted queues, independent work and restored Threads survive. Confirmed expiry clears worker custody even if cleanup fails; metadata retries use body-free proof and never repeat an issued delete. Cold startup preserves deadlines, and queue admission retains selected lifetime metadata.
package/README.md CHANGED
@@ -69,6 +69,8 @@ The prompt queue is session-local. Before a reload or restart, let it drain or k
69
69
 
70
70
  Journal recovery is a separate existing loss policy, not shared-envelope recovery. A snapshot removed by older broad temp cleanup is rebuilt when its complete segment history proves an empty result, while a revisionless snapshot is repaired from the first surviving segment's exact predecessor when the reconstructed tail validates. Otherwise the damaged snapshot and segments are deleted (their inputs are lost), a fresh journal is published, and startup continues with an informational diagnostic. Unsupported journal versions block recovery without rewriting or quarantining retained files; use a compatible runtime rather than deleting journals. Saved `telegram.json` configuration and runtime diagnostics are outside that journal reset. Startup best-effort removes obsolete current-runtime root/session `recovery/` folders, so those directories are not a preservation location.
71
71
 
72
+ Ordinary network or admission failures use exponential retry pauses capped at 30 seconds, without permanently stopping after an arbitrary attempt count. The poller recovers when the outage clears. Explicit `/telegram-disconnect` still attempts to stop its captured local transport if Thread cleanup is unavailable; it reports unconfirmed cleanup rather than successful deletion, and an old disconnect cannot stop a new connection. Incomplete stop/release remains visible; check `/telegram-status --debug` and keep Pi open.
73
+
72
74
  Persistent competing `getUpdates` clients cause a bounded transport stand-down rather than endless retries. Accepted local work remains queued/executable, but Telegram delivery stops. Inspect `/telegram-status --debug`, stop the competing client, then reconnect. See [Runtime Ownership](./docs/architecture.md#runtime-ownership).
73
75
 
74
76
  ### 4. Pair your Telegram account
@@ -1551,6 +1551,7 @@ export default function (pi) {
1551
1551
  getSyncState: telegramSyncStateRuntime.getState,
1552
1552
  setSyncState: telegramSyncStateRuntime.setState,
1553
1553
  stopPolling: lockedPollingRuntime.stop,
1554
+ captureStopPolling: lockedPollingRuntime.captureStop,
1554
1555
  suspendPolling: lockedPollingRuntime.suspend,
1555
1556
  recordRuntimeEvent,
1556
1557
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
@@ -215,6 +215,11 @@ type TelegramLockedPollingStartResult = {
215
215
  export interface TelegramLockedPollingRuntime<TContext extends TelegramLockContext> {
216
216
  start: (ctx: TContext, options?: TelegramLockedPollingStartOptions) => Promise<TelegramLockedPollingStartResult>;
217
217
  stop: () => Promise<string>;
218
+ /** Capture one disconnect attempt before cleanup awaits; a newer start revokes it. */
219
+ captureStop: () => {
220
+ isCurrent: () => boolean;
221
+ stop: () => Promise<string>;
222
+ };
218
223
  suspend: () => Promise<void>;
219
224
  isSuspended: () => boolean;
220
225
  /** Fence one owned polling generation; suspension, restart, conflict or lock loss revokes it. */
package/dist/lib/locks.js CHANGED
@@ -1210,6 +1210,8 @@ export function createTelegramLockedPollingRuntime(deps) {
1210
1210
  stopOwnershipWatcher();
1211
1211
  if (sessionAutoStartRun) {
1212
1212
  await sessionAutoStartRun;
1213
+ if (generation !== pollingGeneration)
1214
+ return;
1213
1215
  deps.stopFollowerRegistration?.();
1214
1216
  }
1215
1217
  if (ownershipStop) {
@@ -1325,6 +1327,21 @@ export function createTelegramLockedPollingRuntime(deps) {
1325
1327
  const canStartPolling = (ctx) => deps.canStartPolling?.(ctx) ?? true;
1326
1328
  const formatStartBlockedMessage = (ctx) => deps.formatStartBlockedMessage?.(ctx) ??
1327
1329
  "Telegram polling is unavailable in this Pi run mode.";
1330
+ const stop = async () => {
1331
+ const generation = pollingGeneration + 1;
1332
+ await suspendPolling();
1333
+ if (generation !== pollingGeneration)
1334
+ throw new Error("Telegram disconnect was superseded by a new connection.");
1335
+ const state = deps.lock.release();
1336
+ deps.onTransportAvailabilityChanged?.();
1337
+ if (state.kind === "active-elsewhere") {
1338
+ return `Telegram bridge is active in another Pi instance (${formatTelegramLockEntry(state.lock)}).`;
1339
+ }
1340
+ if (state.kind === "stale") {
1341
+ return `Removed stale Telegram bridge lock (${formatTelegramLockEntry(state.lock)}).`;
1342
+ }
1343
+ return "Telegram bridge disconnected.";
1344
+ };
1328
1345
  return {
1329
1346
  start: async (ctx, options = {}) => {
1330
1347
  if (!deps.hasBotToken()) {
@@ -1440,17 +1457,18 @@ export function createTelegramLockedPollingRuntime(deps) {
1440
1457
  const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
1441
1458
  return { ok: true, message: `Telegram bridge connected.${staleSuffix}` };
1442
1459
  },
1443
- stop: async () => {
1444
- await suspendPolling();
1445
- const state = deps.lock.release();
1446
- deps.onTransportAvailabilityChanged?.();
1447
- if (state.kind === "active-elsewhere") {
1448
- return `Telegram bridge is active in another Pi instance (${formatTelegramLockEntry(state.lock)}).`;
1449
- }
1450
- if (state.kind === "stale") {
1451
- return `Removed stale Telegram bridge lock (${formatTelegramLockEntry(state.lock)}).`;
1452
- }
1453
- return "Telegram bridge disconnected.";
1460
+ stop,
1461
+ captureStop() {
1462
+ const generation = pollingGeneration;
1463
+ const isCurrent = () => generation === pollingGeneration;
1464
+ return {
1465
+ isCurrent,
1466
+ async stop() {
1467
+ if (!isCurrent())
1468
+ throw new Error("Telegram disconnect was superseded by a new connection.");
1469
+ return stop();
1470
+ },
1471
+ };
1454
1472
  },
1455
1473
  suspend: suspendPolling,
1456
1474
  captureTransportAuthority(ctx) {
@@ -28,6 +28,8 @@ export declare class TelegramPersistentGetUpdatesConflictError extends Error {
28
28
  readonly count: number;
29
29
  constructor(count: number);
30
30
  }
31
+ /** Exponential backoff for non-conflict polling/admission failures. */
32
+ export declare function getTelegramPollingRetryDelayMs(consecutiveFailures: number): number;
31
33
  export declare class TelegramGetUpdatesTimeoutError extends Error {
32
34
  readonly timeoutMs: number;
33
35
  constructor(timeoutMs: number);
@@ -14,7 +14,8 @@ export const TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT = 10;
14
14
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
15
15
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
16
16
  const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
17
- const TELEGRAM_POLLING_RETRY_MS = 3_000;
17
+ const TELEGRAM_POLLING_RETRY_BASE_MS = 1_000;
18
+ const TELEGRAM_POLLING_RETRY_MAX_MS = 30_000;
18
19
  const TELEGRAM_GET_UPDATES_GRACE_MS = 10_000;
19
20
  // Standard Telegram DM polling does not expose ordinary message-deletion events.
20
21
  // Business deletions belong to a separate namespace and default routing ignores them.
@@ -51,6 +52,11 @@ export class TelegramPersistentGetUpdatesConflictError extends Error {
51
52
  this.count = count;
52
53
  }
53
54
  }
55
+ /** Exponential backoff for non-conflict polling/admission failures. */
56
+ export function getTelegramPollingRetryDelayMs(consecutiveFailures) {
57
+ const exponent = Math.max(0, Math.min(consecutiveFailures - 1, 16));
58
+ return Math.min(TELEGRAM_POLLING_RETRY_BASE_MS * 2 ** exponent, TELEGRAM_POLLING_RETRY_MAX_MS);
59
+ }
54
60
  export class TelegramGetUpdatesTimeoutError extends Error {
55
61
  timeoutMs;
56
62
  constructor(timeoutMs) {
@@ -1103,6 +1109,7 @@ export async function runTelegramPollLoop(deps) {
1103
1109
  if (!deps.config.botToken)
1104
1110
  return;
1105
1111
  let consecutiveGetUpdatesConflicts = 0;
1112
+ let consecutiveFailures = 0;
1106
1113
  const retryConflict = async () => {
1107
1114
  consecutiveGetUpdatesConflicts += 1;
1108
1115
  if (consecutiveGetUpdatesConflicts >= TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT) {
@@ -1171,6 +1178,7 @@ export async function runTelegramPollLoop(deps) {
1171
1178
  onPhaseChange: deps.onPhaseChange,
1172
1179
  recordRuntimeEvent: deps.recordRuntimeEvent,
1173
1180
  });
1181
+ consecutiveFailures = 0;
1174
1182
  currentUpdateId = undefined;
1175
1183
  }
1176
1184
  catch (error) {
@@ -1190,8 +1198,10 @@ export async function runTelegramPollLoop(deps) {
1190
1198
  : {}),
1191
1199
  });
1192
1200
  consecutiveGetUpdatesConflicts = 0;
1201
+ consecutiveFailures += 1;
1193
1202
  deps.onErrorStatus(getTelegramPollingErrorMessage(error));
1194
- await deps.sleep(TELEGRAM_POLLING_RETRY_MS, deps.signal);
1203
+ // Cap the pause, not retry count: outages must recover without manual reconnect.
1204
+ await deps.sleep(getTelegramPollingRetryDelayMs(consecutiveFailures), deps.signal);
1195
1205
  if (deps.signal.aborted)
1196
1206
  return;
1197
1207
  deps.onStatusReset();
@@ -92,6 +92,7 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
92
92
  recordRuntimeEvent: (category: string, error: unknown, details?: Record<string, unknown>) => void;
93
93
  runWorkspaceOperation: TelegramSyncWorkspaceOperationRunner;
94
94
  workspaceOperationKind?: string;
95
+ isDisconnectCurrent?: () => boolean;
95
96
  getNowMs?: () => number;
96
97
  }
97
98
  export declare function createTelegramPreservedLeaderQuitHandler(deps: {
@@ -112,6 +113,10 @@ export interface TelegramThreadDisconnectAssembly {
112
113
  }
113
114
  export declare function createTelegramThreadDisconnectAssembly<TSyncState extends TelegramSyncState>(deps: Omit<TelegramManualThreadDisconnectDeps<TSyncState>, "stopPolling"> & {
114
115
  stopPolling: () => Promise<string>;
116
+ captureStopPolling?: () => {
117
+ isCurrent: () => boolean;
118
+ stop: () => Promise<string>;
119
+ };
115
120
  suspendPolling: () => Promise<void>;
116
121
  }): TelegramThreadDisconnectAssembly;
117
122
  export declare function createTelegramManualThreadDisconnectHandler<TSyncState extends TelegramSyncState>(deps: TelegramManualThreadDisconnectDeps<TSyncState>): () => Promise<string>;
package/dist/lib/sync.js CHANGED
@@ -74,11 +74,41 @@ function createTelegramSessionRestartThreadCleanupHandler(deps) {
74
74
  }
75
75
  export function createTelegramThreadDisconnectAssembly(deps) {
76
76
  return {
77
- disconnect: createTelegramManualThreadDisconnectHandler({
78
- ...deps,
79
- workspaceOperationKind: "workspace.disconnect-thread",
80
- stopPolling: deps.stopPolling,
81
- }),
77
+ async disconnect() {
78
+ const captured = deps.captureStopPolling?.();
79
+ const stop = captured?.stop ?? deps.stopPolling;
80
+ let stopAttempted = false;
81
+ const disconnect = createTelegramManualThreadDisconnectHandler({
82
+ ...deps,
83
+ workspaceOperationKind: "workspace.disconnect-thread",
84
+ isDisconnectCurrent: captured?.isCurrent ?? deps.isDisconnectCurrent,
85
+ stopPolling() {
86
+ stopAttempted = true;
87
+ return stop();
88
+ },
89
+ });
90
+ try {
91
+ return await disconnect();
92
+ }
93
+ catch (error) {
94
+ // Never retry a stop whose teardown/release outcome may already have committed.
95
+ if (stopAttempted) {
96
+ deps.recordRuntimeEvent("connection", error, { phase: "disconnect-stop" });
97
+ throw error;
98
+ }
99
+ deps.recordRuntimeEvent("connection", error, { phase: "disconnect-cleanup" });
100
+ let stopped;
101
+ try {
102
+ stopped = await stop();
103
+ }
104
+ catch (stopError) {
105
+ deps.recordRuntimeEvent("connection", stopError, { phase: "disconnect-stop" });
106
+ throw stopError;
107
+ }
108
+ const separator = /[.!?]$/u.test(stopped) ? " " : ". ";
109
+ return `${stopped}${separator}Thread cleanup was not confirmed. Check /telegram-status --debug.`;
110
+ }
111
+ },
82
112
  cleanupForSessionRestart: createTelegramSessionRestartThreadCleanupHandler({
83
113
  ...deps,
84
114
  suspendPolling: deps.suspendPolling,
@@ -86,7 +116,12 @@ export function createTelegramThreadDisconnectAssembly(deps) {
86
116
  };
87
117
  }
88
118
  export function createTelegramManualThreadDisconnectHandler(deps) {
119
+ const assertCurrent = () => {
120
+ if (deps.isDisconnectCurrent?.() === false)
121
+ throw new Error("Telegram disconnect was superseded by a new connection.");
122
+ };
89
123
  const operation = async () => {
124
+ assertCurrent();
90
125
  const currentRecord = deps.getCurrentThreadRecord();
91
126
  let cleanupPending = false;
92
127
  if (currentRecord?.target.threadId) {
@@ -98,6 +133,7 @@ export function createTelegramManualThreadDisconnectHandler(deps) {
98
133
  if (isManualFollower && !ownsLeader) {
99
134
  if (deps.disconnectFollowerThread) {
100
135
  const disconnected = await deps.disconnectFollowerThread();
136
+ assertCurrent();
101
137
  if (!disconnected) {
102
138
  throw new Error("Telegram follower thread deletion requires a live leader registration.");
103
139
  }
@@ -122,6 +158,7 @@ export function createTelegramManualThreadDisconnectHandler(deps) {
122
158
  const isCleanupTargetProtected = createTelegramCleanupTargetProtection(deps.topicTargetStore, departingRecord);
123
159
  deps.topicTargetStore.upsertPendingCleanup(intent);
124
160
  await deps.topicTargetStore.persist();
161
+ assertCurrent();
125
162
  const cleanupPlan = ThreadReconciler.planThreadReconciliation({
126
163
  nowMs: (deps.getNowMs ?? Date.now)(),
127
164
  currentLeaderEpoch: leaderEpoch,
@@ -131,20 +168,25 @@ export function createTelegramManualThreadDisconnectHandler(deps) {
131
168
  const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(cleanupPlan, {
132
169
  isCleanupTargetProtected,
133
170
  callApi(method, body) {
171
+ assertCurrent();
134
172
  return deps.callApi(method, body);
135
173
  },
136
174
  markStaleByTarget(targetToMark, syncStatus, lastSyncError) {
175
+ assertCurrent();
137
176
  return deps.topicTargetStore.markStaleByTarget(targetToMark, syncStatus, lastSyncError);
138
177
  },
139
178
  removeCleanupIntentById(id) {
179
+ assertCurrent();
140
180
  return deps.topicTargetStore.removePendingCleanup(id);
141
181
  },
142
182
  persist() {
183
+ assertCurrent();
143
184
  return deps.topicTargetStore.persist();
144
185
  },
145
186
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
146
187
  recordRuntimeEvent: deps.recordRuntimeEvent,
147
188
  });
189
+ assertCurrent();
148
190
  if (cleanupPlan.actions.some((action) => isCleanupTargetProtected(action.target, action))) {
149
191
  return "Thread disconnect superseded by a new binding.";
150
192
  }
@@ -160,6 +202,7 @@ export function createTelegramManualThreadDisconnectHandler(deps) {
160
202
  action: "manual-disconnect",
161
203
  }));
162
204
  }
205
+ assertCurrent();
163
206
  const stopped = await deps.stopPolling();
164
207
  return cleanupPending
165
208
  ? `${stopped} Telegram thread cleanup remains pending for the next leader.`
@@ -30,6 +30,7 @@ function getTelegramApiTempDir() {
30
30
  return resolveTelegramAttachmentsDir();
31
31
  }
32
32
  const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
33
+ const TELEGRAM_DOWNLOAD_RENAME_MAX_ATTEMPTS = 6;
33
34
  const activeTelegramApiWorkspaceAdmissionOperationIds = new Set();
34
35
  const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(process.env, ["PI_TELEGRAM_INBOUND_FILE_MAX_BYTES", "TELEGRAM_MAX_FILE_SIZE_BYTES"], TELEGRAM_FILE_MAX_BYTES);
35
36
  const TELEGRAM_NETWORK_FAMILY_ENV = "PI_TELEGRAM_NETWORK_FAMILY";
@@ -391,6 +392,22 @@ async function writeTelegramDownloadResponse(response, targetPath, maxFileSizeBy
391
392
  }
392
393
  await pipeline(Readable.from(response.body, { objectMode: false }), createTelegramDownloadLimitTransform(maxFileSizeBytes), createWriteStream(targetPath, { mode: 0o600 }));
393
394
  }
395
+ async function publishTelegramDownload(partPath, targetPath, signal) {
396
+ for (let attempt = 0;; attempt++) {
397
+ throwIfTelegramApiCallAborted(signal);
398
+ try {
399
+ await rename(partPath, targetPath);
400
+ return;
401
+ }
402
+ catch (error) {
403
+ const code = error?.code;
404
+ // Windows may briefly deny replacement while another publisher/scanner holds the file.
405
+ if (process.platform !== "win32" || (code !== "EPERM" && code !== "EACCES") || attempt + 1 >= TELEGRAM_DOWNLOAD_RENAME_MAX_ATTEMPTS)
406
+ throw error;
407
+ await sleepTelegramRetry(50 * 2 ** attempt, signal);
408
+ }
409
+ }
410
+ }
394
411
  async function removeTelegramPartialDownload(path) {
395
412
  try {
396
413
  await unlink(path);
@@ -787,7 +804,7 @@ export async function downloadTelegramFile(botToken, fileId, suggestedName, temp
787
804
  assertTelegramFileSizeWithinLimit(contentLength ? Number.parseInt(contentLength, 10) : undefined, options?.maxFileSizeBytes);
788
805
  try {
789
806
  await writeTelegramDownloadResponse(response, partPath, options?.maxFileSizeBytes);
790
- await rename(partPath, targetPath);
807
+ await publishTelegramDownload(partPath, targetPath, options?.signal);
791
808
  }
792
809
  catch (error) {
793
810
  await removeTelegramPartialDownload(partPath);
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.52.1",
3
+ "version": "0.52.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -334,10 +334,12 @@ Read-only migration probes constrain that comparison. Publishing a v2 snapshot o
334
334
 
335
335
  ### Runtime Ownership
336
336
 
337
- - `/telegram-connect` acquires or moves the active profile's owner slot before polling starts. `/telegram-disconnect` keeps its destructive confirmation, then stops polling and releases only that exact slot. In Threaded Mode it tears down the disconnecting instance's bound Telegram thread: leaders delete their own thread directly, and followers send an authenticated exact-generation disconnect envelope and wait for confirmed leader cleanup before unregistering. Graceful Pi `quit` always preserves the owner slot as restart intent, allowing a reopened same-`cwd` session to reclaim the stale lease. When `threads.automaticCleanup` is enabled (the default), quit also deletes the bound Telegram tab without releasing that restart intent; disabling it preserves the tab. After delivery, polling and inbound-worker shutdown complete, a quitting leader detaches its exact owner record under profile admission while retaining the Workspace, letter and restart ownership. Publication rechecks the captured session generation, profile, leader epoch and successful current-generation suspension; unfinished startup/teardown, unproven suspension and pending cleanup stay protected. Preparation/publication errors are diagnostic and cannot block remaining session cleanup. Failed automatic deletion likewise falls back to safe suspension.
337
+ - `/telegram-connect` acquires or moves the active profile's owner slot before polling starts. `/telegram-disconnect` keeps its destructive confirmation, then stops polling and releases only that exact slot. In Threaded Mode it tears down the disconnecting instance's bound Telegram thread: leaders delete their own thread directly, and followers send an authenticated exact-generation disconnect envelope seeking confirmed leader cleanup before unregistering. If cleanup fails, explicit manual disconnect may instead stop its captured local transport without claiming Thread deletion. Graceful Pi `quit` always preserves the owner slot as restart intent, allowing a reopened same-`cwd` session to reclaim the stale lease. When `threads.automaticCleanup` is enabled (the default), quit also deletes the bound Telegram tab without releasing that restart intent; disabling it preserves the tab. After delivery, polling and inbound-worker shutdown complete, a quitting leader detaches its exact owner record under profile admission while retaining the Workspace, letter and restart ownership. Publication rechecks the captured session generation, profile, leader epoch and successful current-generation suspension; unfinished startup/teardown, unproven suspension and pending cleanup stay protected. Preparation/publication errors are diagnostic and cannot block remaining session cleanup. Failed automatic deletion likewise falls back to safe suspension.
338
+ - Explicit manual disconnect captures one lifecycle-generation-bound stop before entering cleanup admission. The cleanup path rechecks that captured authority before observing a target, after intent publication, before API/store effects and after awaited reconciliation. A newer connection revokes old cleanup and fallback stop; teardown also checks its generation after suspension before durable lock release. Cleanup admission failure may trigger only the captured local stop, not an unguarded latest-runtime stop or new Thread mutations. Failed cleanup reports unconfirmed outcome with debug guidance rather than claiming deletion was skipped or exposing raw errors. A stop/release error propagates as incomplete without another teardown attempt. Session-restart cleanup remains fail-closed behind admission fences and never uses this manual fallback.
338
339
  - Without an explicit carried connection intent, session start schedules polling resume asynchronously only when the owner slot already points at the current `pid`/`cwd`, or when a stale same-`cwd` owner can be safely replaced after process restart. Under a live foreign leader, startup instead attempts capability-gated restore-only follower admission when the selected profile has a remembered exact-`cwd` Workspace binding; it never provisions an unremembered Workspace. Startup and `/resume` do not wait on leader election, Bot API probes, poller handoff, or thread reconciliation before restoring the Pi session.
339
340
  - On local `/resume`, an in-flight `/telegram-connect` or established connection supplies one process-local, 30-second intent containing the selected profile, exact CWD and target session file. The file matches only the lifecycle successor; the fresh public session ID still owns binding selection. Startup claims intent before asynchronous initialization and uses the normal connection path instead of concurrent auto-restore. Quit/reload/disconnect, mismatch, expiry or supersession cancel it. The predecessor carries no Pi context, callback, Thread or slot, and late completion can clear only its own attempt. Resume startup has no automatic forced takeover or repeated failure retry; errors remain diagnostic with a compact recovery notice.
340
341
  - The polling owner alone bounds `getUpdates`: each request derives its cancellation budget from Telegram's declared long-poll timeout plus 10 seconds of transport grace (10 seconds for the zero-timeout initial sync and 40 seconds for the normal 30-second poll). The request-local controller inherits poller cancellation, rejects its owner at the budget, and fences any late transport result. Ordinary Bot API and media operations do not receive speculative blanket deadlines. Existing caller signals remain authoritative through API retry waits, only retry-safe methods replay explicit retryable responses, and non-idempotent sends preserve commit-unknown evidence instead of risking duplicate mutation.
342
+ - Ordinary non-conflict poll/admission failures retry with exponential pauses of `1s → 2s → 4s → 8s → 16s → 30s`, capped at 30 seconds without a retry-count ceiling. Only successful durable admission resets this backoff; a successful HTTP response followed by journal failure does not. Retry sleeps remain poller-abortable, and accepted inputs/cursor ordering are unchanged. Outages therefore recover through the existing poller without manual reconnect.
341
343
  - Ten consecutive `getUpdates` conflict responses, including initial cursor sync, terminate polling with `persistent-conflict`; a successful response or a different error resets the count. The controller detaches its inner promise before notifying the locked lifecycle, avoiding teardown waiting on itself. That lifecycle stops ownership checks, lease refresh, capability monitoring, typing, and classic or bus transport (including leader health, pruning, and IPC), withdraws local direct authority, and transactionally releases only its exact lock. A failed durable release leaves local authority revoked; explicit reacquisition mints a fresh epoch. Accepted queue receipts and local Pi dispatch survive. One terminal diagnostic distinguishes lost local ownership from a competing client despite an apparently owned lock and reports cleanup failures; ordinary status refreshes retain generic `error` until transport recovers. Remove the competing client, then use `/telegram-connect`; no automatic retry continues after the terminal threshold.
342
344
  - Manual and automatic polling starts share a lifecycle generation. A later suspend, disconnect, persistent-conflict stop, or accepted start invalidates older startup continuations; reconnect captures its generation before waiting for transport teardown and rechecks it before acquiring ownership and after awaited startup work. Obsolete completion or failure cannot report a successful connection or roll back a replacement. Admission rejects stale/unauthorized contexts before advancing its own startup generation, so a rejected call cannot cancel valid in-flight initialization. After awaited bus startup, thread-aware completion checks its generation before starting leader health or changing fallback/startup-option state; teardown clears health independently of the current mode flag. Startup probes, capability-monitor transitions, and observed-target transitions share an orchestration lifecycle fence and check it inside their effect-owning helpers after awaited queries, persistence, or transport work. Monitor stop also invalidates pending observations. These checks suppress subsequent state changes, fallback, health, and status effects; already-issued API/persistence calls retain their own transport/storage fencing.
343
345
  - `pollingActive` reports only whether this runtime still owns an unresolved polling lifecycle; it is not health evidence. A separate observable state records `starting`, `long-poll`, `persisting-journal`, `persisting-offset`, `retrying`, or `stopped`, together with phase start, current update id, last successful response time/count, and terminal stop reason. This distinguishes a stuck HTTP poll from downstream update work without a wall-clock stale heuristic.
@@ -521,6 +523,8 @@ All inbound updates are gated by the configured authorized user id.
521
523
  8. Remove local prompt journal authority synchronously immediately before `sendUserMessage`, so session/process replacement can lose an unstarted prompt at that narrow crash boundary but can never replay a prompt already admitted to Pi; controls and foreign forwarding retain their explicit settlement boundaries.
522
524
  9. Handle `edited_message` updates separately while the original turn is still queued and dispatch only when all safety gates are clear.
523
525
 
526
+ Attachments stream into private unique `.part` files and publish by rename only after complete, size-checked download. Windows `EPERM`/`EACCES` sharing failures allow at most six rename attempts, with abortable `50/100/200/400/800 ms` waits; no metadata/content HTTP request is replayed. Non-Windows, non-sharing and exhausted errors propagate, cancellation stops retries, and failure removes the partial file without deleting the existing target. This is attachment publication policy, not a journal/state recovery rule or strict Windows evidence.
527
+
524
528
  #### Durable Admission And Recovery
525
529
 
526
530
  Here, **durable** means recovery across ordinary process exit, crash, kill, and replacement after a successful atomic rename is visible to the filesystem. It does not promise survival across host, kernel, filesystem, storage-device, or power failure: journal and offset publication do not call `fsync`/`fdatasync`, and parent directories are not flushed. A host-level failure may therefore lose a recently acknowledged rename despite correct process-level ordering. Operators requiring that stronger boundary must place the agent directory on storage with an independently managed durability/backup policy; `0.28.0` must not be described as power-loss durable.
package/lib/extension.ts CHANGED
@@ -1818,6 +1818,7 @@ export default function (pi: Pi.ExtensionAPI) {
1818
1818
  getSyncState: telegramSyncStateRuntime.getState,
1819
1819
  setSyncState: telegramSyncStateRuntime.setState,
1820
1820
  stopPolling: lockedPollingRuntime.stop,
1821
+ captureStopPolling: lockedPollingRuntime.captureStop,
1821
1822
  suspendPolling: lockedPollingRuntime.suspend,
1822
1823
  recordRuntimeEvent,
1823
1824
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
package/lib/locks.ts CHANGED
@@ -1489,6 +1489,8 @@ export interface TelegramLockedPollingRuntime<
1489
1489
  options?: TelegramLockedPollingStartOptions,
1490
1490
  ) => Promise<TelegramLockedPollingStartResult>;
1491
1491
  stop: () => Promise<string>;
1492
+ /** Capture one disconnect attempt before cleanup awaits; a newer start revokes it. */
1493
+ captureStop: () => { isCurrent: () => boolean; stop: () => Promise<string> };
1492
1494
  suspend: () => Promise<void>;
1493
1495
  isSuspended: () => boolean;
1494
1496
  /** Fence one owned polling generation; suspension, restart, conflict or lock loss revokes it. */
@@ -1582,6 +1584,7 @@ export function createTelegramLockedPollingRuntime<
1582
1584
  stopOwnershipWatcher();
1583
1585
  if (sessionAutoStartRun) {
1584
1586
  await sessionAutoStartRun;
1587
+ if (generation !== pollingGeneration) return;
1585
1588
  deps.stopFollowerRegistration?.();
1586
1589
  }
1587
1590
  if (ownershipStop) {
@@ -1687,6 +1690,20 @@ export function createTelegramLockedPollingRuntime<
1687
1690
  const formatStartBlockedMessage = (ctx: TContext): string =>
1688
1691
  deps.formatStartBlockedMessage?.(ctx) ??
1689
1692
  "Telegram polling is unavailable in this Pi run mode.";
1693
+ const stop = async (): Promise<string> => {
1694
+ const generation = pollingGeneration + 1;
1695
+ await suspendPolling();
1696
+ if (generation !== pollingGeneration) throw new Error("Telegram disconnect was superseded by a new connection.");
1697
+ const state = deps.lock.release();
1698
+ deps.onTransportAvailabilityChanged?.();
1699
+ if (state.kind === "active-elsewhere") {
1700
+ return `Telegram bridge is active in another Pi instance (${formatTelegramLockEntry(state.lock)}).`;
1701
+ }
1702
+ if (state.kind === "stale") {
1703
+ return `Removed stale Telegram bridge lock (${formatTelegramLockEntry(state.lock)}).`;
1704
+ }
1705
+ return "Telegram bridge disconnected.";
1706
+ };
1690
1707
  return {
1691
1708
  start: async (ctx, options = {}) => {
1692
1709
  if (!deps.hasBotToken()) {
@@ -1798,17 +1815,17 @@ export function createTelegramLockedPollingRuntime<
1798
1815
  const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
1799
1816
  return { ok: true, message: `Telegram bridge connected.${staleSuffix}` };
1800
1817
  },
1801
- stop: async () => {
1802
- await suspendPolling();
1803
- const state = deps.lock.release();
1804
- deps.onTransportAvailabilityChanged?.();
1805
- if (state.kind === "active-elsewhere") {
1806
- return `Telegram bridge is active in another Pi instance (${formatTelegramLockEntry(state.lock)}).`;
1807
- }
1808
- if (state.kind === "stale") {
1809
- return `Removed stale Telegram bridge lock (${formatTelegramLockEntry(state.lock)}).`;
1810
- }
1811
- return "Telegram bridge disconnected.";
1818
+ stop,
1819
+ captureStop() {
1820
+ const generation = pollingGeneration;
1821
+ const isCurrent = () => generation === pollingGeneration;
1822
+ return {
1823
+ isCurrent,
1824
+ async stop() {
1825
+ if (!isCurrent()) throw new Error("Telegram disconnect was superseded by a new connection.");
1826
+ return stop();
1827
+ },
1828
+ };
1812
1829
  },
1813
1830
  suspend: suspendPolling,
1814
1831
  captureTransportAuthority(ctx) {
package/lib/polling.ts CHANGED
@@ -25,7 +25,8 @@ export const TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT = 10;
25
25
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
26
26
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
27
27
  const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
28
- const TELEGRAM_POLLING_RETRY_MS = 3_000;
28
+ const TELEGRAM_POLLING_RETRY_BASE_MS = 1_000;
29
+ const TELEGRAM_POLLING_RETRY_MAX_MS = 30_000;
29
30
  const TELEGRAM_GET_UPDATES_GRACE_MS = 10_000;
30
31
 
31
32
  // Standard Telegram DM polling does not expose ordinary message-deletion events.
@@ -79,6 +80,17 @@ export class TelegramPersistentGetUpdatesConflictError extends Error {
79
80
  }
80
81
  }
81
82
 
83
+ /** Exponential backoff for non-conflict polling/admission failures. */
84
+ export function getTelegramPollingRetryDelayMs(
85
+ consecutiveFailures: number,
86
+ ): number {
87
+ const exponent = Math.max(0, Math.min(consecutiveFailures - 1, 16));
88
+ return Math.min(
89
+ TELEGRAM_POLLING_RETRY_BASE_MS * 2 ** exponent,
90
+ TELEGRAM_POLLING_RETRY_MAX_MS,
91
+ );
92
+ }
93
+
82
94
  export class TelegramGetUpdatesTimeoutError extends Error {
83
95
  readonly timeoutMs: number;
84
96
 
@@ -1709,6 +1721,7 @@ export async function runTelegramPollLoop<
1709
1721
  >(deps: TelegramPollLoopDeps<TUpdate, TContext>): Promise<void> {
1710
1722
  if (!deps.config.botToken) return;
1711
1723
  let consecutiveGetUpdatesConflicts = 0;
1724
+ let consecutiveFailures = 0;
1712
1725
  const retryConflict = async () => {
1713
1726
  consecutiveGetUpdatesConflicts += 1;
1714
1727
  if (consecutiveGetUpdatesConflicts >= TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT) {
@@ -1791,6 +1804,7 @@ export async function runTelegramPollLoop<
1791
1804
  onPhaseChange: deps.onPhaseChange,
1792
1805
  recordRuntimeEvent: deps.recordRuntimeEvent,
1793
1806
  });
1807
+ consecutiveFailures = 0;
1794
1808
  currentUpdateId = undefined;
1795
1809
  } catch (error) {
1796
1810
  if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
@@ -1809,8 +1823,13 @@ export async function runTelegramPollLoop<
1809
1823
  : {}),
1810
1824
  });
1811
1825
  consecutiveGetUpdatesConflicts = 0;
1826
+ consecutiveFailures += 1;
1812
1827
  deps.onErrorStatus(getTelegramPollingErrorMessage(error));
1813
- await deps.sleep(TELEGRAM_POLLING_RETRY_MS, deps.signal);
1828
+ // Cap the pause, not retry count: outages must recover without manual reconnect.
1829
+ await deps.sleep(
1830
+ getTelegramPollingRetryDelayMs(consecutiveFailures),
1831
+ deps.signal,
1832
+ );
1814
1833
  if (deps.signal.aborted) return;
1815
1834
  deps.onStatusReset();
1816
1835
  }
package/lib/sync.ts CHANGED
@@ -176,6 +176,7 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
176
176
  ) => void;
177
177
  runWorkspaceOperation: TelegramSyncWorkspaceOperationRunner;
178
178
  workspaceOperationKind?: string;
179
+ isDisconnectCurrent?: () => boolean;
179
180
  getNowMs?: () => number;
180
181
  }
181
182
 
@@ -270,15 +271,44 @@ export function createTelegramThreadDisconnectAssembly<
270
271
  >(
271
272
  deps: Omit<TelegramManualThreadDisconnectDeps<TSyncState>, "stopPolling"> & {
272
273
  stopPolling: () => Promise<string>;
274
+ captureStopPolling?: () => { isCurrent: () => boolean; stop: () => Promise<string> };
273
275
  suspendPolling: () => Promise<void>;
274
276
  },
275
277
  ): TelegramThreadDisconnectAssembly {
276
278
  return {
277
- disconnect: createTelegramManualThreadDisconnectHandler({
278
- ...deps,
279
- workspaceOperationKind: "workspace.disconnect-thread",
280
- stopPolling: deps.stopPolling,
281
- }),
279
+ async disconnect() {
280
+ const captured = deps.captureStopPolling?.();
281
+ const stop = captured?.stop ?? deps.stopPolling;
282
+ let stopAttempted = false;
283
+ const disconnect = createTelegramManualThreadDisconnectHandler({
284
+ ...deps,
285
+ workspaceOperationKind: "workspace.disconnect-thread",
286
+ isDisconnectCurrent: captured?.isCurrent ?? deps.isDisconnectCurrent,
287
+ stopPolling() {
288
+ stopAttempted = true;
289
+ return stop();
290
+ },
291
+ });
292
+ try {
293
+ return await disconnect();
294
+ } catch (error) {
295
+ // Never retry a stop whose teardown/release outcome may already have committed.
296
+ if (stopAttempted) {
297
+ deps.recordRuntimeEvent("connection", error, { phase: "disconnect-stop" });
298
+ throw error;
299
+ }
300
+ deps.recordRuntimeEvent("connection", error, { phase: "disconnect-cleanup" });
301
+ let stopped: string;
302
+ try {
303
+ stopped = await stop();
304
+ } catch (stopError) {
305
+ deps.recordRuntimeEvent("connection", stopError, { phase: "disconnect-stop" });
306
+ throw stopError;
307
+ }
308
+ const separator = /[.!?]$/u.test(stopped) ? " " : ". ";
309
+ return `${stopped}${separator}Thread cleanup was not confirmed. Check /telegram-status --debug.`;
310
+ }
311
+ },
282
312
  cleanupForSessionRestart:
283
313
  createTelegramSessionRestartThreadCleanupHandler({
284
314
  ...deps,
@@ -290,7 +320,11 @@ export function createTelegramThreadDisconnectAssembly<
290
320
  export function createTelegramManualThreadDisconnectHandler<
291
321
  TSyncState extends TelegramSyncState,
292
322
  >(deps: TelegramManualThreadDisconnectDeps<TSyncState>): () => Promise<string> {
323
+ const assertCurrent = () => {
324
+ if (deps.isDisconnectCurrent?.() === false) throw new Error("Telegram disconnect was superseded by a new connection.");
325
+ };
293
326
  const operation = async (): Promise<string> => {
327
+ assertCurrent();
294
328
  const currentRecord = deps.getCurrentThreadRecord();
295
329
  let cleanupPending = false;
296
330
  if (currentRecord?.target.threadId) {
@@ -302,6 +336,7 @@ export function createTelegramManualThreadDisconnectHandler<
302
336
  if (isManualFollower && !ownsLeader) {
303
337
  if (deps.disconnectFollowerThread) {
304
338
  const disconnected = await deps.disconnectFollowerThread();
339
+ assertCurrent();
305
340
  if (!disconnected) {
306
341
  throw new Error(
307
342
  "Telegram follower thread deletion requires a live leader registration.",
@@ -329,6 +364,7 @@ export function createTelegramManualThreadDisconnectHandler<
329
364
  const isCleanupTargetProtected = createTelegramCleanupTargetProtection(deps.topicTargetStore, departingRecord);
330
365
  deps.topicTargetStore.upsertPendingCleanup(intent);
331
366
  await deps.topicTargetStore.persist();
367
+ assertCurrent();
332
368
  const cleanupPlan = ThreadReconciler.planThreadReconciliation({
333
369
  nowMs: (deps.getNowMs ?? Date.now)(),
334
370
  currentLeaderEpoch: leaderEpoch,
@@ -340,9 +376,11 @@ export function createTelegramManualThreadDisconnectHandler<
340
376
  {
341
377
  isCleanupTargetProtected,
342
378
  callApi(method, body) {
379
+ assertCurrent();
343
380
  return deps.callApi(method, body);
344
381
  },
345
382
  markStaleByTarget(targetToMark, syncStatus, lastSyncError) {
383
+ assertCurrent();
346
384
  return deps.topicTargetStore.markStaleByTarget(
347
385
  targetToMark,
348
386
  syncStatus,
@@ -350,15 +388,18 @@ export function createTelegramManualThreadDisconnectHandler<
350
388
  );
351
389
  },
352
390
  removeCleanupIntentById(id) {
391
+ assertCurrent();
353
392
  return deps.topicTargetStore.removePendingCleanup(id);
354
393
  },
355
394
  persist() {
395
+ assertCurrent();
356
396
  return deps.topicTargetStore.persist();
357
397
  },
358
398
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
359
399
  recordRuntimeEvent: deps.recordRuntimeEvent,
360
400
  },
361
401
  );
402
+ assertCurrent();
362
403
  if (cleanupPlan.actions.some((action) => isCleanupTargetProtected(action.target, action))) {
363
404
  return "Thread disconnect superseded by a new binding.";
364
405
  }
@@ -378,6 +419,7 @@ export function createTelegramManualThreadDisconnectHandler<
378
419
  }) as TSyncState,
379
420
  );
380
421
  }
422
+ assertCurrent();
381
423
  const stopped = await deps.stopPolling();
382
424
  return cleanupPending
383
425
  ? `${stopped} Telegram thread cleanup remains pending for the next leader.`
@@ -38,6 +38,7 @@ function getTelegramApiTempDir(): string {
38
38
  return resolveTelegramAttachmentsDir();
39
39
  }
40
40
  const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
41
+ const TELEGRAM_DOWNLOAD_RENAME_MAX_ATTEMPTS = 6;
41
42
  const activeTelegramApiWorkspaceAdmissionOperationIds = new Set<string>();
42
43
  const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(
43
44
  process.env,
@@ -1106,6 +1107,25 @@ async function writeTelegramDownloadResponse(
1106
1107
  );
1107
1108
  }
1108
1109
 
1110
+ async function publishTelegramDownload(
1111
+ partPath: string,
1112
+ targetPath: string,
1113
+ signal?: AbortSignal,
1114
+ ): Promise<void> {
1115
+ for (let attempt = 0; ; attempt++) {
1116
+ throwIfTelegramApiCallAborted(signal);
1117
+ try {
1118
+ await rename(partPath, targetPath);
1119
+ return;
1120
+ } catch (error) {
1121
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
1122
+ // Windows may briefly deny replacement while another publisher/scanner holds the file.
1123
+ if (process.platform !== "win32" || (code !== "EPERM" && code !== "EACCES") || attempt + 1 >= TELEGRAM_DOWNLOAD_RENAME_MAX_ATTEMPTS) throw error;
1124
+ await sleepTelegramRetry(50 * 2 ** attempt, signal);
1125
+ }
1126
+ }
1127
+ }
1128
+
1109
1129
  async function removeTelegramPartialDownload(path: string): Promise<void> {
1110
1130
  try {
1111
1131
  await unlink(path);
@@ -1693,7 +1713,7 @@ export async function downloadTelegramFile(
1693
1713
  partPath,
1694
1714
  options?.maxFileSizeBytes,
1695
1715
  );
1696
- await rename(partPath, targetPath);
1716
+ await publishTelegramDownload(partPath, targetPath, options?.signal);
1697
1717
  } catch (error) {
1698
1718
  await removeTelegramPartialDownload(partPath);
1699
1719
  throw error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.52.1",
3
+ "version": "0.52.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"