@llblab/pi-kit 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +5 -0
- package/node_modules/@llblab/pi-telegram/README.md +2 -0
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +4 -2
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +1 -1
- package/node_modules/@llblab/pi-telegram/index.ts +8 -0
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +99 -16
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +142 -30
- package/node_modules/@llblab/pi-telegram/lib/prompts.ts +8 -6
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +4 -0
- package/node_modules/@llblab/pi-telegram/lib/status.ts +4 -0
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +6 -0
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +2 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@llblab/pi-kit` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.5.1 - 2026-09-06
|
|
6
|
+
|
|
7
|
+
- `Telegram Continuity Hotfix`: Advances the exact Telegram pin to `0.43.1`, bringing bounded persistent polling-conflict stand-down with accepted queue preservation, missing/null system-prompt normalization, and authority-fenced preview cleanup without changing the resource inventory.
|
|
8
|
+
|
|
5
9
|
## 0.5.0 - 2026-09-05
|
|
6
10
|
|
|
7
11
|
- `Telegram Native Controls`: Advances the exact Telegram pin to `0.43.0`, bringing in-body button blocks, shared JSON/CML disabled controls, HTML footer fallback, and updated agent Skills while preserving package-owned implementations and the existing resource inventory.
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Package links lead to the owning repositories for usage, documentation, issues,
|
|
|
15
15
|
| [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.9.4` | Compact Codex/Spark subscription-limit status |
|
|
16
16
|
| [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.7.4` | Visible continuation scheduling and bounded worker Skills |
|
|
17
17
|
| [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.3.0` | Opt-in validated state handoffs between agent runs |
|
|
18
|
-
| [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.43.
|
|
18
|
+
| [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.43.1` | Telegram companion, queues, files, voice, controls, and Generative Apps guidance |
|
|
19
19
|
| [`@llblab/skills`](https://github.com/llblab/skills) | `1.14.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
|
|
20
20
|
|
|
21
21
|
Versions are exact by design. An upstream release does not change an installed kit until this repository explicitly advances the dependency and publishes a new kit version. Runtime defects and package-specific feature requests belong in the linked repository; package selection and kit installation issues belong here.
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
> Each release keeps at most 8 outcome records of at most 512 characters.
|
|
4
4
|
|
|
5
|
+
## 0.43.1: Transport And Preview Continuity
|
|
6
|
+
|
|
7
|
+
- `Conflict Stand-Down`: Stops transport after ten consecutive competing getUpdates conflicts, including ownership checks, heartbeat, monitoring, and bus teardown. Releases only the exact local lock and revokes direct authority even if release fails, while preserving accepted local queue work. A persistent terminal status and one diagnostic distinguish lost ownership from a competing external client; cancelled reconnects and stale admission cannot supersede current lifecycle work.
|
|
8
|
+
- `Prompt And Preview Continuity`: Treats absent/null host system prompts as empty while retaining Telegram guidance, clears completed text from preview state after successful delivery, and rejects stale session/transport completions before they can clear a replacement preview. Receipt-conflict diagnostics now include the receipt and source update ids; durable handoff verification remains fail-closed.
|
|
9
|
+
|
|
5
10
|
## 0.43.0: Native Button Blocks
|
|
6
11
|
|
|
7
12
|
- `In-Body Controls`: Renders standalone `telegram_button` fences as native button rows between paragraphs using the same singleton/mixed JSON/CML grammar and callback ownership as footer comments. Invalid/incomplete blocks register nothing, previews hide action payloads, literal examples remain inert, and HTML mode moves controls to the footer. In-body clicks acknowledge without recoloring; operator-confirmed current-client smoke passed; cross-client/follower checks remain pending.
|
|
@@ -59,6 +59,8 @@ The connected Pi instance owns Telegram polling. Use `/telegram-connect <name>`
|
|
|
59
59
|
|
|
60
60
|
After an unclean computer shutdown, `/telegram-connect` detects truncated or structurally invalid temporary ownership/routing files, quarantines only the damaged files under `tmp/telegram/recovery/`, and retries once. A journal 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 snapshot and segments are quarantined as recovery evidence, a fresh journal is published, and startup continues with an informational diagnostic instead of requiring manual JSON repair. Saved `telegram.json` configuration and runtime diagnostics remain intact. Recovery never replaces a verifiable live owner; if safe automatic recovery cannot complete, the command gives one explicit Pi-restart instruction instead of requiring deletion of the whole `tmp/` directory.
|
|
61
61
|
|
|
62
|
+
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).
|
|
63
|
+
|
|
62
64
|
### 4. Pair your Telegram account
|
|
63
65
|
|
|
64
66
|
Open the bot DM and send:
|
|
@@ -92,7 +92,7 @@ The repository uses a **Flat Domain DAG**:
|
|
|
92
92
|
|
|
93
93
|
Pi is the primary and only officially supported host. `pi-telegram` may still accept narrow, host-neutral representation differences at its existing Pi-facing boundary when they preserve native Pi behavior and do not create a second runtime policy layer:
|
|
94
94
|
|
|
95
|
-
- `prompts` preserves either Pi's plain system-prompt string or an ordered block array supplied by a compatible host, appending Telegram guidance without collapsing host-owned blocks.
|
|
95
|
+
- `prompts` preserves either Pi's plain system-prompt string or an ordered block array supplied by a compatible host, appending Telegram guidance without collapsing host-owned blocks. An absent/null host system prompt is treated as empty, including the disconnected metadata-stripping path.
|
|
96
96
|
- `pi` normalizes settings-manager construction that is either synchronous or asynchronous, then adapts either Pi's legacy enabled-model methods or a generic `get` / `set` settings service before model-menu reads and scoped-model persistence use it. Hosts without an explicit reload method rely on fresh asynchronous construction; durable writes still require `flush`.
|
|
97
97
|
- `lifecycle` continues to require Pi's semantic `agent_settled` boundary. It does not infer terminal settlement from host-specific `agent_end`, retry, or stop events; a compatibility shim must reproduce that contract before it can safely support activity identity and unrecovered-error finalization.
|
|
98
98
|
|
|
@@ -135,6 +135,8 @@ Telegram configuration lives in `~/.pi/agent/telegram.json`. Bot/session identit
|
|
|
135
135
|
- `/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 through replacement-style suspension. Failed automatic cleanup records diagnostics and falls back to safe suspension so remaining lifecycle cleanup still runs.
|
|
136
136
|
- 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. Startup and `/resume` do not wait on leader election, Bot API probes, poller handoff, or thread reconciliation before restoring the Pi session.
|
|
137
137
|
- 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.
|
|
138
|
+
- 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.
|
|
139
|
+
- 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.
|
|
138
140
|
- `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.
|
|
139
141
|
- Built-in read-only menu commands return after required local mutation and schedule context-fenced rendering and command synchronization independently, so those effects cannot withhold the next inbound offset.
|
|
140
142
|
- Pi `print`/`json` run modes stay passive. Inherited child sessions that share `telegram.json` but do not own the exact `pid`/`cwd` slot must not poll or call `getUpdates` unless the operator force-takes ownership.
|
|
@@ -339,7 +341,7 @@ Assistant delivery guarantees:
|
|
|
339
341
|
- Quoted rich replies use Telegram `rich_message` blocks as the prompt-context source when available, so `[reply]` context receives rendered plain text instead of raw `InputRichMessage.markdown` fallback text. Replied media runs through the same inbound handlers and voice transcription providers as current-message media, with provenance-scoped `[outputs|from:…]` appended inside the reply block.
|
|
340
342
|
- Long native Markdown replies are split only at Telegram Rich Message transport limits; oversized fenced code, display-math, and fully wrapped inline-formatting blocks are rewrapped per chunk so persisted Rich Markdown chunks remain structurally valid.
|
|
341
343
|
- When Draft previews are enabled, streaming previews pass structurally closed assistant Markdown prefixes through to `sendRichMessageDraft` with ownership checks, voice suppression, and serialized flushes. Unclosed inline spans, links, fenced code, comments, and display-math blocks are held back until a safe boundary exists. Draft failures are recorded and the failing frame is skipped instead of degrading to raw plain-message previews, because partial Markdown can be invalid while the final message remains valid.
|
|
342
|
-
- Preview flushes are serialized so older edits cannot race newer drafts; final delivery waits for active draft flushes and does not perform a post-final draft-clear call.
|
|
344
|
+
- Preview flushes are serialized so older edits cannot race newer drafts; final delivery waits for active draft flushes and does not perform a post-final draft-clear call. Successful final text delivery clears the local pending preview text only while the captured session and transport remain active, so a late delivery or Rich-attachment cleanup cannot erase replacement preview state.
|
|
343
345
|
|
|
344
346
|
UI/compat rendering guarantees:
|
|
345
347
|
|
|
@@ -434,7 +434,7 @@ Follower Restore requires exact registration generation and expected old target
|
|
|
434
434
|
|
|
435
435
|
- Two leaders calling `getUpdates` is the main safety failure.
|
|
436
436
|
- Lock heartbeat/takeover must be atomic enough to prevent this under normal local concurrency.
|
|
437
|
-
-
|
|
437
|
+
- Persistent competing `getUpdates` clients trigger a full transport stand-down even when the local lock still appears owned; the stopped runtime preserves accepted local work and releases only its exact ownership. See [Runtime Ownership](./architecture.md#runtime-ownership) for the threshold, diagnostic, and reconnect contract.
|
|
438
438
|
|
|
439
439
|
## Security Boundaries
|
|
440
440
|
|
|
@@ -1034,6 +1034,12 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1034
1034
|
Pi.ExtensionContext
|
|
1035
1035
|
>({
|
|
1036
1036
|
state: pollingControllerState,
|
|
1037
|
+
canStart(ctx) {
|
|
1038
|
+
return telegramSessionContextStore.isCurrent(ctx) && lockRuntime.owns(ctx);
|
|
1039
|
+
},
|
|
1040
|
+
onPersistentConflict(ctx, count): Promise<void> {
|
|
1041
|
+
return lockedPollingRuntime.onPersistentConflict(ctx, count);
|
|
1042
|
+
},
|
|
1037
1043
|
getConfig: configStore.get,
|
|
1038
1044
|
hasBotToken: configStore.hasBotToken,
|
|
1039
1045
|
deleteWebhook,
|
|
@@ -1205,8 +1211,10 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1205
1211
|
const threadAwarePollingPorts = telegramThreadCapabilityRuntime.pollingPorts;
|
|
1206
1212
|
const lockedPollingRuntime = Locks.createTelegramLockedPollingRuntime({
|
|
1207
1213
|
lock: lockRuntime,
|
|
1214
|
+
transportMonitor: telegramThreadCapabilityMonitor,
|
|
1208
1215
|
hasBotToken: configStore.hasBotToken,
|
|
1209
1216
|
canStartPolling: Pi.canStartPollingInExtensionContext,
|
|
1217
|
+
isContextCurrent: telegramSessionContextStore.isCurrent,
|
|
1210
1218
|
formatStartBlockedMessage: Pi.formatPollingStartBlockedByRunMode,
|
|
1211
1219
|
startPolling: threadAwarePollingPorts.startPolling,
|
|
1212
1220
|
stopPolling: threadAwarePollingPorts.stopPolling,
|
|
@@ -871,6 +871,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
871
871
|
options.runtimeGeneration ?? allocateTelegramLockRuntimeGeneration();
|
|
872
872
|
let ownedLockKey: string | undefined;
|
|
873
873
|
let ownedLock: TelegramLockEntry | undefined;
|
|
874
|
+
let deliveryRevoked = false;
|
|
874
875
|
const stateOptions = () => ({
|
|
875
876
|
nowMs: getNowMs(),
|
|
876
877
|
staleHeartbeatMs: options.staleHeartbeatMs,
|
|
@@ -917,7 +918,8 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
917
918
|
);
|
|
918
919
|
if (
|
|
919
920
|
state.kind === "active-here" &&
|
|
920
|
-
hasSameLockOwner(current, expectedOwned)
|
|
921
|
+
hasSameLockOwner(current, expectedOwned) &&
|
|
922
|
+
!deliveryRevoked
|
|
921
923
|
) {
|
|
922
924
|
return {
|
|
923
925
|
result: {
|
|
@@ -958,6 +960,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
958
960
|
if (
|
|
959
961
|
!acquireOptions.election &&
|
|
960
962
|
(state.kind === "active-here" || state.kind === "active-elsewhere") &&
|
|
963
|
+
!(deliveryRevoked && hasSameLockOwner(current, expectedOwned)) &&
|
|
961
964
|
(!acquireOptions.force ||
|
|
962
965
|
!expectedReplacementMatches ||
|
|
963
966
|
!canReplaceCurrent)
|
|
@@ -978,6 +981,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
978
981
|
locks[effectiveKey] = lock;
|
|
979
982
|
ownedLockKey = effectiveKey;
|
|
980
983
|
ownedLock = lock;
|
|
984
|
+
deliveryRevoked = false;
|
|
981
985
|
return {
|
|
982
986
|
result: {
|
|
983
987
|
ok: true,
|
|
@@ -987,8 +991,10 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
987
991
|
changed: true,
|
|
988
992
|
};
|
|
989
993
|
}),
|
|
990
|
-
release: () =>
|
|
991
|
-
|
|
994
|
+
release: () => {
|
|
995
|
+
// Withdraw local send authority even if the durable release fails.
|
|
996
|
+
deliveryRevoked = true;
|
|
997
|
+
return withLockTransaction(locksPath, (locks) => {
|
|
992
998
|
const effectiveKey = resolveEffectiveKey();
|
|
993
999
|
const state = getLockState(
|
|
994
1000
|
parseTelegramLockEntry(locks[effectiveKey]),
|
|
@@ -1008,17 +1014,20 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1008
1014
|
ownedLock = undefined;
|
|
1009
1015
|
}
|
|
1010
1016
|
return { result: state, changed };
|
|
1011
|
-
})
|
|
1017
|
+
});
|
|
1018
|
+
},
|
|
1012
1019
|
getState: () => getLockState(readLock(), pid, isAlive, stateOptions()),
|
|
1013
1020
|
getStatusLabel: () =>
|
|
1014
1021
|
formatLockState(getLockState(readLock(), pid, isAlive, stateOptions())),
|
|
1015
1022
|
getOwnedLeaderEpoch: () => {
|
|
1023
|
+
if (deliveryRevoked) return undefined;
|
|
1016
1024
|
const effectiveKey = resolveEffectiveKey();
|
|
1017
1025
|
const lock = parseTelegramLockEntry(readLocks(locksPath)[effectiveKey]);
|
|
1018
1026
|
const exactOwner = adoptCompatibleOwnedLock(effectiveKey, lock);
|
|
1019
1027
|
return hasSameLockOwner(lock, exactOwner) ? lock?.leaderEpoch : undefined;
|
|
1020
1028
|
},
|
|
1021
1029
|
owns: (ctx) => {
|
|
1030
|
+
if (deliveryRevoked) return false;
|
|
1022
1031
|
const effectiveKey = resolveEffectiveKey();
|
|
1023
1032
|
const lock = parseTelegramLockEntry(readLocks(locksPath)[effectiveKey]);
|
|
1024
1033
|
return hasSameLockOwner(
|
|
@@ -1027,7 +1036,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1027
1036
|
);
|
|
1028
1037
|
},
|
|
1029
1038
|
commitIfOwned: (commit) =>
|
|
1030
|
-
withLockTransaction(locksPath, (locks) => {
|
|
1039
|
+
!deliveryRevoked && withLockTransaction(locksPath, (locks) => {
|
|
1031
1040
|
const effectiveKey = resolveEffectiveKey();
|
|
1032
1041
|
const lock = parseTelegramLockEntry(locks[effectiveKey]);
|
|
1033
1042
|
const exactOwner =
|
|
@@ -1043,7 +1052,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1043
1052
|
return { result: true, changed: false };
|
|
1044
1053
|
}),
|
|
1045
1054
|
refresh: (ctx) =>
|
|
1046
|
-
withLockTransaction(locksPath, (locks) => {
|
|
1055
|
+
!deliveryRevoked && withLockTransaction(locksPath, (locks) => {
|
|
1047
1056
|
const effectiveKey = resolveEffectiveKey();
|
|
1048
1057
|
const lock = parseTelegramLockEntry(locks[effectiveKey]);
|
|
1049
1058
|
const expectedOwner = adoptCompatibleOwnedLock(effectiveKey, lock, ctx);
|
|
@@ -1116,6 +1125,7 @@ export interface TelegramLockedPollingRuntime<
|
|
|
1116
1125
|
) => Promise<TelegramLockedPollingStartResult>;
|
|
1117
1126
|
stop: () => Promise<string>;
|
|
1118
1127
|
suspend: () => Promise<void>;
|
|
1128
|
+
onPersistentConflict: (ctx: TContext, count: number) => Promise<void>;
|
|
1119
1129
|
onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
|
|
1120
1130
|
registerFollowerWithOwner?: (
|
|
1121
1131
|
ctx: TContext,
|
|
@@ -1130,6 +1140,7 @@ export interface TelegramLockedPollingRuntimeDeps<
|
|
|
1130
1140
|
lock: TelegramLockRuntime<TContext>;
|
|
1131
1141
|
hasBotToken: () => boolean;
|
|
1132
1142
|
canStartPolling?: (ctx: TContext) => boolean;
|
|
1143
|
+
isContextCurrent?: (ctx: TContext) => boolean;
|
|
1133
1144
|
formatStartBlockedMessage?: (ctx: TContext) => string;
|
|
1134
1145
|
startPolling: (
|
|
1135
1146
|
ctx: TContext,
|
|
@@ -1142,6 +1153,7 @@ export interface TelegramLockedPollingRuntimeDeps<
|
|
|
1142
1153
|
) => boolean | undefined | Promise<boolean | undefined>;
|
|
1143
1154
|
stopFollowerRegistration?: () => void;
|
|
1144
1155
|
onTransportAvailabilityChanged?: () => void;
|
|
1156
|
+
transportMonitor?: { start: (ctx: TContext) => void; stop: () => void };
|
|
1145
1157
|
updateStatus: (ctx: TContext) => void;
|
|
1146
1158
|
recordRuntimeEvent?: (
|
|
1147
1159
|
category: string,
|
|
@@ -1164,9 +1176,10 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1164
1176
|
let ownershipCheckInterval: ReturnType<typeof setInterval> | undefined;
|
|
1165
1177
|
let ownershipRefreshInterval: ReturnType<typeof setInterval> | undefined;
|
|
1166
1178
|
let ownershipStop: Promise<void> | undefined;
|
|
1179
|
+
let activeContext: TContext | undefined;
|
|
1167
1180
|
let takeoverCandidate: TelegramLockEntry | undefined;
|
|
1168
1181
|
let sessionAutoStartRun: Promise<void> | undefined;
|
|
1169
|
-
let
|
|
1182
|
+
let pollingGeneration = 0;
|
|
1170
1183
|
const ownershipCheckMs =
|
|
1171
1184
|
deps.ownershipCheckMs ?? TELEGRAM_OWNERSHIP_CHECK_MS;
|
|
1172
1185
|
const ownershipRefreshMs =
|
|
@@ -1178,7 +1191,9 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1178
1191
|
ownershipRefreshInterval = undefined;
|
|
1179
1192
|
};
|
|
1180
1193
|
const suspendPolling = async () => {
|
|
1181
|
-
|
|
1194
|
+
pollingGeneration += 1;
|
|
1195
|
+
activeContext = undefined;
|
|
1196
|
+
deps.transportMonitor?.stop();
|
|
1182
1197
|
deps.stopFollowerRegistration?.();
|
|
1183
1198
|
stopOwnershipWatcher();
|
|
1184
1199
|
if (sessionAutoStartRun) {
|
|
@@ -1192,6 +1207,8 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1192
1207
|
};
|
|
1193
1208
|
const stopAfterOwnershipLoss = () => {
|
|
1194
1209
|
if (ownershipStop) return;
|
|
1210
|
+
activeContext = undefined;
|
|
1211
|
+
deps.transportMonitor?.stop();
|
|
1195
1212
|
stopOwnershipWatcher();
|
|
1196
1213
|
deps.onTransportAvailabilityChanged?.();
|
|
1197
1214
|
ownershipStop = deps
|
|
@@ -1228,7 +1245,10 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1228
1245
|
const runOwnedPollingStart = async (
|
|
1229
1246
|
ctx: TContext,
|
|
1230
1247
|
options: TelegramLockedPollingStartOptions,
|
|
1248
|
+
isCurrent: () => boolean,
|
|
1231
1249
|
): Promise<boolean> => {
|
|
1250
|
+
if (!isCurrent()) return false;
|
|
1251
|
+
activeContext = ctx;
|
|
1232
1252
|
startOwnershipWatcher(ctx);
|
|
1233
1253
|
try {
|
|
1234
1254
|
if (!deps.lock.refresh(snapshotLockContext(ctx))) {
|
|
@@ -1236,8 +1256,10 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1236
1256
|
return false;
|
|
1237
1257
|
}
|
|
1238
1258
|
await options.onAcquired?.();
|
|
1259
|
+
if (!isCurrent()) return false;
|
|
1239
1260
|
await deps.startPolling(ctx, options);
|
|
1240
1261
|
} catch (error) {
|
|
1262
|
+
if (!isCurrent()) return false;
|
|
1241
1263
|
stopOwnershipWatcher();
|
|
1242
1264
|
try {
|
|
1243
1265
|
await deps.stopPolling();
|
|
@@ -1246,14 +1268,22 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1246
1268
|
phase: "startup-rollback",
|
|
1247
1269
|
});
|
|
1248
1270
|
}
|
|
1271
|
+
if (!isCurrent()) return false;
|
|
1249
1272
|
deps.lock.release();
|
|
1250
1273
|
deps.onTransportAvailabilityChanged?.();
|
|
1251
1274
|
throw error;
|
|
1252
1275
|
}
|
|
1253
|
-
if (
|
|
1276
|
+
if (!isCurrent()) return false;
|
|
1277
|
+
if (deps.lock.owns(ctx)) {
|
|
1278
|
+
if (activeContext !== ctx) return false;
|
|
1279
|
+
deps.transportMonitor?.start(ctx);
|
|
1280
|
+
return true;
|
|
1281
|
+
}
|
|
1254
1282
|
stopOwnershipWatcher();
|
|
1255
1283
|
if (ownershipStop) await ownershipStop;
|
|
1284
|
+
if (!isCurrent()) return false;
|
|
1256
1285
|
await deps.stopPolling();
|
|
1286
|
+
if (!isCurrent()) return false;
|
|
1257
1287
|
deps.onTransportAvailabilityChanged?.();
|
|
1258
1288
|
return false;
|
|
1259
1289
|
};
|
|
@@ -1270,6 +1300,17 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1270
1300
|
if (!canStartPolling(ctx)) {
|
|
1271
1301
|
return { ok: false, message: formatStartBlockedMessage(ctx) };
|
|
1272
1302
|
}
|
|
1303
|
+
const cancelled = {
|
|
1304
|
+
ok: false as const,
|
|
1305
|
+
canTakeover: false as const,
|
|
1306
|
+
message: "Telegram polling startup was cancelled or superseded.",
|
|
1307
|
+
};
|
|
1308
|
+
if (deps.isContextCurrent?.(ctx) === false) return cancelled;
|
|
1309
|
+
const generation = ++pollingGeneration;
|
|
1310
|
+
const isCurrent = () => generation === pollingGeneration &&
|
|
1311
|
+
(deps.isContextCurrent?.(ctx) ?? true);
|
|
1312
|
+
if (ownershipStop) await ownershipStop;
|
|
1313
|
+
if (!isCurrent()) return cancelled;
|
|
1273
1314
|
let acquired = deps.lock.acquire(ctx, {
|
|
1274
1315
|
force: options.force,
|
|
1275
1316
|
expectedOwner:
|
|
@@ -1306,6 +1347,7 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1306
1347
|
ctx,
|
|
1307
1348
|
acquired.lock,
|
|
1308
1349
|
);
|
|
1350
|
+
if (!isCurrent()) return cancelled;
|
|
1309
1351
|
if (registered) {
|
|
1310
1352
|
deps.updateStatus(ctx);
|
|
1311
1353
|
return { ok: true, canTakeover: false };
|
|
@@ -1337,13 +1379,15 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1337
1379
|
};
|
|
1338
1380
|
}
|
|
1339
1381
|
takeoverCandidate = undefined;
|
|
1340
|
-
if (!(await runOwnedPollingStart(ctx, options))) {
|
|
1382
|
+
if (!(await runOwnedPollingStart(ctx, options, isCurrent))) {
|
|
1383
|
+
if (!isCurrent()) return cancelled;
|
|
1341
1384
|
return {
|
|
1342
1385
|
ok: false,
|
|
1343
1386
|
canTakeover: false,
|
|
1344
1387
|
message: "Telegram leadership changed during polling startup.",
|
|
1345
1388
|
};
|
|
1346
1389
|
}
|
|
1390
|
+
if (!isCurrent()) return cancelled;
|
|
1347
1391
|
deps.onTransportAvailabilityChanged?.();
|
|
1348
1392
|
deps.updateStatus(ctx);
|
|
1349
1393
|
const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
|
|
@@ -1362,6 +1406,42 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1362
1406
|
return "Telegram bridge disconnected.";
|
|
1363
1407
|
},
|
|
1364
1408
|
suspend: suspendPolling,
|
|
1409
|
+
onPersistentConflict: async (ctx, count) => {
|
|
1410
|
+
if (activeContext === undefined || ownershipStop) return;
|
|
1411
|
+
if (!(deps.isContextCurrent?.(ctx) ?? activeContext === ctx)) return;
|
|
1412
|
+
activeContext = undefined;
|
|
1413
|
+
pollingGeneration += 1;
|
|
1414
|
+
stopOwnershipWatcher();
|
|
1415
|
+
deps.transportMonitor?.stop();
|
|
1416
|
+
let ownership = "unverifiable";
|
|
1417
|
+
const cleanupErrors: string[] = [];
|
|
1418
|
+
try {
|
|
1419
|
+
ownership = deps.lock.owns(snapshotLockContext(ctx)) ? "owned" : "lost";
|
|
1420
|
+
} catch (error) {
|
|
1421
|
+
cleanupErrors.push(String(error));
|
|
1422
|
+
}
|
|
1423
|
+
try {
|
|
1424
|
+
deps.lock.release();
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
ownership = "unverifiable";
|
|
1427
|
+
cleanupErrors.push(String(error));
|
|
1428
|
+
}
|
|
1429
|
+
ownershipStop = Promise.resolve()
|
|
1430
|
+
.then(() => deps.stopPolling())
|
|
1431
|
+
.catch((error) => { cleanupErrors.push(String(error)); })
|
|
1432
|
+
.finally(() => {
|
|
1433
|
+
ownershipStop = undefined;
|
|
1434
|
+
deps.recordRuntimeEvent?.("polling", ownership === "lost"
|
|
1435
|
+
? "Telegram transport stopped: local ownership lost; check for another Pi instance."
|
|
1436
|
+
: "Telegram transport stopped: competing getUpdates client or ownership mismatch.", {
|
|
1437
|
+
phase: "persistent-conflict", count, ownership,
|
|
1438
|
+
...(cleanupErrors.length ? { cleanupErrors } : {}),
|
|
1439
|
+
});
|
|
1440
|
+
deps.updateStatus(ctx);
|
|
1441
|
+
});
|
|
1442
|
+
deps.onTransportAvailabilityChanged?.();
|
|
1443
|
+
await ownershipStop;
|
|
1444
|
+
},
|
|
1365
1445
|
onSessionStart: async (_event, ctx) => {
|
|
1366
1446
|
if (!deps.hasBotToken()) return;
|
|
1367
1447
|
if (!canStartPolling(ctx)) return;
|
|
@@ -1379,15 +1459,18 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1379
1459
|
) {
|
|
1380
1460
|
return;
|
|
1381
1461
|
}
|
|
1382
|
-
|
|
1383
|
-
const generation =
|
|
1462
|
+
if (deps.isContextCurrent?.(ctx) === false) return;
|
|
1463
|
+
const generation = ++pollingGeneration;
|
|
1464
|
+
const isCurrent = () => generation === pollingGeneration &&
|
|
1465
|
+
(deps.isContextCurrent?.(ctx) ?? true);
|
|
1384
1466
|
const startedAtMs = Date.now();
|
|
1385
1467
|
deps.recordRuntimeEvent?.("lock", "Telegram auto-start scheduled", {
|
|
1386
1468
|
phase: "auto-start-scheduled",
|
|
1387
1469
|
});
|
|
1388
1470
|
const run = (async () => {
|
|
1389
1471
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1390
|
-
if (
|
|
1472
|
+
if (ownershipStop) await ownershipStop;
|
|
1473
|
+
if (!isCurrent()) return;
|
|
1391
1474
|
if (canResumeStaleSameCwd || canHandoffSameProcess) {
|
|
1392
1475
|
const acquired = deps.lock.acquire(
|
|
1393
1476
|
ctx,
|
|
@@ -1397,9 +1480,9 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1397
1480
|
);
|
|
1398
1481
|
if (!acquired.ok) return;
|
|
1399
1482
|
}
|
|
1400
|
-
if (
|
|
1401
|
-
if (!(await runOwnedPollingStart(ctx, {}))) return;
|
|
1402
|
-
if (
|
|
1483
|
+
if (!isCurrent()) return;
|
|
1484
|
+
if (!(await runOwnedPollingStart(ctx, {}, isCurrent))) return;
|
|
1485
|
+
if (!isCurrent()) return;
|
|
1403
1486
|
deps.onTransportAvailabilityChanged?.();
|
|
1404
1487
|
deps.updateStatus(ctx);
|
|
1405
1488
|
deps.recordRuntimeEvent?.("lock", "Telegram auto-start completed", {
|
|
@@ -21,6 +21,7 @@ const TELEGRAM_LONG_POLL_LIMIT = 10;
|
|
|
21
21
|
const TELEGRAM_LONG_POLL_TIMEOUT_SECONDS = 30;
|
|
22
22
|
const TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS = 2_500;
|
|
23
23
|
const TELEGRAM_THREAD_CAPABILITY_DISABLED_CONFIRMATION_PROBES = 2;
|
|
24
|
+
export const TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT = 10;
|
|
24
25
|
const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
|
|
25
26
|
const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
|
|
26
27
|
const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
|
|
@@ -69,6 +70,15 @@ export function getLatestTelegramUpdateId(
|
|
|
69
70
|
return updates.at(-1)?.update_id;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
export class TelegramPersistentGetUpdatesConflictError extends Error {
|
|
74
|
+
readonly count: number;
|
|
75
|
+
constructor(count: number) {
|
|
76
|
+
super(`Telegram polling stopped after ${count} consecutive getUpdates conflicts.`);
|
|
77
|
+
this.name = "TelegramPersistentGetUpdatesConflictError";
|
|
78
|
+
this.count = count;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
export class TelegramGetUpdatesTimeoutError extends Error {
|
|
73
83
|
readonly timeoutMs: number;
|
|
74
84
|
|
|
@@ -126,7 +136,8 @@ export type TelegramPollingStopReason =
|
|
|
126
136
|
| "not-started"
|
|
127
137
|
| "requested"
|
|
128
138
|
| "completed"
|
|
129
|
-
| "failed"
|
|
139
|
+
| "failed"
|
|
140
|
+
| "persistent-conflict";
|
|
130
141
|
|
|
131
142
|
export interface TelegramPollingStateSnapshot {
|
|
132
143
|
phase: TelegramPollingPhase;
|
|
@@ -200,6 +211,7 @@ export interface TelegramPollingRuntimeDeps<
|
|
|
200
211
|
createAbortController?: () => AbortController;
|
|
201
212
|
getNowMs?: () => number;
|
|
202
213
|
onPollingStateChange?: () => void;
|
|
214
|
+
onPersistentConflict?: (ctx: TContext, count: number) => MaybePromise<void>;
|
|
203
215
|
onPollingStarted?: () => void;
|
|
204
216
|
onPollingStopped?: (reason: TelegramPollingStopReason) => void;
|
|
205
217
|
}
|
|
@@ -227,20 +239,31 @@ export interface TelegramPollingAdmissionRuntime<TContext> {
|
|
|
227
239
|
export function createTelegramPollingAdmissionRuntime<TContext>(deps: {
|
|
228
240
|
polling: TelegramPollingController<TContext>;
|
|
229
241
|
prepareStart?: () => MaybePromise<void>;
|
|
242
|
+
canStart?: (ctx: TContext) => boolean;
|
|
230
243
|
validateStart?: () => void;
|
|
231
244
|
worker: {
|
|
232
245
|
onSessionStart: (ctx: TContext) => Promise<void>;
|
|
233
246
|
};
|
|
234
247
|
}): TelegramPollingAdmissionRuntime<TContext> {
|
|
248
|
+
let generation = 0;
|
|
235
249
|
return {
|
|
236
250
|
isActive: deps.polling.isActive,
|
|
237
251
|
async start(ctx) {
|
|
252
|
+
if (!(deps.canStart?.(ctx) ?? true)) return;
|
|
253
|
+
const expectedGeneration = ++generation;
|
|
254
|
+
const isCurrent = () => expectedGeneration === generation && (deps.canStart?.(ctx) ?? true);
|
|
255
|
+
if (!isCurrent()) return;
|
|
238
256
|
await deps.prepareStart?.();
|
|
257
|
+
if (!isCurrent()) return;
|
|
239
258
|
deps.validateStart?.();
|
|
240
259
|
await deps.worker.onSessionStart(ctx);
|
|
241
|
-
|
|
260
|
+
if (!isCurrent()) return;
|
|
261
|
+
deps.polling.start(ctx);
|
|
262
|
+
},
|
|
263
|
+
async stop() {
|
|
264
|
+
generation += 1;
|
|
265
|
+
await deps.polling.stop();
|
|
242
266
|
},
|
|
243
|
-
stop: deps.polling.stop,
|
|
244
267
|
};
|
|
245
268
|
}
|
|
246
269
|
|
|
@@ -256,6 +279,7 @@ export type TelegramDurablePollingRuntimeAssemblyDeps<
|
|
|
256
279
|
TelegramPollingControllerRuntimeDeps<TUpdate, TContext>,
|
|
257
280
|
"appendUpdateBatch" | "getJournalEntryCount" | "signalUpdateWorker"
|
|
258
281
|
> & {
|
|
282
|
+
canStart?: (ctx: TContext) => boolean;
|
|
259
283
|
journal: {
|
|
260
284
|
appendBatch: (
|
|
261
285
|
updates: readonly TUpdate[],
|
|
@@ -287,6 +311,7 @@ export function createTelegramDurablePollingRuntimeAssembly<
|
|
|
287
311
|
const admission = createTelegramPollingAdmissionRuntime({
|
|
288
312
|
polling: controller,
|
|
289
313
|
prepareStart: deps.journal.prepareCursorCutover,
|
|
314
|
+
canStart: deps.canStart,
|
|
290
315
|
validateStart() {
|
|
291
316
|
if (deps.journal.getAcceptedThroughUpdateId() !== undefined) return;
|
|
292
317
|
if (deps.journal.getBootstrapEntryCount() === 0) return;
|
|
@@ -312,6 +337,7 @@ export type TelegramPollingControllerRuntimeDeps<
|
|
|
312
337
|
createAbortController?: () => AbortController;
|
|
313
338
|
getNowMs?: () => number;
|
|
314
339
|
onPollingStateChange?: () => void;
|
|
340
|
+
onPersistentConflict?: (ctx: TContext, count: number) => MaybePromise<void>;
|
|
315
341
|
};
|
|
316
342
|
|
|
317
343
|
function notifyTelegramPollingStateChange(
|
|
@@ -390,6 +416,7 @@ export function createTelegramPollingControllerRuntime<
|
|
|
390
416
|
createAbortController: deps.createAbortController,
|
|
391
417
|
getNowMs,
|
|
392
418
|
onPollingStateChange: deps.onPollingStateChange,
|
|
419
|
+
onPersistentConflict: deps.onPersistentConflict,
|
|
393
420
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
394
421
|
});
|
|
395
422
|
}
|
|
@@ -503,6 +530,7 @@ export function startTelegramPollingRuntime<TContext>(
|
|
|
503
530
|
deps.setPollingController(controller);
|
|
504
531
|
deps.onPollingStarted?.();
|
|
505
532
|
let failed = false;
|
|
533
|
+
let persistentConflict: TelegramPersistentGetUpdatesConflictError | undefined;
|
|
506
534
|
let runPromise: Promise<void>;
|
|
507
535
|
try {
|
|
508
536
|
runPromise = deps.runPollLoop(ctx, controller.signal);
|
|
@@ -513,24 +541,40 @@ export function startTelegramPollingRuntime<TContext>(
|
|
|
513
541
|
promise = runPromise
|
|
514
542
|
.catch((error) => {
|
|
515
543
|
if (shouldStopTelegramPolling(controller.signal.aborted, error)) return;
|
|
544
|
+
if (error instanceof TelegramPersistentGetUpdatesConflictError) {
|
|
545
|
+
persistentConflict = error;
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
516
548
|
failed = true;
|
|
517
549
|
deps.recordRuntimeEvent?.("polling", error, {
|
|
518
550
|
phase: "controller",
|
|
519
551
|
});
|
|
520
552
|
})
|
|
521
|
-
.finally(() => {
|
|
553
|
+
.finally(async () => {
|
|
522
554
|
const ownsPromise = deps.getPollingPromise() === promise;
|
|
523
555
|
const ownsController = deps.getPollingController() === controller;
|
|
524
556
|
if (ownsPromise) deps.setPollingPromise(undefined);
|
|
525
557
|
if (ownsController) deps.setPollingController(undefined);
|
|
526
|
-
if (ownsPromise
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
558
|
+
if (!ownsPromise && !ownsController) return;
|
|
559
|
+
deps.onPollingStopped?.(
|
|
560
|
+
controller.signal.aborted ? "requested" : persistentConflict
|
|
561
|
+
? "persistent-conflict" : failed ? "failed" : "completed",
|
|
562
|
+
);
|
|
563
|
+
// Detach the inner promise before outer teardown calls polling.stop().
|
|
564
|
+
if (persistentConflict && !controller.signal.aborted) {
|
|
565
|
+
try {
|
|
566
|
+
if (deps.onPersistentConflict) {
|
|
567
|
+
await deps.onPersistentConflict(ctx, persistentConflict.count);
|
|
568
|
+
} else {
|
|
569
|
+
deps.stopTypingLoop();
|
|
570
|
+
deps.recordRuntimeEvent?.("polling", persistentConflict, {
|
|
571
|
+
phase: "persistent-conflict", count: persistentConflict.count,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
} catch (error) {
|
|
575
|
+
deps.recordRuntimeEvent?.("polling", error, { phase: "conflict-stand-down" });
|
|
576
|
+
}
|
|
577
|
+
if (deps.getPollingController() || deps.getPollingPromise()) return;
|
|
534
578
|
}
|
|
535
579
|
updateTelegramPollingStatusSafely(deps.updateStatus, ctx, {
|
|
536
580
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
@@ -581,6 +625,11 @@ export interface TelegramThreadCapabilityReaderDeps {
|
|
|
581
625
|
) => Promise<TResponse>;
|
|
582
626
|
}
|
|
583
627
|
|
|
628
|
+
interface TelegramThreadCapabilityLifecycle {
|
|
629
|
+
capture: () => () => boolean;
|
|
630
|
+
invalidate: () => void;
|
|
631
|
+
}
|
|
632
|
+
|
|
584
633
|
export interface TelegramStartupThreadCapabilityProbeDeps extends TelegramThreadCapabilityReaderDeps {
|
|
585
634
|
topicTargetStore: TelegramThreadCapabilityStore;
|
|
586
635
|
recordEvent: (
|
|
@@ -595,6 +644,7 @@ export interface TelegramStartupThreadCapabilityProbeDeps extends TelegramThread
|
|
|
595
644
|
export interface TelegramThreadCapabilityRuntimeDeps<
|
|
596
645
|
TContext,
|
|
597
646
|
> extends TelegramThreadCapabilityReaderDeps {
|
|
647
|
+
lifecycle?: TelegramThreadCapabilityLifecycle;
|
|
598
648
|
topicTargetStore: TelegramThreadCapabilityStore;
|
|
599
649
|
ownsLock: (ctx: TContext) => boolean;
|
|
600
650
|
isFollowerRegistered?: () => boolean;
|
|
@@ -672,6 +722,7 @@ export interface TelegramThreadAwarePollingDeps<
|
|
|
672
722
|
TContext,
|
|
673
723
|
TOwner,
|
|
674
724
|
> extends TelegramStartupThreadCapabilityProbeDeps {
|
|
725
|
+
lifecycle?: TelegramThreadCapabilityLifecycle;
|
|
675
726
|
isBusRuntimeEnabled: () => boolean;
|
|
676
727
|
isTopicModeUnavailableError: (error: unknown) => boolean;
|
|
677
728
|
getPollingStartedWithTelegramBus: () => boolean;
|
|
@@ -749,7 +800,16 @@ export function createTelegramThreadCapabilityStateRuntime(): TelegramThreadCapa
|
|
|
749
800
|
export function createTelegramThreadCapabilityOrchestration<TContext, TOwner>(
|
|
750
801
|
deps: TelegramThreadCapabilityOrchestrationDeps<TContext, TOwner>,
|
|
751
802
|
): TelegramThreadCapabilityOrchestration<TContext, TOwner> {
|
|
803
|
+
let generation = 0;
|
|
804
|
+
const lifecycle: TelegramThreadCapabilityLifecycle = {
|
|
805
|
+
capture() {
|
|
806
|
+
const expected = generation;
|
|
807
|
+
return () => expected === generation;
|
|
808
|
+
},
|
|
809
|
+
invalidate() { generation++; },
|
|
810
|
+
};
|
|
752
811
|
const capabilityDeps: TelegramThreadCapabilityRuntimeDeps<TContext> = {
|
|
812
|
+
lifecycle,
|
|
753
813
|
getAllowedUserId: deps.getAllowedUserId,
|
|
754
814
|
callApi: deps.callApi,
|
|
755
815
|
topicTargetStore: deps.topicTargetStore,
|
|
@@ -773,6 +833,7 @@ export function createTelegramThreadCapabilityOrchestration<TContext, TOwner>(
|
|
|
773
833
|
monitor: createTelegramThreadCapabilityMonitor(capabilityDeps),
|
|
774
834
|
observeTarget: createTelegramThreadTargetObservationHandler(capabilityDeps),
|
|
775
835
|
pollingPorts: createTelegramThreadAwarePollingPorts({
|
|
836
|
+
lifecycle,
|
|
776
837
|
getAllowedUserId: deps.getAllowedUserId,
|
|
777
838
|
callApi: deps.callApi,
|
|
778
839
|
topicTargetStore: deps.topicTargetStore,
|
|
@@ -807,8 +868,11 @@ export async function readTelegramThreadCapability(
|
|
|
807
868
|
|
|
808
869
|
export async function probeTelegramStartupThreadCapability(
|
|
809
870
|
deps: TelegramStartupThreadCapabilityProbeDeps,
|
|
871
|
+
isCurrent: () => boolean = () => true,
|
|
810
872
|
): Promise<boolean | undefined> {
|
|
873
|
+
if (!isCurrent()) return;
|
|
811
874
|
const threadModeEnabled = await readTelegramThreadCapability(deps);
|
|
875
|
+
if (!isCurrent()) return;
|
|
812
876
|
const nowMs = (deps.getNowMs ?? Date.now)();
|
|
813
877
|
if (threadModeEnabled === false) {
|
|
814
878
|
deps.topicTargetStore.setBotState({
|
|
@@ -817,6 +881,7 @@ export async function probeTelegramStartupThreadCapability(
|
|
|
817
881
|
lastReconcileAction: "startup-bot-topics-disabled",
|
|
818
882
|
});
|
|
819
883
|
await deps.topicTargetStore.persist();
|
|
884
|
+
if (!isCurrent()) return;
|
|
820
885
|
deps.recordEvent("bus", "Telegram Threaded Mode unavailable on startup", {
|
|
821
886
|
phase: "startup-bot-topics-disabled",
|
|
822
887
|
});
|
|
@@ -831,6 +896,7 @@ export async function probeTelegramStartupThreadCapability(
|
|
|
831
896
|
lastReconcileAction: "startup-bot-topics-enabled",
|
|
832
897
|
});
|
|
833
898
|
await deps.topicTargetStore.persist();
|
|
899
|
+
if (!isCurrent()) return;
|
|
834
900
|
deps.setTopicModeUnavailable(false);
|
|
835
901
|
}
|
|
836
902
|
return threadModeEnabled;
|
|
@@ -865,8 +931,11 @@ export async function applyTelegramThreadCapability<TContext>(
|
|
|
865
931
|
threadModeEnabled: boolean,
|
|
866
932
|
phase: string,
|
|
867
933
|
deps: TelegramThreadCapabilityRuntimeDeps<TContext>,
|
|
934
|
+
isCurrent: () => boolean = deps.lifecycle?.capture() ?? (() => true),
|
|
868
935
|
): Promise<void> {
|
|
936
|
+
if (!isCurrent()) return;
|
|
869
937
|
await deps.topicTargetStore.load();
|
|
938
|
+
if (!isCurrent()) return;
|
|
870
939
|
const nowMs = (deps.getNowMs ?? Date.now)();
|
|
871
940
|
const previousBotState = deps.topicTargetStore.getBotState();
|
|
872
941
|
if (!threadModeEnabled) {
|
|
@@ -886,6 +955,7 @@ export async function applyTelegramThreadCapability<TContext>(
|
|
|
886
955
|
lastReconcileAction: phase,
|
|
887
956
|
});
|
|
888
957
|
await deps.topicTargetStore.persist();
|
|
958
|
+
if (!isCurrent()) return;
|
|
889
959
|
deps.setTopicModeUnavailable(true);
|
|
890
960
|
deps.stopFollowerRegistration();
|
|
891
961
|
if (
|
|
@@ -894,16 +964,20 @@ export async function applyTelegramThreadCapability<TContext>(
|
|
|
894
964
|
) {
|
|
895
965
|
deps.stopLeaderHealth();
|
|
896
966
|
await deps.stopBusPolling();
|
|
967
|
+
if (!isCurrent()) return;
|
|
897
968
|
deps.setPollingStartedWithTelegramBus(false);
|
|
898
969
|
try {
|
|
899
970
|
await deps.startClassicPolling(ctx);
|
|
971
|
+
if (!isCurrent()) return;
|
|
900
972
|
} catch (classicError) {
|
|
973
|
+
if (!isCurrent()) return;
|
|
901
974
|
deps.topicTargetStore.setBotState({
|
|
902
975
|
threadMode: "disabled",
|
|
903
976
|
updatedAtMs: (deps.getNowMs ?? Date.now)(),
|
|
904
977
|
lastReconcileAction: `${phase}-classic-restore-failed`,
|
|
905
978
|
});
|
|
906
979
|
await deps.topicTargetStore.persist();
|
|
980
|
+
if (!isCurrent()) return;
|
|
907
981
|
deps.recordEvent("bus", classicError, {
|
|
908
982
|
phase: `${phase}-classic-restore`,
|
|
909
983
|
});
|
|
@@ -919,14 +993,18 @@ export async function applyTelegramThreadCapability<TContext>(
|
|
|
919
993
|
lastReconcileAction: phase,
|
|
920
994
|
});
|
|
921
995
|
await deps.topicTargetStore.persist();
|
|
996
|
+
if (!isCurrent()) return;
|
|
922
997
|
deps.setTopicModeUnavailable(false);
|
|
923
998
|
if (!deps.getPollingStartedWithTelegramBus() && deps.ownsLock(ctx)) {
|
|
924
999
|
await deps.stopClassicPolling();
|
|
1000
|
+
if (!isCurrent()) return;
|
|
925
1001
|
deps.setPollingStartedWithTelegramBus(true);
|
|
926
1002
|
try {
|
|
927
1003
|
await deps.startBusPolling(ctx);
|
|
1004
|
+
if (!isCurrent()) return;
|
|
928
1005
|
deps.startLeaderHealth();
|
|
929
1006
|
} catch (error) {
|
|
1007
|
+
if (!isCurrent()) return;
|
|
930
1008
|
deps.setPollingStartedWithTelegramBus(false);
|
|
931
1009
|
const threadModeUnavailable =
|
|
932
1010
|
deps.isTopicModeUnavailableError?.(error) === true;
|
|
@@ -937,17 +1015,21 @@ export async function applyTelegramThreadCapability<TContext>(
|
|
|
937
1015
|
lastReconcileAction: `${phase}-unavailable`,
|
|
938
1016
|
});
|
|
939
1017
|
await deps.topicTargetStore.persist();
|
|
1018
|
+
if (!isCurrent()) return;
|
|
940
1019
|
deps.setTopicModeUnavailable(true);
|
|
941
1020
|
}
|
|
942
1021
|
try {
|
|
943
1022
|
await deps.startClassicPolling(ctx);
|
|
1023
|
+
if (!isCurrent()) return;
|
|
944
1024
|
} catch (classicError) {
|
|
1025
|
+
if (!isCurrent()) return;
|
|
945
1026
|
deps.topicTargetStore.setBotState({
|
|
946
1027
|
threadMode: "disabled",
|
|
947
1028
|
updatedAtMs: (deps.getNowMs ?? Date.now)(),
|
|
948
1029
|
lastReconcileAction: `${phase}-classic-restore-failed`,
|
|
949
1030
|
});
|
|
950
1031
|
await deps.topicTargetStore.persist();
|
|
1032
|
+
if (!isCurrent()) return;
|
|
951
1033
|
deps.recordEvent("bus", classicError, {
|
|
952
1034
|
phase: `${phase}-classic-restore`,
|
|
953
1035
|
});
|
|
@@ -963,17 +1045,25 @@ export async function applyTelegramThreadCapability<TContext>(
|
|
|
963
1045
|
export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
|
|
964
1046
|
deps: TelegramThreadAwarePollingDeps<TContext, TOwner>,
|
|
965
1047
|
): TelegramThreadAwarePollingPorts<TContext, TOwner> {
|
|
1048
|
+
let generation = 0;
|
|
966
1049
|
const startPolling = async (
|
|
967
1050
|
ctx: TContext,
|
|
968
1051
|
options?: { forceFreshLeaderThread?: boolean },
|
|
969
1052
|
): Promise<void> => {
|
|
1053
|
+
const expectedGeneration = ++generation;
|
|
1054
|
+
deps.lifecycle?.invalidate();
|
|
1055
|
+
const isLifecycleCurrent = deps.lifecycle?.capture() ?? (() => true);
|
|
1056
|
+
const isCurrent = () => expectedGeneration === generation && isLifecycleCurrent();
|
|
970
1057
|
await deps.topicTargetStore.load();
|
|
1058
|
+
if (!isCurrent()) return;
|
|
971
1059
|
let startupThreadCapability: boolean | undefined;
|
|
972
1060
|
try {
|
|
973
|
-
startupThreadCapability = await probeTelegramStartupThreadCapability(deps);
|
|
1061
|
+
startupThreadCapability = await probeTelegramStartupThreadCapability(deps, isCurrent);
|
|
974
1062
|
} catch (error) {
|
|
1063
|
+
if (!isCurrent()) return;
|
|
975
1064
|
deps.recordEvent("bus", error, { phase: "startup-thread-mode-probe" });
|
|
976
1065
|
}
|
|
1066
|
+
if (!isCurrent()) return;
|
|
977
1067
|
deps.setTopicModeUnavailable(startupThreadCapability !== true);
|
|
978
1068
|
if (deps.isBusRuntimeEnabled()) {
|
|
979
1069
|
deps.setTopicModeUnavailable(false);
|
|
@@ -983,32 +1073,39 @@ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
|
|
|
983
1073
|
!!options?.forceFreshLeaderThread,
|
|
984
1074
|
);
|
|
985
1075
|
await deps.startBusLeaderPolling(ctx);
|
|
1076
|
+
if (!isCurrent()) return;
|
|
986
1077
|
deps.startLeaderHealth();
|
|
987
1078
|
return;
|
|
988
1079
|
} catch (error) {
|
|
1080
|
+
if (!isCurrent()) return;
|
|
989
1081
|
deps.setPollingStartedWithTelegramBus(false);
|
|
990
1082
|
if (!deps.isTopicModeUnavailableError(error)) throw error;
|
|
991
1083
|
deps.setTopicModeUnavailable(true);
|
|
992
1084
|
await deps.topicTargetStore.load();
|
|
1085
|
+
if (!isCurrent()) return;
|
|
993
1086
|
deps.topicTargetStore.setBotState({
|
|
994
1087
|
threadMode: "disabled",
|
|
995
1088
|
updatedAtMs: Date.now(),
|
|
996
1089
|
lastReconcileAction: "thread-mode-unavailable",
|
|
997
1090
|
});
|
|
998
1091
|
await deps.topicTargetStore.persist();
|
|
1092
|
+
if (!isCurrent()) return;
|
|
999
1093
|
deps.recordEvent("bus", error, { phase: "thread-mode-unavailable" });
|
|
1000
1094
|
} finally {
|
|
1001
|
-
deps.setForceFreshLeaderThreadOnNextStart(false);
|
|
1095
|
+
if (isCurrent()) deps.setForceFreshLeaderThreadOnNextStart(false);
|
|
1002
1096
|
}
|
|
1003
1097
|
}
|
|
1004
1098
|
deps.setPollingStartedWithTelegramBus(false);
|
|
1005
1099
|
await deps.startClassicPolling(ctx);
|
|
1006
1100
|
};
|
|
1007
1101
|
const stopPolling = async (): Promise<void> => {
|
|
1102
|
+
const expectedGeneration = ++generation;
|
|
1103
|
+
deps.lifecycle?.invalidate();
|
|
1104
|
+
deps.setForceFreshLeaderThreadOnNextStart(false);
|
|
1105
|
+
deps.stopLeaderHealth();
|
|
1008
1106
|
if (deps.getPollingStartedWithTelegramBus()) {
|
|
1009
|
-
deps.stopLeaderHealth();
|
|
1010
1107
|
await deps.stopBusLeaderPolling();
|
|
1011
|
-
deps.setPollingStartedWithTelegramBus(false);
|
|
1108
|
+
if (expectedGeneration === generation) deps.setPollingStartedWithTelegramBus(false);
|
|
1012
1109
|
return;
|
|
1013
1110
|
}
|
|
1014
1111
|
await deps.stopClassicPolling();
|
|
@@ -1043,14 +1140,17 @@ export function createTelegramThreadTargetObservationHandler<TContext>(
|
|
|
1043
1140
|
if (transitionPending) return;
|
|
1044
1141
|
if (deps.topicTargetStore.getBotState().threadMode === "enabled") return;
|
|
1045
1142
|
transitionPending = true;
|
|
1143
|
+
const isCurrent = deps.lifecycle?.capture() ?? (() => true);
|
|
1046
1144
|
try {
|
|
1047
1145
|
await applyTelegramThreadCapability(
|
|
1048
1146
|
ctx,
|
|
1049
1147
|
true,
|
|
1050
1148
|
"thread-target-observed",
|
|
1051
1149
|
deps,
|
|
1150
|
+
isCurrent,
|
|
1052
1151
|
);
|
|
1053
1152
|
} catch (error) {
|
|
1153
|
+
if (!isCurrent()) return;
|
|
1054
1154
|
deps.recordEvent("bus", error, { phase: "thread-target-observed" });
|
|
1055
1155
|
} finally {
|
|
1056
1156
|
transitionPending = false;
|
|
@@ -1079,6 +1179,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
|
|
|
1079
1179
|
let consecutiveDisabledProbes = 0;
|
|
1080
1180
|
const stop = (): void => {
|
|
1081
1181
|
generation += 1;
|
|
1182
|
+
deps.lifecycle?.invalidate();
|
|
1082
1183
|
if (interval) clearInterval(interval);
|
|
1083
1184
|
interval = undefined;
|
|
1084
1185
|
};
|
|
@@ -1087,7 +1188,8 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
|
|
|
1087
1188
|
return;
|
|
1088
1189
|
}
|
|
1089
1190
|
const expectedGeneration = generation;
|
|
1090
|
-
const
|
|
1191
|
+
const isLifecycleCurrent = deps.lifecycle?.capture() ?? (() => true);
|
|
1192
|
+
const isCurrent = (): boolean => generation === expectedGeneration && isLifecycleCurrent();
|
|
1091
1193
|
let tracked: Promise<void>;
|
|
1092
1194
|
tracked = readTelegramThreadCapability(deps)
|
|
1093
1195
|
.then(async (threadModeEnabled) => {
|
|
@@ -1103,6 +1205,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
|
|
|
1103
1205
|
true,
|
|
1104
1206
|
"capability-monitor-retry",
|
|
1105
1207
|
deps,
|
|
1208
|
+
isCurrent,
|
|
1106
1209
|
);
|
|
1107
1210
|
}
|
|
1108
1211
|
return;
|
|
@@ -1123,6 +1226,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
|
|
|
1123
1226
|
false,
|
|
1124
1227
|
"capability-monitor-disabled-confirmed",
|
|
1125
1228
|
deps,
|
|
1229
|
+
isCurrent,
|
|
1126
1230
|
);
|
|
1127
1231
|
return;
|
|
1128
1232
|
}
|
|
@@ -1153,6 +1257,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
|
|
|
1153
1257
|
? "capability-monitor-disabled-confirmed"
|
|
1154
1258
|
: "capability-monitor-disabled",
|
|
1155
1259
|
deps,
|
|
1260
|
+
isCurrent,
|
|
1156
1261
|
);
|
|
1157
1262
|
})
|
|
1158
1263
|
.catch((error) => {
|
|
@@ -1526,6 +1631,19 @@ export async function runTelegramPollLoop<
|
|
|
1526
1631
|
TContext = unknown,
|
|
1527
1632
|
>(deps: TelegramPollLoopDeps<TUpdate, TContext>): Promise<void> {
|
|
1528
1633
|
if (!deps.config.botToken) return;
|
|
1634
|
+
let consecutiveGetUpdatesConflicts = 0;
|
|
1635
|
+
const retryConflict = async () => {
|
|
1636
|
+
consecutiveGetUpdatesConflicts += 1;
|
|
1637
|
+
if (consecutiveGetUpdatesConflicts >= TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT) {
|
|
1638
|
+
throw new TelegramPersistentGetUpdatesConflictError(consecutiveGetUpdatesConflicts);
|
|
1639
|
+
}
|
|
1640
|
+
await deps.sleep(
|
|
1641
|
+
consecutiveGetUpdatesConflicts < TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT
|
|
1642
|
+
? TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS
|
|
1643
|
+
: TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS,
|
|
1644
|
+
deps.signal,
|
|
1645
|
+
);
|
|
1646
|
+
};
|
|
1529
1647
|
try {
|
|
1530
1648
|
await deps.deleteWebhook(deps.signal);
|
|
1531
1649
|
} catch {
|
|
@@ -1564,7 +1682,9 @@ export async function runTelegramPollLoop<
|
|
|
1564
1682
|
} catch (error) {
|
|
1565
1683
|
if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
|
|
1566
1684
|
reportTelegramPollingPhase(deps, "retrying");
|
|
1567
|
-
|
|
1685
|
+
if (isTelegramGetUpdatesConflictError(error)) {
|
|
1686
|
+
await retryConflict();
|
|
1687
|
+
} else deps.recordRuntimeEvent?.("polling", error, {
|
|
1568
1688
|
phase: "initial-sync",
|
|
1569
1689
|
...(error instanceof TelegramGetUpdatesTimeoutError
|
|
1570
1690
|
? { timeoutMs: error.timeoutMs }
|
|
@@ -1572,7 +1692,6 @@ export async function runTelegramPollLoop<
|
|
|
1572
1692
|
});
|
|
1573
1693
|
}
|
|
1574
1694
|
}
|
|
1575
|
-
let consecutiveGetUpdatesConflicts = 0;
|
|
1576
1695
|
let currentUpdateId: number | undefined;
|
|
1577
1696
|
while (!deps.signal.aborted) {
|
|
1578
1697
|
try {
|
|
@@ -1600,6 +1719,10 @@ export async function runTelegramPollLoop<
|
|
|
1600
1719
|
} catch (error) {
|
|
1601
1720
|
if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
|
|
1602
1721
|
reportTelegramPollingPhase(deps, "retrying", currentUpdateId);
|
|
1722
|
+
if (isTelegramGetUpdatesConflictError(error)) {
|
|
1723
|
+
await retryConflict();
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1603
1726
|
deps.recordRuntimeEvent?.("polling", error, {
|
|
1604
1727
|
phase:
|
|
1605
1728
|
error instanceof TelegramGetUpdatesTimeoutError
|
|
@@ -1609,17 +1732,6 @@ export async function runTelegramPollLoop<
|
|
|
1609
1732
|
? { timeoutMs: error.timeoutMs }
|
|
1610
1733
|
: {}),
|
|
1611
1734
|
});
|
|
1612
|
-
if (isTelegramGetUpdatesConflictError(error)) {
|
|
1613
|
-
consecutiveGetUpdatesConflicts += 1;
|
|
1614
|
-
await deps.sleep(
|
|
1615
|
-
consecutiveGetUpdatesConflicts <
|
|
1616
|
-
TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT
|
|
1617
|
-
? TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS
|
|
1618
|
-
: TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS,
|
|
1619
|
-
deps.signal,
|
|
1620
|
-
);
|
|
1621
|
-
continue;
|
|
1622
|
-
}
|
|
1623
1735
|
consecutiveGetUpdatesConflicts = 0;
|
|
1624
1736
|
deps.onErrorStatus(getTelegramPollingErrorMessage(error));
|
|
1625
1737
|
await deps.sleep(TELEGRAM_POLLING_RETRY_MS, deps.signal);
|
|
@@ -152,7 +152,7 @@ type TelegramBeforeAgentStartEvent = Omit<
|
|
|
152
152
|
BeforeAgentStartEvent,
|
|
153
153
|
"systemPrompt"
|
|
154
154
|
> & {
|
|
155
|
-
systemPrompt
|
|
155
|
+
systemPrompt?: TelegramSystemPrompt | null;
|
|
156
156
|
};
|
|
157
157
|
|
|
158
158
|
type TelegramBeforeAgentStartResult = {
|
|
@@ -165,11 +165,12 @@ type TelegramBeforeAgentStartHook = (
|
|
|
165
165
|
|
|
166
166
|
export function buildTelegramBridgeSystemPrompt(options: {
|
|
167
167
|
prompt: string;
|
|
168
|
-
systemPrompt
|
|
168
|
+
systemPrompt?: TelegramSystemPrompt | null;
|
|
169
169
|
telegramPrefix?: string;
|
|
170
170
|
localSystemPromptSuffix: string;
|
|
171
171
|
telegramTurnSystemPromptSuffix: string;
|
|
172
172
|
}): TelegramBeforeAgentStartResult {
|
|
173
|
+
const basePrompt = options.systemPrompt ?? "";
|
|
173
174
|
const telegramPrefix = options.telegramPrefix ?? TELEGRAM_PREFIX;
|
|
174
175
|
const telegramHead = telegramPrefix.endsWith("]")
|
|
175
176
|
? telegramPrefix.slice(0, -1)
|
|
@@ -182,12 +183,12 @@ export function buildTelegramBridgeSystemPrompt(options: {
|
|
|
182
183
|
? `${options.telegramTurnSystemPromptSuffix}\n- The current user message came from Telegram.`
|
|
183
184
|
: "";
|
|
184
185
|
return {
|
|
185
|
-
systemPrompt: Array.isArray(
|
|
186
|
+
systemPrompt: Array.isArray(basePrompt)
|
|
186
187
|
? [
|
|
187
|
-
...
|
|
188
|
+
...basePrompt,
|
|
188
189
|
options.localSystemPromptSuffix + telegramSuffix,
|
|
189
190
|
]
|
|
190
|
-
:
|
|
191
|
+
: basePrompt +
|
|
191
192
|
options.localSystemPromptSuffix +
|
|
192
193
|
telegramSuffix,
|
|
193
194
|
};
|
|
@@ -221,8 +222,9 @@ function stripTelegramToolMetadataFromString(systemPrompt: string): string {
|
|
|
221
222
|
}
|
|
222
223
|
|
|
223
224
|
function stripTelegramToolMetadataFromSystemPrompt(
|
|
224
|
-
systemPrompt: TelegramSystemPrompt,
|
|
225
|
+
systemPrompt: TelegramSystemPrompt | null | undefined,
|
|
225
226
|
): TelegramSystemPrompt {
|
|
227
|
+
if (!systemPrompt) return "";
|
|
226
228
|
return Array.isArray(systemPrompt)
|
|
227
229
|
? systemPrompt.map(stripTelegramToolMetadataFromString)
|
|
228
230
|
: stripTelegramToolMetadataFromString(systemPrompt);
|
|
@@ -1908,6 +1908,8 @@ export async function handleTelegramAgentEndRuntime<
|
|
|
1908
1908
|
if (!isDeliveryActive()) return;
|
|
1909
1909
|
if (richAttachmentDelivered) {
|
|
1910
1910
|
await deps.clearPreview(turn.chatId, { target: turn.target });
|
|
1911
|
+
if (!isDeliveryActive()) return;
|
|
1912
|
+
deps.setPreviewPendingText("");
|
|
1911
1913
|
}
|
|
1912
1914
|
} catch (error) {
|
|
1913
1915
|
deps.recordRuntimeEvent?.("delivery", error, {
|
|
@@ -1938,6 +1940,8 @@ export async function handleTelegramAgentEndRuntime<
|
|
|
1938
1940
|
{ replyMarkup, target: turn.target },
|
|
1939
1941
|
);
|
|
1940
1942
|
}
|
|
1943
|
+
if (!isDeliveryActive()) return;
|
|
1944
|
+
deps.setPreviewPendingText("");
|
|
1941
1945
|
} catch (error) {
|
|
1942
1946
|
deps.recordRuntimeEvent?.("delivery", error, {
|
|
1943
1947
|
phase: "final-text",
|
|
@@ -291,6 +291,7 @@ export interface TelegramStatusBarTheme {
|
|
|
291
291
|
export interface TelegramStatusBarState {
|
|
292
292
|
hasBotToken: boolean;
|
|
293
293
|
pollingActive: boolean;
|
|
294
|
+
pollingStopReason?: string;
|
|
294
295
|
paired: boolean;
|
|
295
296
|
busRole?: TelegramBridgeBusRole;
|
|
296
297
|
busLifecyclePhase?: TelegramBridgeBusLifecyclePhase;
|
|
@@ -699,6 +700,7 @@ export function createTelegramBridgeStatusRuntime<
|
|
|
699
700
|
queuedItems: queuedItemCount,
|
|
700
701
|
}),
|
|
701
702
|
queuedStatus: deps.formatQueuedStatus(queuedItems),
|
|
703
|
+
pollingStopReason: deps.getPollingState?.().stopReason,
|
|
702
704
|
error,
|
|
703
705
|
};
|
|
704
706
|
},
|
|
@@ -889,6 +891,8 @@ export function buildTelegramStatusBarText(
|
|
|
889
891
|
: "";
|
|
890
892
|
if (!state.hasBotToken)
|
|
891
893
|
return `${label} ${theme.fg("muted", "not configured")}${queued}`;
|
|
894
|
+
if (state.pollingStopReason === "persistent-conflict" && state.busRole !== "follower")
|
|
895
|
+
return `${label} ${theme.fg("error", "error")}`;
|
|
892
896
|
if (!state.paired)
|
|
893
897
|
return `${label} ${theme.fg("warning", "awaiting pairing")}${queued}`;
|
|
894
898
|
if (state.busLifecyclePhase === "electing")
|
|
@@ -2160,6 +2160,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2160
2160
|
failurePhase: string,
|
|
2161
2161
|
error: unknown,
|
|
2162
2162
|
currentUpdateId?: number,
|
|
2163
|
+
extraDetails?: Record<string, unknown>,
|
|
2163
2164
|
): "blocked" => {
|
|
2164
2165
|
blocked = true;
|
|
2165
2166
|
state.lastFailureAtMs = getNowMs();
|
|
@@ -2168,6 +2169,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2168
2169
|
phase: failurePhase,
|
|
2169
2170
|
generation: owner?.generation,
|
|
2170
2171
|
...(currentUpdateId !== undefined ? { updateId: currentUpdateId } : {}),
|
|
2172
|
+
...extraDetails,
|
|
2171
2173
|
});
|
|
2172
2174
|
transition("blocked", currentUpdateId, blockedReason);
|
|
2173
2175
|
return "blocked";
|
|
@@ -2553,6 +2555,10 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2553
2555
|
`Telegram queue receipt ${normalized.receiptId} conflicts with committed authority.`,
|
|
2554
2556
|
),
|
|
2555
2557
|
currentUpdateId,
|
|
2558
|
+
{
|
|
2559
|
+
receiptId: normalized.receiptId,
|
|
2560
|
+
sourceUpdateIds: normalized.sourceUpdateIds,
|
|
2561
|
+
},
|
|
2556
2562
|
);
|
|
2557
2563
|
}
|
|
2558
2564
|
return "duplicate";
|
|
@@ -13,4 +13,6 @@ These slash commands are registered Pi commands, not shell executables or agent
|
|
|
13
13
|
|
|
14
14
|
When `PI_CODING_AGENT_DIR` selects another compatible runtime, resolve its equivalent `tmp/telegram` directory.
|
|
15
15
|
|
|
16
|
+
A `persistent-conflict` polling stop means the bounded competing-`getUpdates` threshold triggered full transport stand-down, not cancellation of accepted local Pi work. The terminal diagnostic distinguishes lost local ownership from a competing client despite an apparently owned lock. Check other profiles, agent directories, installations, or non-Pi clients sharing the bot; after removing the competition, reconnect through the supported Pi command. Do not restart repeatedly or alter lock files to compete for the stream.
|
|
17
|
+
|
|
16
18
|
Do not mutate ownership files, bridge state, journals, bindings, or locks to force recovery. Use supported commands and preserve exact profile, target, transport, and session authority. Never claim successful delivery without transport evidence.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-kit",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"@llblab/pi-codex-usage": "0.9.4",
|
|
46
46
|
"@llblab/pi-grow-loop": "0.7.4",
|
|
47
47
|
"@llblab/pi-state-flow": "0.3.0",
|
|
48
|
-
"@llblab/pi-telegram": "0.43.
|
|
48
|
+
"@llblab/pi-telegram": "0.43.1",
|
|
49
49
|
"@llblab/skills": "1.14.0"
|
|
50
50
|
},
|
|
51
51
|
"bundledDependencies": [
|