@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 +1 -1
- package/BACKLOG.md +5 -3
- package/CHANGELOG.md +6 -0
- package/README.md +2 -0
- package/dist/lib/extension.js +1 -0
- package/dist/lib/locks.d.ts +5 -0
- package/dist/lib/locks.js +29 -11
- package/dist/lib/polling.d.ts +2 -0
- package/dist/lib/polling.js +12 -2
- package/dist/lib/sync.d.ts +5 -0
- package/dist/lib/sync.js +48 -5
- package/dist/lib/telegram-api.js +18 -1
- package/dist/package.json +1 -1
- package/docs/architecture.md +5 -1
- package/lib/extension.ts +1 -0
- package/lib/locks.ts +28 -11
- package/lib/polling.ts +21 -2
- package/lib/sync.ts +47 -5
- package/lib/telegram-api.ts +21 -1
- package/package.json +1 -1
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
|
|
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
|
-
##
|
|
5
|
+
## Post-Release Transport Recovery Acceptance
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
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
|
package/dist/lib/extension.js
CHANGED
|
@@ -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,
|
package/dist/lib/locks.d.ts
CHANGED
|
@@ -215,6 +215,11 @@ type TelegramLockedPollingStartResult = {
|
|
|
215
215
|
export interface TelegramLockedPollingRuntime<TContext extends TelegramLockContext> {
|
|
216
216
|
start: (ctx: TContext, options?: TelegramLockedPollingStartOptions) => Promise<TelegramLockedPollingStartResult>;
|
|
217
217
|
stop: () => Promise<string>;
|
|
218
|
+
/** Capture one disconnect attempt before cleanup awaits; a newer start revokes it. */
|
|
219
|
+
captureStop: () => {
|
|
220
|
+
isCurrent: () => boolean;
|
|
221
|
+
stop: () => Promise<string>;
|
|
222
|
+
};
|
|
218
223
|
suspend: () => Promise<void>;
|
|
219
224
|
isSuspended: () => boolean;
|
|
220
225
|
/** Fence one owned polling generation; suspension, restart, conflict or lock loss revokes it. */
|
package/dist/lib/locks.js
CHANGED
|
@@ -1210,6 +1210,8 @@ export function createTelegramLockedPollingRuntime(deps) {
|
|
|
1210
1210
|
stopOwnershipWatcher();
|
|
1211
1211
|
if (sessionAutoStartRun) {
|
|
1212
1212
|
await sessionAutoStartRun;
|
|
1213
|
+
if (generation !== pollingGeneration)
|
|
1214
|
+
return;
|
|
1213
1215
|
deps.stopFollowerRegistration?.();
|
|
1214
1216
|
}
|
|
1215
1217
|
if (ownershipStop) {
|
|
@@ -1325,6 +1327,21 @@ export function createTelegramLockedPollingRuntime(deps) {
|
|
|
1325
1327
|
const canStartPolling = (ctx) => deps.canStartPolling?.(ctx) ?? true;
|
|
1326
1328
|
const formatStartBlockedMessage = (ctx) => deps.formatStartBlockedMessage?.(ctx) ??
|
|
1327
1329
|
"Telegram polling is unavailable in this Pi run mode.";
|
|
1330
|
+
const stop = async () => {
|
|
1331
|
+
const generation = pollingGeneration + 1;
|
|
1332
|
+
await suspendPolling();
|
|
1333
|
+
if (generation !== pollingGeneration)
|
|
1334
|
+
throw new Error("Telegram disconnect was superseded by a new connection.");
|
|
1335
|
+
const state = deps.lock.release();
|
|
1336
|
+
deps.onTransportAvailabilityChanged?.();
|
|
1337
|
+
if (state.kind === "active-elsewhere") {
|
|
1338
|
+
return `Telegram bridge is active in another Pi instance (${formatTelegramLockEntry(state.lock)}).`;
|
|
1339
|
+
}
|
|
1340
|
+
if (state.kind === "stale") {
|
|
1341
|
+
return `Removed stale Telegram bridge lock (${formatTelegramLockEntry(state.lock)}).`;
|
|
1342
|
+
}
|
|
1343
|
+
return "Telegram bridge disconnected.";
|
|
1344
|
+
};
|
|
1328
1345
|
return {
|
|
1329
1346
|
start: async (ctx, options = {}) => {
|
|
1330
1347
|
if (!deps.hasBotToken()) {
|
|
@@ -1440,17 +1457,18 @@ export function createTelegramLockedPollingRuntime(deps) {
|
|
|
1440
1457
|
const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
|
|
1441
1458
|
return { ok: true, message: `Telegram bridge connected.${staleSuffix}` };
|
|
1442
1459
|
},
|
|
1443
|
-
stop
|
|
1444
|
-
|
|
1445
|
-
const
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1460
|
+
stop,
|
|
1461
|
+
captureStop() {
|
|
1462
|
+
const generation = pollingGeneration;
|
|
1463
|
+
const isCurrent = () => generation === pollingGeneration;
|
|
1464
|
+
return {
|
|
1465
|
+
isCurrent,
|
|
1466
|
+
async stop() {
|
|
1467
|
+
if (!isCurrent())
|
|
1468
|
+
throw new Error("Telegram disconnect was superseded by a new connection.");
|
|
1469
|
+
return stop();
|
|
1470
|
+
},
|
|
1471
|
+
};
|
|
1454
1472
|
},
|
|
1455
1473
|
suspend: suspendPolling,
|
|
1456
1474
|
captureTransportAuthority(ctx) {
|
package/dist/lib/polling.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export declare class TelegramPersistentGetUpdatesConflictError extends Error {
|
|
|
28
28
|
readonly count: number;
|
|
29
29
|
constructor(count: number);
|
|
30
30
|
}
|
|
31
|
+
/** Exponential backoff for non-conflict polling/admission failures. */
|
|
32
|
+
export declare function getTelegramPollingRetryDelayMs(consecutiveFailures: number): number;
|
|
31
33
|
export declare class TelegramGetUpdatesTimeoutError extends Error {
|
|
32
34
|
readonly timeoutMs: number;
|
|
33
35
|
constructor(timeoutMs: number);
|
package/dist/lib/polling.js
CHANGED
|
@@ -14,7 +14,8 @@ export const TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT = 10;
|
|
|
14
14
|
const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
|
|
15
15
|
const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
|
|
16
16
|
const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
|
|
17
|
-
const
|
|
17
|
+
const TELEGRAM_POLLING_RETRY_BASE_MS = 1_000;
|
|
18
|
+
const TELEGRAM_POLLING_RETRY_MAX_MS = 30_000;
|
|
18
19
|
const TELEGRAM_GET_UPDATES_GRACE_MS = 10_000;
|
|
19
20
|
// Standard Telegram DM polling does not expose ordinary message-deletion events.
|
|
20
21
|
// Business deletions belong to a separate namespace and default routing ignores them.
|
|
@@ -51,6 +52,11 @@ export class TelegramPersistentGetUpdatesConflictError extends Error {
|
|
|
51
52
|
this.count = count;
|
|
52
53
|
}
|
|
53
54
|
}
|
|
55
|
+
/** Exponential backoff for non-conflict polling/admission failures. */
|
|
56
|
+
export function getTelegramPollingRetryDelayMs(consecutiveFailures) {
|
|
57
|
+
const exponent = Math.max(0, Math.min(consecutiveFailures - 1, 16));
|
|
58
|
+
return Math.min(TELEGRAM_POLLING_RETRY_BASE_MS * 2 ** exponent, TELEGRAM_POLLING_RETRY_MAX_MS);
|
|
59
|
+
}
|
|
54
60
|
export class TelegramGetUpdatesTimeoutError extends Error {
|
|
55
61
|
timeoutMs;
|
|
56
62
|
constructor(timeoutMs) {
|
|
@@ -1103,6 +1109,7 @@ export async function runTelegramPollLoop(deps) {
|
|
|
1103
1109
|
if (!deps.config.botToken)
|
|
1104
1110
|
return;
|
|
1105
1111
|
let consecutiveGetUpdatesConflicts = 0;
|
|
1112
|
+
let consecutiveFailures = 0;
|
|
1106
1113
|
const retryConflict = async () => {
|
|
1107
1114
|
consecutiveGetUpdatesConflicts += 1;
|
|
1108
1115
|
if (consecutiveGetUpdatesConflicts >= TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT) {
|
|
@@ -1171,6 +1178,7 @@ export async function runTelegramPollLoop(deps) {
|
|
|
1171
1178
|
onPhaseChange: deps.onPhaseChange,
|
|
1172
1179
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
1173
1180
|
});
|
|
1181
|
+
consecutiveFailures = 0;
|
|
1174
1182
|
currentUpdateId = undefined;
|
|
1175
1183
|
}
|
|
1176
1184
|
catch (error) {
|
|
@@ -1190,8 +1198,10 @@ export async function runTelegramPollLoop(deps) {
|
|
|
1190
1198
|
: {}),
|
|
1191
1199
|
});
|
|
1192
1200
|
consecutiveGetUpdatesConflicts = 0;
|
|
1201
|
+
consecutiveFailures += 1;
|
|
1193
1202
|
deps.onErrorStatus(getTelegramPollingErrorMessage(error));
|
|
1194
|
-
|
|
1203
|
+
// Cap the pause, not retry count: outages must recover without manual reconnect.
|
|
1204
|
+
await deps.sleep(getTelegramPollingRetryDelayMs(consecutiveFailures), deps.signal);
|
|
1195
1205
|
if (deps.signal.aborted)
|
|
1196
1206
|
return;
|
|
1197
1207
|
deps.onStatusReset();
|
package/dist/lib/sync.d.ts
CHANGED
|
@@ -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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.`
|
package/dist/lib/telegram-api.js
CHANGED
|
@@ -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
|
|
807
|
+
await publishTelegramDownload(partPath, targetPath, options?.signal);
|
|
791
808
|
}
|
|
792
809
|
catch (error) {
|
|
793
810
|
await removeTelegramPartialDownload(partPath);
|
package/dist/package.json
CHANGED
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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
|
|
1802
|
-
|
|
1803
|
-
const
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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.`
|
package/lib/telegram-api.ts
CHANGED
|
@@ -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
|
|
1716
|
+
await publishTelegramDownload(partPath, targetPath, options?.signal);
|
|
1697
1717
|
} catch (error) {
|
|
1698
1718
|
await removeTelegramPartialDownload(partPath);
|
|
1699
1719
|
throw error;
|