@llblab/pi-kit 0.29.1 → 0.29.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/BACKLOG.md +2 -1
- package/CHANGELOG.md +5 -0
- package/README.md +4 -4
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +12 -0
- package/node_modules/@llblab/pi-state-flow/README.md +2 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +2 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +17 -8
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +7 -3
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +2 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +3 -2
- package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +3 -3
- package/node_modules/@llblab/pi-state-flow/docs/usage.md +3 -2
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +17 -8
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +6 -3
- package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +4 -2
- package/node_modules/@llblab/pi-state-flow/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +6 -5
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +18 -1
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +12 -0
- package/node_modules/@llblab/pi-telegram/README.md +5 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +4 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +4 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +7 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +72 -11
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +5 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +29 -11
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +12 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +25 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +118 -44
- package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +5 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +48 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +22 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +6 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +45 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +11 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +34 -18
- package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +14 -8
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +1 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +4 -2
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +5 -0
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +80 -11
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +28 -11
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +21 -2
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +26 -3
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +91 -36
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +47 -5
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +26 -1
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +46 -7
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +43 -21
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/package.json +3 -3
|
@@ -61,7 +61,7 @@ The connected Pi instance owns Telegram polling. Use `/telegram-connect <profile
|
|
|
61
61
|
|
|
62
62
|
An eligible single-text unbound Thread chooser offers **⛔️ Cancel routing**: the original stays private and is not sent to Pi; its Telegram message and Thread are not deleted. This does not cancel already selected or accepted work. When protected cancellation attempts remain, **Status → ❌ Pending cancellations** lets the owner review and finish them, including after restart. Historical unbound startup text has no main-menu entry or historical-review submenu. Eligible raw unsupported command/media/unknown historical private-Thread originals use a separate retain-only path, including on bound Threads without surviving Restore/temporary membership. Each worker generation reclassifies them after cold forgetting, blocking handler replay, generic Cancel and cold spending. Ordinary bound startup dispatch stops for these originals; same-kind live arrivals and owner/custody paths remain unchanged. Old historical-review buttons are refused without routing or abandoning the original; removal of the UI does not cancel previously accepted work.
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
Eligible routing choosers and their donor sources share a **fixed 60-minute lifetime** from the first acknowledged publication, preserved across refresh/restart. Expiry drops unselected or unacknowledged routing attempts without privately archiving prompt bodies; nondelivery is accepted, so resend manually if needed. Old controls and late replies cannot replay the source. An expired disposable unbound tab is removed after all its owned groups resolve and fresh protection checks pass. Accepted queues, independent work and restored bound Threads remain intact. Confirmed expiry releases worker custody even if tab metadata fails; retries use only body-free tab/source proof, and uncertain deletion is never repeated. Explicit Cancel retains its separate private-original policy. Historical originals without a saved clock and unconfirmed publication receive no invented deadline. Local candidate checks do not certify live deletion.
|
|
65
65
|
|
|
66
66
|
`/telegram-connect` keeps working when the shared runtime `state.json` is damaged. If it is malformed, foreign or contains invalid evidence, the connecting leader replaces it with a fresh empty state and continues. Every profile then forgets old Thread bindings, slots, unfinished Restore/temporary work and polling continuity; `telegram.json`, Pi history and the released `tmp/telegram` are untouched. Permission errors still stop connection. See [Damaged-State Reset](./docs/architecture.md#damaged-state-reset-operator-approved).
|
|
67
67
|
|
|
@@ -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
|
|
@@ -214,11 +216,12 @@ Queue policy:
|
|
|
214
216
|
- Priority and Normal are separate FIFO lanes; Priority dispatches first.
|
|
215
217
|
- Moving `Normal → Priority` removes the prompt from Normal and places it at the Priority tail. Moving `Priority → Normal` removes it from Priority and places it at the Normal tail; no former position is restored.
|
|
216
218
|
- Keep/Skip never changes lane position. Skip preserves durable authority while waiting so Keep remains reversible, then settles that authority and drops the prompt without a model turn when dispatch reaches it. Skipped prompts stay visible at their physical queue position with a struck-through ordinal, but are excluded immediately from the executable queue count shown in both the Pi status bar and Telegram main menu. Graceful session shutdown discards all remaining queued authority, so a new session starts empty.
|
|
217
|
-
-
|
|
219
|
+
- For ordinary prompts, reactions control two independent dimensions; changing one category preserves the other:
|
|
218
220
|
- `Positive`: `👍`, `⚡️`, `❤️`, `🕊`, `🔥` — controls Priority.
|
|
219
221
|
- `Negative`: `👎`, `👻`, `💔`, `💩`, `🗑` — controls Skip.
|
|
220
222
|
- Priority and Skip can coexist—for example `👍 + 💩`. Skip wins at dispatch, regardless of which negative emoji is selected.
|
|
221
223
|
- Menu selectors and reactions share queue state, but the bot cannot remove a user's reaction; Keep may clear internal Skip while the user's emoji remains visible until they remove it.
|
|
224
|
+
- A negative reaction to your original standalone `/continue` immediately cancels its exact waiting continuation, even while other work is active. This is irreversible cancellation, not reversible Skip. Positive reactions do not change its control lane; already-dispatched or running work and synthetic model-switch continuations are untouched.
|
|
222
225
|
|
|
223
226
|
The detailed contract lives in [Priority, Reactions, Keep, and Skip](./docs/architecture.md#priority-reactions-keep-and-skip). If Pi automatically retries a transient provider failure, the active Telegram turn stays bound until the successful reply arrives or Pi confirms that the run has settled.
|
|
224
227
|
|
|
@@ -34,6 +34,7 @@ export function createTelegramQueueBindingRuntime(deps) {
|
|
|
34
34
|
};
|
|
35
35
|
const mutation = Queue.createTelegramQueueMutationController({
|
|
36
36
|
...deps.store,
|
|
37
|
+
hasPendingDispatch: deps.lifecycle.hasDispatchPending,
|
|
37
38
|
allocateLaneOrder: deps.queue.allocateItemOrder,
|
|
38
39
|
onItemsDiscarded(items, ctx) {
|
|
39
40
|
if (!settleDiscardedItems(items, ctx)) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Wraps the direct Telegram Bot API runtime so follower instances can route outbound calls through the bus leader
|
|
5
5
|
*/
|
|
6
6
|
import { markTelegramBusCrossTargetDelivery, stripTelegramBusApiMetadata, } from "./bus.js";
|
|
7
|
-
import { buildTelegramAnswerGuestQueryBody, isTelegramMessageNotModifiedError, } from "./telegram-api.js";
|
|
7
|
+
import { buildTelegramAnswerGuestQueryBody, formatTelegramCallbackAnswerText, isTelegramMessageNotModifiedError, } from "./telegram-api.js";
|
|
8
8
|
function asRecord(value) {
|
|
9
9
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
10
10
|
? value
|
|
@@ -201,15 +201,16 @@ export function createTelegramBusAwareApiRuntime(deps) {
|
|
|
201
201
|
]);
|
|
202
202
|
},
|
|
203
203
|
async answerCallbackQuery(callbackQueryId, text) {
|
|
204
|
+
const answer = formatTelegramCallbackAnswerText(text);
|
|
204
205
|
if (deps.ownsDirect()) {
|
|
205
|
-
await deps.directRuntime.answerCallbackQuery(callbackQueryId,
|
|
206
|
+
await deps.directRuntime.answerCallbackQuery(callbackQueryId, answer);
|
|
206
207
|
return;
|
|
207
208
|
}
|
|
208
209
|
await deps.callFollowerApi("call", [
|
|
209
210
|
"answerCallbackQuery",
|
|
210
211
|
{
|
|
211
212
|
callback_query_id: callbackQueryId,
|
|
212
|
-
...(
|
|
213
|
+
...(answer !== undefined ? { text: answer } : {}),
|
|
213
214
|
},
|
|
214
215
|
]);
|
|
215
216
|
},
|
|
@@ -943,6 +943,9 @@ export default function (pi) {
|
|
|
943
943
|
return telegramJournalBindingRuntime.inspectSourceAbandonment(journalBindingKey, updateId);
|
|
944
944
|
});
|
|
945
945
|
},
|
|
946
|
+
inspectRoutingInputGroupExpiry(input) {
|
|
947
|
+
return telegramJournalReferenceRegistry.withReference({ referenceClass: "operator-disposition", recoveryKey: input.journalBindingKey }, function () { return telegramJournalBindingRuntime.inspectSourceGroupExpiry(input.journalBindingKey, input.updateIds); });
|
|
948
|
+
},
|
|
946
949
|
inspectTemporaryThreadSources(_target, requiredJournalBindingKeys) {
|
|
947
950
|
const binding = resolveTelegramUpdateJournalBinding();
|
|
948
951
|
const botToken = configStore.getBotToken();
|
|
@@ -1548,6 +1551,7 @@ export default function (pi) {
|
|
|
1548
1551
|
getSyncState: telegramSyncStateRuntime.getState,
|
|
1549
1552
|
setSyncState: telegramSyncStateRuntime.setState,
|
|
1550
1553
|
stopPolling: lockedPollingRuntime.stop,
|
|
1554
|
+
captureStopPolling: lockedPollingRuntime.captureStop,
|
|
1551
1555
|
suspendPolling: lockedPollingRuntime.suspend,
|
|
1552
1556
|
recordRuntimeEvent,
|
|
1553
1557
|
runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
|
|
@@ -374,6 +374,9 @@ export interface TelegramRoutingInputAuthority {
|
|
|
374
374
|
operatorUserId: number;
|
|
375
375
|
isCurrent(): boolean;
|
|
376
376
|
}
|
|
377
|
+
/** Expiry drops a donor attempt without retaining its body or claiming recipient execution/cancellation. */
|
|
378
|
+
export type TelegramRoutingInputExpiryResult = Omit<TelegramUpdateJournalPendingAbandonmentResult, "retainedPath">;
|
|
379
|
+
export type TelegramRoutingInputExpiryEvidence = Omit<TelegramUpdateJournalAbandonedPendingEvidence, "retainedPath">;
|
|
377
380
|
/** Package-private v1 capability; raw input custody does not expose it. */
|
|
378
381
|
export interface TelegramRoutingInputJournal {
|
|
379
382
|
arm(input: TelegramRoutingInputAuthority & {
|
|
@@ -385,7 +388,9 @@ export interface TelegramRoutingInputJournal {
|
|
|
385
388
|
};
|
|
386
389
|
expire(input: Omit<TelegramRoutingInputAuthority, "entries"> & {
|
|
387
390
|
entry: TelegramUpdateJournalEntry;
|
|
388
|
-
}):
|
|
391
|
+
}): TelegramRoutingInputExpiryResult;
|
|
392
|
+
inspectExpiry(updateId: number): TelegramRoutingInputExpiryEvidence | undefined;
|
|
393
|
+
inspectGroupExpiry(updateIds: readonly number[]): TelegramRoutingInputExpiryEvidence[] | undefined;
|
|
389
394
|
}
|
|
390
395
|
export interface TelegramUpdateJournalStore {
|
|
391
396
|
routingInputs?: TelegramRoutingInputJournal;
|
|
@@ -804,6 +809,7 @@ export interface TelegramUpdateJournalBindingRuntime {
|
|
|
804
809
|
getActiveRecoveryKey: () => string | undefined;
|
|
805
810
|
/** Exact historical proof lookup; no store, admission, execution, recovery or mutation port. */
|
|
806
811
|
inspectSourceAbandonment: (journalBindingKey: string, updateId: number) => TelegramUpdateJournalAbandonedPendingEvidence | undefined;
|
|
812
|
+
inspectSourceGroupExpiry: (journalBindingKey: string, updateIds: readonly number[]) => TelegramRoutingInputExpiryEvidence[] | undefined;
|
|
807
813
|
inspectSourceCompletion: (journalBindingKey: string, expected: TelegramUpdateJournalSourceCompletion) => TelegramUpdateJournalSourceCompletion | undefined;
|
|
808
814
|
inspectQueuedReceipt: (journalBindingKey: string, expected: TelegramUpdateJournalQueuedCompletion) => TelegramUpdateJournalQueuedReceiptEvidence | undefined;
|
|
809
815
|
/** In-process `/new` succession: move unclaimed predecessor pending inputs into the successor session journal. */
|
|
@@ -285,6 +285,27 @@ export const TELEGRAM_ROUTING_INPUT_TTL_MS = 60 * 60 * 1000;
|
|
|
285
285
|
export function createTelegramUpdateJournalEntryDigest(entry) {
|
|
286
286
|
return { updateId: entry.updateId, sourceSha256: createHash("sha256").update(JSON.stringify(entry)).digest("hex") };
|
|
287
287
|
}
|
|
288
|
+
function inspectRoutingInputExpiry(file, journalBindingKey, updateId) {
|
|
289
|
+
const disposition = file.operatorDispositions?.find(value => value.updateId === updateId &&
|
|
290
|
+
"dispositionKind" in value && value.dispositionKind === "legacy-custody" && value.action === "discard" &&
|
|
291
|
+
value.failureId === `routing-expiry:${value.evidenceSha256}`);
|
|
292
|
+
if (!disposition || !("operatorAuthorityId" in disposition) || file.entries.some(entry => entry.updateId === updateId))
|
|
293
|
+
return undefined;
|
|
294
|
+
return { journalBindingKey, updateId, operatorAuthorityId: disposition.operatorAuthorityId };
|
|
295
|
+
}
|
|
296
|
+
function inspectRoutingGroupExpiry(file, journalBindingKey, updateIds) {
|
|
297
|
+
if (!Array.isArray(updateIds) || !updateIds.length || updateIds.length > TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES ||
|
|
298
|
+
updateIds.some((id, index) => !isSafeNonNegativeInteger(id) || (index > 0 && id <= updateIds[index - 1])))
|
|
299
|
+
return undefined;
|
|
300
|
+
if (file.entries.some(entry => updateIds.includes(entry.updateId)))
|
|
301
|
+
return undefined;
|
|
302
|
+
const expired = updateIds.map(id => inspectRoutingInputExpiry(file, journalBindingKey, id)).filter(value => value !== undefined);
|
|
303
|
+
if (!expired.length || expired.some(value => value.operatorAuthorityId !== expired[0].operatorAuthorityId))
|
|
304
|
+
return undefined;
|
|
305
|
+
// Some members may have been acknowledged earlier. Whole donor absence plus one exact expiry ends this known cohort,
|
|
306
|
+
// not recipient custody; fresh Thread protection still independently gates deletion.
|
|
307
|
+
return updateIds.map(updateId => ({ journalBindingKey, updateId, operatorAuthorityId: expired[0].operatorAuthorityId }));
|
|
308
|
+
}
|
|
288
309
|
function createJournalError(code, path, detail, cause) {
|
|
289
310
|
return new TelegramUpdateJournalError(code, path, `Telegram update journal ${detail}: ${path}`, cause === undefined ? undefined : { cause });
|
|
290
311
|
}
|
|
@@ -2064,6 +2085,9 @@ export function createTelegramUpdateJournalBindingRuntime(deps) {
|
|
|
2064
2085
|
return { journalBindingKey, updateId, retainedPath: original.path, operatorAuthorityId: disposition.operatorAuthorityId };
|
|
2065
2086
|
});
|
|
2066
2087
|
},
|
|
2088
|
+
inspectSourceGroupExpiry(journalBindingKey, updateIds) {
|
|
2089
|
+
return inspectHistoricalSource(journalBindingKey, file => inspectRoutingGroupExpiry(file, journalBindingKey, updateIds));
|
|
2090
|
+
},
|
|
2067
2091
|
inspectSourceCompletion(journalBindingKey, expected) {
|
|
2068
2092
|
const completion = validateJournalSourceCompletion(expected, getTelegramUpdateJournalBindingPath(journalBindingKey) ?? "journal");
|
|
2069
2093
|
return inspectHistoricalSource(journalBindingKey, (file, _originals, path) => inspectJournalSourceCompletion(file, completion, path));
|
|
@@ -3401,6 +3425,7 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
3401
3425
|
update: entry.update,
|
|
3402
3426
|
admittedAtMs: entry.admittedAtMs,
|
|
3403
3427
|
...(entry.preApprovalExcluded !== undefined ? { preApprovalExcluded: entry.preApprovalExcluded } : {}),
|
|
3428
|
+
...(entry.routingInput ? { routingInput: { ...entry.routingInput } } : {}),
|
|
3404
3429
|
state: "queued",
|
|
3405
3430
|
queueKind: receipt.queueKind,
|
|
3406
3431
|
queueReceiptId: receipt.receiptId,
|
|
@@ -4069,22 +4094,58 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
4069
4094
|
});
|
|
4070
4095
|
},
|
|
4071
4096
|
};
|
|
4072
|
-
const abandonRoutingPending = journal.abandonPending.bind(journal);
|
|
4073
4097
|
if (isTelegramUpdateJournalLegacyFamilyVersion(version))
|
|
4074
4098
|
journal.routingInputs = {
|
|
4075
4099
|
arm(input) { return mutateRoutingInputs(input, false, input.publishedAtMs).entries; },
|
|
4076
4100
|
select(input) { return mutateRoutingInputs(input, true); },
|
|
4101
|
+
inspectExpiry(updateId) {
|
|
4102
|
+
return runMutation(readCurrent => {
|
|
4103
|
+
if (!isSafeNonNegativeInteger(updateId))
|
|
4104
|
+
throw createJournalError("invalid", path, "invalid routing expiry update ID");
|
|
4105
|
+
return inspectRoutingInputExpiry(readCurrent().file, createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateId);
|
|
4106
|
+
});
|
|
4107
|
+
},
|
|
4108
|
+
inspectGroupExpiry(updateIds) {
|
|
4109
|
+
return runMutation(readCurrent => inspectRoutingGroupExpiry(readCurrent().file, createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity }), updateIds));
|
|
4110
|
+
},
|
|
4077
4111
|
expire(input) {
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4112
|
+
return runMutation(readCurrent => {
|
|
4113
|
+
const original = validateJournalEntry(input.entry, path, version), lifetime = original.routingInput;
|
|
4114
|
+
const binding = createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity: expectedIdentity });
|
|
4115
|
+
if (!lifetime || original.state !== "pending" || original.inputClaim || original.inputProvenance || original.queueOwner ||
|
|
4116
|
+
original.queueReceiptId || original.queueHandoff || original.failure || input.journalBindingKey !== binding ||
|
|
4117
|
+
lifetime.operatorUserId !== input.operatorUserId || !isSafePositiveInteger(input.operatorUserId) || typeof input.isCurrent !== "function") {
|
|
4118
|
+
throw createJournalError("conflict", path, "routing expiry requires an exact unclaimed chooser source");
|
|
4119
|
+
}
|
|
4120
|
+
const assertCurrent = () => {
|
|
4121
|
+
const now = getNowMs();
|
|
4122
|
+
if (!isSafeNonNegativeInteger(now) || now < lifetime.expiresAtMs || !input.isCurrent())
|
|
4123
|
+
throw createJournalError("conflict", path, "routing source has not expired under current authority");
|
|
4124
|
+
};
|
|
4125
|
+
assertCurrent();
|
|
4126
|
+
const current = sourceAccess ? readCurrent() : readCurrentStrict(false);
|
|
4127
|
+
const evidenceSha256 = createHash("sha256").update(JSON.stringify(original)).digest("hex");
|
|
4128
|
+
const failureId = `routing-expiry:${evidenceSha256}`;
|
|
4129
|
+
const existing = current.file.operatorDispositions?.find(value => value.updateId === original.updateId);
|
|
4130
|
+
const entry = current.file.entries.find(value => value.updateId === original.updateId);
|
|
4131
|
+
if (existing) {
|
|
4132
|
+
if (entry || !("dispositionKind" in existing) || existing.dispositionKind !== "legacy-custody" || existing.action !== "discard" ||
|
|
4133
|
+
existing.failureId !== failureId || existing.evidenceSha256 !== evidenceSha256 || existing.operatorAuthorityId !== `telegram-owner:${input.operatorUserId}`)
|
|
4134
|
+
throw createJournalError("conflict", path, "routing source has contradictory expiry evidence");
|
|
4135
|
+
assertCurrent();
|
|
4136
|
+
return { disposition: existing, duplicate: true, entryCount: current.file.entries.length, serializedBytes: current.serializedBytes };
|
|
4137
|
+
}
|
|
4138
|
+
if (!entry || !isDeepStrictEqual(entry, original))
|
|
4139
|
+
throw createJournalError("conflict", path, "routing source changed before expiry");
|
|
4140
|
+
// A discard tombstone prevents old readers and delayed reports from replaying; no prompt-body archive is written.
|
|
4141
|
+
const now = getNowMs();
|
|
4142
|
+
const disposition = { dispositionKind: "legacy-custody", failureId,
|
|
4143
|
+
updateId: original.updateId, action: "discard", committedAtMs: now, authorizedAtMs: now, evidenceSha256,
|
|
4144
|
+
operatorAuthorityId: `telegram-owner:${input.operatorUserId}` };
|
|
4145
|
+
const published = publishMutation(current, current.file.entries.filter(value => value.updateId !== original.updateId), true, [...(current.file.operatorDispositions ?? []), disposition], current.file.acceptedThroughUpdateId, (boundary, target) => { onPublicationBoundary?.(boundary, target); assertCurrent(); });
|
|
4146
|
+
assertCurrent();
|
|
4147
|
+
return { disposition, duplicate: false, entryCount: published.file.entries.length, serializedBytes: published.serializedBytes };
|
|
4148
|
+
});
|
|
4088
4149
|
},
|
|
4089
4150
|
};
|
|
4090
4151
|
if (!getInputContext)
|
|
@@ -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. */
|
|
@@ -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) {
|
|
@@ -28,6 +28,8 @@ export declare class TelegramPersistentGetUpdatesConflictError extends Error {
|
|
|
28
28
|
readonly count: number;
|
|
29
29
|
constructor(count: number);
|
|
30
30
|
}
|
|
31
|
+
/** Exponential backoff for non-conflict polling/admission failures. */
|
|
32
|
+
export declare function getTelegramPollingRetryDelayMs(consecutiveFailures: number): number;
|
|
31
33
|
export declare class TelegramGetUpdatesTimeoutError extends Error {
|
|
32
34
|
readonly timeoutMs: number;
|
|
33
35
|
constructor(timeoutMs: number);
|
|
@@ -14,7 +14,8 @@ export const TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT = 10;
|
|
|
14
14
|
const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
|
|
15
15
|
const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
|
|
16
16
|
const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
|
|
17
|
-
const
|
|
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();
|
|
@@ -613,11 +613,13 @@ export type TelegramSessionLifecycleHookEvent = unknown;
|
|
|
613
613
|
export declare function createTelegramSessionStateApplier<TQueueItem, TModel>(deps: TelegramSessionStateApplierDeps<TQueueItem, TModel>): TelegramSessionStateApplier<TQueueItem, TModel>;
|
|
614
614
|
export interface TelegramQueueMutationRuntimeDeps<TContext> extends TelegramQueueStore<TContext>, TelegramRuntimeEventRecorderPort {
|
|
615
615
|
ctx: TContext;
|
|
616
|
+
hasPendingDispatch?: () => boolean;
|
|
616
617
|
allocateLaneOrder?: () => number;
|
|
617
618
|
onItemsDiscarded?: (items: readonly TelegramQueueItem<TContext>[], ctx: TContext) => void;
|
|
618
619
|
updateStatus: (ctx: TContext) => void;
|
|
619
620
|
}
|
|
620
621
|
export interface TelegramQueueMutationControllerDeps<TContext> extends TelegramQueueStore<TContext>, TelegramRuntimeEventRecorderPort {
|
|
622
|
+
hasPendingDispatch?: () => boolean;
|
|
621
623
|
allocateLaneOrder?: () => number;
|
|
622
624
|
onItemsDiscarded?: (items: readonly TelegramQueueItem<TContext>[], ctx: TContext) => void;
|
|
623
625
|
updateStatus: (ctx: TContext) => void;
|
|
@@ -582,7 +582,7 @@ function removeTelegramQueuedGuestPromptByOrder(items, queueOrder) {
|
|
|
582
582
|
export function applyTelegramQueuePromptReactionDisposition(items, messageId, disposition, destinationLaneOrder, scope) {
|
|
583
583
|
let nextItems = items;
|
|
584
584
|
for (const [index, item] of items.entries()) {
|
|
585
|
-
if (!isPendingTelegramTurn(item) ||
|
|
585
|
+
if (!isPendingTelegramTurn(item) || item.queueLane === "control" ||
|
|
586
586
|
!isTelegramQueueItemInMessageScope(item, scope) ||
|
|
587
587
|
!item.sourceMessageIds.includes(messageId)) {
|
|
588
588
|
continue;
|
|
@@ -1550,7 +1550,15 @@ function updateTelegramQueueStatusRuntime(deps) {
|
|
|
1550
1550
|
}
|
|
1551
1551
|
}
|
|
1552
1552
|
function commitReorderedTelegramQueueItemsRuntime(items, deps) {
|
|
1553
|
-
deps.
|
|
1553
|
+
const pendingHead = deps.hasPendingDispatch?.() ? deps.getQueuedItems()[0] : undefined;
|
|
1554
|
+
const ordered = [...items].sort(compareTelegramQueueItems);
|
|
1555
|
+
if (pendingHead) {
|
|
1556
|
+
const index = ordered.findIndex(item => item.queueOrder === pendingHead.queueOrder && item.kind === pendingHead.kind &&
|
|
1557
|
+
item.chatId === pendingHead.chatId && item.replyToMessageId === pendingHead.replyToMessageId);
|
|
1558
|
+
if (index > 0)
|
|
1559
|
+
ordered.unshift(ordered.splice(index, 1)[0]);
|
|
1560
|
+
}
|
|
1561
|
+
deps.setQueuedItems(ordered);
|
|
1554
1562
|
updateTelegramQueueStatusRuntime(deps);
|
|
1555
1563
|
}
|
|
1556
1564
|
function appendTelegramQueueItemRuntime(item, deps) {
|
|
@@ -1602,8 +1610,22 @@ function removeTelegramQueuedGuestPromptByOrderRuntime(queueOrder, deps) {
|
|
|
1602
1610
|
}
|
|
1603
1611
|
export function applyTelegramQueuePromptReactionDispositionRuntime(messageId, disposition, deps, scope) {
|
|
1604
1612
|
const queuedItems = deps.getQueuedItems();
|
|
1613
|
+
const suppression = disposition.kind === "reaction-transition" ? disposition.suppressionEmoji :
|
|
1614
|
+
disposition.kind === "suppressed" ? disposition.emoji : disposition.kind === "priority-suppressed" ? disposition.suppressionEmoji : undefined;
|
|
1615
|
+
// Source-addressed control-lane prompts are explicit continuations. Synthetic model-switch turns have no source IDs.
|
|
1616
|
+
// A Pi-owned dispatched head cannot be removed: agent_start must consume that exact item, not its successor.
|
|
1617
|
+
const cancelled = suppression && deps.hasPendingDispatch ? queuedItems.filter((item, index) => isPendingTelegramTurn(item) && item.queueLane === "control" && isTelegramQueueItemInMessageScope(item, scope) &&
|
|
1618
|
+
item.sourceMessageIds.includes(messageId) && !(index === 0 && deps.hasPendingDispatch())) : [];
|
|
1619
|
+
if (cancelled.length) {
|
|
1620
|
+
if (cancelled.some(item => (item.admissionReceipts?.length ?? 0) > 0) && !deps.onItemsDiscarded)
|
|
1621
|
+
return false;
|
|
1622
|
+
deps.onItemsDiscarded?.(cancelled, deps.ctx);
|
|
1623
|
+
deps.setQueuedItems(queuedItems.filter(item => !cancelled.includes(item)));
|
|
1624
|
+
updateTelegramQueueStatusRuntime(deps);
|
|
1625
|
+
return true;
|
|
1626
|
+
}
|
|
1605
1627
|
const changesLane = queuedItems.some((item) => {
|
|
1606
|
-
if (!isPendingTelegramTurn(item) ||
|
|
1628
|
+
if (!isPendingTelegramTurn(item) || item.queueLane === "control" ||
|
|
1607
1629
|
!isTelegramQueueItemInMessageScope(item, scope) ||
|
|
1608
1630
|
!item.sourceMessageIds.includes(messageId)) {
|
|
1609
1631
|
return false;
|
|
@@ -121,6 +121,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
|
|
|
121
121
|
captureWorkspaceExternalProtection?: ReturnType<typeof createTelegramWorkspaceExternalProtectionCapture>;
|
|
122
122
|
/** Strict committed abandonment plus retained original; shared by Restore and temporary-input cancellation. */
|
|
123
123
|
inspectRestoreSourceAbandonment?: (updateId: number, journalBindingKey: string) => Threads.TelegramTemporaryThreadCancellationEvidence | undefined;
|
|
124
|
+
inspectRoutingInputGroupExpiry?: (input: Threads.TelegramTemporaryThreadInput) => readonly Threads.TelegramTemporaryThreadCancellationEvidence[] | undefined;
|
|
124
125
|
/** Strict active-journal observation only; a hint or missing source never substitutes for this ACK. */
|
|
125
126
|
inspectRestoreSourceCompletion?: (expected: Updates.TelegramDeferredSourceEvidence & {
|
|
126
127
|
completionSha256: string;
|