@llblab/pi-telegram 0.21.1 → 0.22.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/lib/threads.ts CHANGED
@@ -5,12 +5,30 @@
5
5
  */
6
6
 
7
7
  import { randomUUID } from "node:crypto";
8
- import { existsSync } from "node:fs";
9
- import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
8
+ import {
9
+ chmodSync,
10
+ existsSync,
11
+ readFileSync,
12
+ renameSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import {
16
+ chmod,
17
+ mkdir,
18
+ readFile,
19
+ rename,
20
+ unlink,
21
+ writeFile,
22
+ } from "node:fs/promises";
10
23
  import { dirname } from "node:path";
11
24
 
12
- import type { TelegramApiCallOptions } from "./telegram-api.ts";
25
+ import {
26
+ isTelegramApiCommitUnknownError,
27
+ TelegramApiCommitUnknownError,
28
+ type TelegramApiCallOptions,
29
+ } from "./telegram-api.ts";
13
30
  import type { TelegramTarget } from "./target.ts";
31
+ import { withTelegramFileTransaction } from "./locks.ts";
14
32
  import * as ThreadReconciler from "./thread-reconciler.ts";
15
33
  import {
16
34
  resolveAgentDir,
@@ -58,6 +76,9 @@ export interface TelegramThreadPendingProvision {
58
76
  id: string;
59
77
  owner: "leader" | "manual-follower";
60
78
  instanceId: string;
79
+ profileKey?: string;
80
+ status?: "in-flight" | "ambiguous";
81
+ threadName?: string;
61
82
  slot?: string;
62
83
  target?: TelegramTarget & { threadId: number };
63
84
  startedAtMs: number;
@@ -65,6 +86,16 @@ export interface TelegramThreadPendingProvision {
65
86
  leaderEpoch?: number | string;
66
87
  }
67
88
 
89
+ type TelegramProvisionRecoveryFile = Record<
90
+ string,
91
+ {
92
+ instanceId: string;
93
+ profileKey?: string;
94
+ leaderEpoch?: number | string;
95
+ target: TelegramTarget & { threadId: number };
96
+ }
97
+ >;
98
+
68
99
  export interface TelegramTopicSyncObservation {
69
100
  target: TelegramTarget & { threadId: number };
70
101
  syncStatus: TelegramTopicSyncStatus;
@@ -152,7 +183,11 @@ function getNextMonotonicSlot(
152
183
  cursorCode = Math.max(cursorCode, reservation.slot.charCodeAt(0));
153
184
  }
154
185
  for (const provision of pendingProvisions) {
155
- if (provision.expiresAtMs !== undefined && provision.expiresAtMs <= nowMs)
186
+ if (
187
+ provision.status !== "ambiguous" &&
188
+ provision.expiresAtMs !== undefined &&
189
+ provision.expiresAtMs <= nowMs
190
+ )
156
191
  continue;
157
192
  if (!provision.slot) continue;
158
193
  cursorCode = Math.max(cursorCode, provision.slot.charCodeAt(0));
@@ -191,6 +226,10 @@ export interface TelegramTopicTargetStore {
191
226
  listSyncObservations: () => TelegramTopicSyncObservation[];
192
227
  reserveThread: (reservation: TelegramThreadReservation) => void;
193
228
  upsertPendingProvision: (provision: TelegramThreadPendingProvision) => void;
229
+ recordPendingProvisionTargetRecovery: (
230
+ provision: TelegramThreadPendingProvision,
231
+ target: TelegramTarget & { threadId: number },
232
+ ) => Promise<boolean>;
194
233
  removePendingProvision: (id: string) => boolean;
195
234
  removeReservationByTarget: (target: TelegramTarget) => boolean;
196
235
  getBotState: () => TelegramBotStateSnapshot;
@@ -234,10 +273,7 @@ export interface TelegramTopicTargetStore {
234
273
  }
235
274
 
236
275
  export function reconcileTelegramFreshAllocationCursor(
237
- store: Pick<
238
- TelegramTopicTargetStore,
239
- "getBotState" | "list" | "setBotState"
240
- >,
276
+ store: Pick<TelegramTopicTargetStore, "getBotState" | "list" | "setBotState">,
241
277
  nowMs = Date.now(),
242
278
  ): boolean {
243
279
  const currentCursor = store.getBotState().lastSlot;
@@ -270,6 +306,7 @@ export interface TelegramTopicTargetStoreOptions {
270
306
  path: string | (() => string);
271
307
  getNowMs?: () => number;
272
308
  canPersist?: () => boolean;
309
+ commitPersist?: (commit: () => void) => boolean;
273
310
  }
274
311
 
275
312
  export interface TelegramTopicTargetProvisionerDeps {
@@ -285,7 +322,9 @@ export interface TelegramTopicTargetProvisionerDeps {
285
322
  | "markStaleByTarget"
286
323
  | "allocateSlot"
287
324
  | "claimReusableTarget"
325
+ | "listPendingProvisions"
288
326
  | "upsertPendingProvision"
327
+ | "recordPendingProvisionTargetRecovery"
289
328
  | "removePendingProvision"
290
329
  | "persist"
291
330
  >;
@@ -753,6 +792,15 @@ function normalizePendingProvision(
753
792
  id: record.id,
754
793
  owner,
755
794
  instanceId: record.instanceId,
795
+ ...(typeof record.profileKey === "string"
796
+ ? { profileKey: record.profileKey }
797
+ : {}),
798
+ ...(record.status === "in-flight" || record.status === "ambiguous"
799
+ ? { status: record.status }
800
+ : {}),
801
+ ...(typeof record.threadName === "string"
802
+ ? { threadName: record.threadName }
803
+ : {}),
756
804
  ...(typeof record.slot === "string" ? { slot: record.slot } : {}),
757
805
  ...(target ? { target } : {}),
758
806
  startedAtMs: record.startedAtMs,
@@ -883,7 +931,11 @@ function parseFollowerRecoveryHints(
883
931
  const hints = new Map<string, { slot?: string; threadName?: string }>();
884
932
  if (!value || typeof value !== "object" || Array.isArray(value)) return hints;
885
933
  const liveRoster = (value as Record<string, unknown>).liveRoster;
886
- if (!liveRoster || typeof liveRoster !== "object" || Array.isArray(liveRoster))
934
+ if (
935
+ !liveRoster ||
936
+ typeof liveRoster !== "object" ||
937
+ Array.isArray(liveRoster)
938
+ )
887
939
  return hints;
888
940
  const followers = (liveRoster as Record<string, unknown>).busFollowers;
889
941
  if (!Array.isArray(followers)) return hints;
@@ -892,7 +944,8 @@ function parseFollowerRecoveryHints(
892
944
  continue;
893
945
  const record = follower as Record<string, unknown>;
894
946
  const target = record.target;
895
- if (!target || typeof target !== "object" || Array.isArray(target)) continue;
947
+ if (!target || typeof target !== "object" || Array.isArray(target))
948
+ continue;
896
949
  const targetRecord = target as Record<string, unknown>;
897
950
  if (typeof targetRecord.chatId !== "number") continue;
898
951
  const normalizedTarget: TelegramTarget = {
@@ -941,6 +994,7 @@ function isPendingProvisionLiveOrTargeted(
941
994
  provision: TelegramThreadPendingProvision,
942
995
  nowMs: number,
943
996
  ): boolean {
997
+ if (provision.status === "ambiguous") return true;
944
998
  if (provision.expiresAtMs === undefined || provision.expiresAtMs > nowMs) {
945
999
  return true;
946
1000
  }
@@ -964,6 +1018,8 @@ export function createTelegramTopicTargetStore(
964
1018
  let loaded = false;
965
1019
  let loadedPath: string | undefined;
966
1020
  let dirty = false;
1021
+ let mutationRevision = 0;
1022
+ let persistQueue: Promise<void> = Promise.resolve();
967
1023
  let statusSnapshot: {
968
1024
  runtime?: Record<string, unknown>;
969
1025
  liveRoster?: Record<string, unknown>;
@@ -987,6 +1043,21 @@ export function createTelegramTopicTargetStore(
987
1043
 
988
1044
  const getPath = () =>
989
1045
  typeof options.path === "function" ? options.path() : options.path;
1046
+ const getRecoveryPath = (path: string) => `${path}.provision-recovery.json`;
1047
+ const readProvisionRecoveries = (
1048
+ path: string,
1049
+ ): TelegramProvisionRecoveryFile => {
1050
+ const recoveryPath = getRecoveryPath(path);
1051
+ if (!existsSync(recoveryPath)) return {};
1052
+ try {
1053
+ const value = JSON.parse(readFileSync(recoveryPath, "utf8")) as unknown;
1054
+ return value && typeof value === "object" && !Array.isArray(value)
1055
+ ? (value as TelegramProvisionRecoveryFile)
1056
+ : {};
1057
+ } catch {
1058
+ return {};
1059
+ }
1060
+ };
990
1061
  const resetForPath = (path: string) => {
991
1062
  if (loadedPath === path) return;
992
1063
  botState = { threadMode: "unknown" };
@@ -1042,12 +1113,25 @@ export function createTelegramTopicTargetStore(
1042
1113
  reservation.expiresAtMs > nowMs,
1043
1114
  )
1044
1115
  .map((reservation) => ({ ...reservation }));
1116
+ const recoveries = readProvisionRecoveries(path);
1045
1117
  pendingProvisions = (file.pendingProvisions ?? [])
1046
1118
  .filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs))
1047
- .map((provision) => ({
1048
- ...provision,
1049
- ...(provision.target ? { target: { ...provision.target } } : {}),
1050
- }));
1119
+ .map((provision) => {
1120
+ const recovery = recoveries[provision.id];
1121
+ const recoveryMatches =
1122
+ recovery?.instanceId === provision.instanceId &&
1123
+ recovery.profileKey === provision.profileKey &&
1124
+ recovery.leaderEpoch === provision.leaderEpoch &&
1125
+ Number.isInteger(recovery.target?.threadId);
1126
+ return {
1127
+ ...provision,
1128
+ ...(recoveryMatches
1129
+ ? { target: { ...recovery.target }, status: "ambiguous" as const }
1130
+ : provision.target
1131
+ ? { target: { ...provision.target } }
1132
+ : {}),
1133
+ };
1134
+ });
1051
1135
  syncObservations = (file.syncObservations ?? []).map((observation) => ({
1052
1136
  ...observation,
1053
1137
  target: { ...observation.target },
@@ -1056,69 +1140,98 @@ export function createTelegramTopicTargetStore(
1056
1140
  dirty = false;
1057
1141
  };
1058
1142
 
1143
+ const markDirty = (): void => {
1144
+ loaded = true;
1145
+ dirty = true;
1146
+ mutationRevision += 1;
1147
+ };
1148
+
1059
1149
  return {
1060
1150
  async load() {
1061
1151
  if (dirty) return;
1062
1152
  await loadFromDisk();
1063
1153
  },
1064
- async persist() {
1065
- const path = getPath();
1066
- if (loadedPath !== path && !dirty) resetForPath(path);
1067
- if (options.canPersist && !options.canPersist()) {
1068
- await loadFromDisk();
1069
- return;
1070
- }
1071
- if (!dirty || !loaded) await loadFromDisk();
1072
- await mkdir(dirname(path), { recursive: true });
1073
- const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
1074
- const nowMs = getNowMs();
1075
- reservations = reservations.filter(
1076
- (reservation) =>
1077
- reservation.expiresAtMs === undefined ||
1078
- reservation.expiresAtMs > nowMs,
1079
- );
1080
- pendingProvisions = pendingProvisions.filter((provision) =>
1081
- isPendingProvisionLiveOrTargeted(provision, nowMs),
1082
- );
1083
- const currentRecords = Array.from(records.values())
1084
- .filter(isCurrentThreadRecord)
1085
- .map(cloneRecord);
1086
- records = new Map(
1087
- currentRecords.map((record) => [
1088
- getRecordOwnerKey(record),
1089
- cloneRecord(record),
1090
- ]),
1091
- );
1092
- const file = {
1093
- version: 1,
1094
- source: "snapshot",
1095
- writtenAtMs: nowMs,
1096
- bot: botState,
1097
- ...statusSnapshot,
1098
- identities: Array.from(identities.values()).map(cloneIdentityRecord),
1099
- reservations: reservations.map((reservation) => ({ ...reservation })),
1100
- pendingProvisions: pendingProvisions.map((provision) => ({
1101
- ...provision,
1102
- ...(provision.target ? { target: { ...provision.target } } : {}),
1103
- })),
1104
- syncObservations: syncObservations.map((observation) => ({
1105
- ...observation,
1106
- target: { ...observation.target },
1107
- })),
1108
- threads: currentRecords.map((record) => {
1109
- const { profileKey: _profileKey, ...serialized } = record;
1110
- return serialized;
1111
- }),
1112
- };
1113
- await writeFile(tempPath, `${JSON.stringify(file, null, 2)}\n`, {
1114
- encoding: "utf8",
1115
- mode: 0o600,
1154
+ persist() {
1155
+ const persist = persistQueue.then(async () => {
1156
+ const path = getPath();
1157
+ if (loadedPath !== path && !dirty) resetForPath(path);
1158
+ if (options.canPersist && !options.canPersist()) {
1159
+ await loadFromDisk();
1160
+ return;
1161
+ }
1162
+ if (!dirty || !loaded) await loadFromDisk();
1163
+ await mkdir(dirname(path), { recursive: true });
1164
+ const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
1165
+ const nowMs = getNowMs();
1166
+ reservations = reservations.filter(
1167
+ (reservation) =>
1168
+ reservation.expiresAtMs === undefined ||
1169
+ reservation.expiresAtMs > nowMs,
1170
+ );
1171
+ pendingProvisions = pendingProvisions.filter((provision) =>
1172
+ isPendingProvisionLiveOrTargeted(provision, nowMs),
1173
+ );
1174
+ const currentRecords = Array.from(records.values())
1175
+ .filter(isCurrentThreadRecord)
1176
+ .map(cloneRecord);
1177
+ records = new Map(
1178
+ currentRecords.map((record) => [
1179
+ getRecordOwnerKey(record),
1180
+ cloneRecord(record),
1181
+ ]),
1182
+ );
1183
+ const persistedRevision = mutationRevision;
1184
+ const file = {
1185
+ version: 1,
1186
+ source: "snapshot",
1187
+ writtenAtMs: nowMs,
1188
+ bot: botState,
1189
+ ...statusSnapshot,
1190
+ identities: Array.from(identities.values()).map(cloneIdentityRecord),
1191
+ reservations: reservations.map((reservation) => ({ ...reservation })),
1192
+ pendingProvisions: pendingProvisions.map((provision) => ({
1193
+ ...provision,
1194
+ ...(provision.target ? { target: { ...provision.target } } : {}),
1195
+ })),
1196
+ syncObservations: syncObservations.map((observation) => ({
1197
+ ...observation,
1198
+ target: { ...observation.target },
1199
+ })),
1200
+ threads: currentRecords.map((record) => {
1201
+ const { profileKey: _profileKey, ...serialized } = record;
1202
+ return serialized;
1203
+ }),
1204
+ };
1205
+ await writeFile(tempPath, `${JSON.stringify(file, null, 2)}\n`, {
1206
+ encoding: "utf8",
1207
+ mode: 0o600,
1208
+ });
1209
+ await chmod(tempPath, 0o600);
1210
+ try {
1211
+ if (options.commitPersist) {
1212
+ const committed = options.commitPersist(() => {
1213
+ renameSync(tempPath, path);
1214
+ chmodSync(path, 0o600);
1215
+ });
1216
+ if (!committed) {
1217
+ await loadFromDisk();
1218
+ throw new Error(
1219
+ "Telegram thread snapshot lost exact transport ownership before commit.",
1220
+ );
1221
+ }
1222
+ } else {
1223
+ await rename(tempPath, path);
1224
+ await chmod(path, 0o600);
1225
+ }
1226
+ } catch (error) {
1227
+ await unlink(tempPath).catch(() => undefined);
1228
+ throw error;
1229
+ }
1230
+ loaded = true;
1231
+ if (mutationRevision === persistedRevision) dirty = false;
1116
1232
  });
1117
- await chmod(tempPath, 0o600);
1118
- await rename(tempPath, path);
1119
- await chmod(path, 0o600);
1120
- loaded = true;
1121
- dirty = false;
1233
+ persistQueue = persist.catch(() => undefined);
1234
+ return persist;
1122
1235
  },
1123
1236
  list() {
1124
1237
  return Array.from(records.values()).map(cloneRecord);
@@ -1162,8 +1275,7 @@ export function createTelegramTopicTargetStore(
1162
1275
  !targetMatches(existing.target, next.target),
1163
1276
  );
1164
1277
  reservations.push(next);
1165
- loaded = true;
1166
- dirty = true;
1278
+ markDirty();
1167
1279
  },
1168
1280
  upsertPendingProvision(provision) {
1169
1281
  const next = {
@@ -1174,8 +1286,41 @@ export function createTelegramTopicTargetStore(
1174
1286
  (existing) => existing.id !== next.id,
1175
1287
  );
1176
1288
  pendingProvisions.push(next);
1177
- loaded = true;
1178
- dirty = true;
1289
+ markDirty();
1290
+ },
1291
+ async recordPendingProvisionTargetRecovery(provision, target) {
1292
+ const path = getPath();
1293
+ const recoveryPath = getRecoveryPath(path);
1294
+ await mkdir(dirname(recoveryPath), { recursive: true });
1295
+ withTelegramFileTransaction(`${recoveryPath}.transaction`, () => {
1296
+ const recoveries = readProvisionRecoveries(path);
1297
+ recoveries[provision.id] = {
1298
+ instanceId: provision.instanceId,
1299
+ ...(provision.profileKey ? { profileKey: provision.profileKey } : {}),
1300
+ ...(provision.leaderEpoch !== undefined
1301
+ ? { leaderEpoch: provision.leaderEpoch }
1302
+ : {}),
1303
+ target: { ...target },
1304
+ };
1305
+ const tempPath = `${recoveryPath}.${process.pid}.${randomUUID()}.tmp`;
1306
+ writeFileSync(tempPath, `${JSON.stringify(recoveries, null, 2)}\n`, {
1307
+ encoding: "utf8",
1308
+ mode: 0o600,
1309
+ });
1310
+ renameSync(tempPath, recoveryPath);
1311
+ chmodSync(recoveryPath, 0o600);
1312
+ });
1313
+ const current = pendingProvisions.find(
1314
+ (entry) =>
1315
+ entry.id === provision.id &&
1316
+ entry.instanceId === provision.instanceId &&
1317
+ entry.profileKey === provision.profileKey &&
1318
+ entry.leaderEpoch === provision.leaderEpoch,
1319
+ );
1320
+ if (!current) return false;
1321
+ current.target = { ...target };
1322
+ current.status = "ambiguous";
1323
+ return true;
1179
1324
  },
1180
1325
  removePendingProvision(id) {
1181
1326
  const before = pendingProvisions.length;
@@ -1183,10 +1328,7 @@ export function createTelegramTopicTargetStore(
1183
1328
  (provision) => provision.id !== id,
1184
1329
  );
1185
1330
  const changed = pendingProvisions.length !== before;
1186
- if (changed) {
1187
- loaded = true;
1188
- dirty = true;
1189
- }
1331
+ if (changed) markDirty();
1190
1332
  return changed;
1191
1333
  },
1192
1334
  removeReservationByTarget(target) {
@@ -1195,10 +1337,7 @@ export function createTelegramTopicTargetStore(
1195
1337
  (reservation) => !targetMatches(reservation.target, target),
1196
1338
  );
1197
1339
  const changed = reservations.length !== before;
1198
- if (changed) {
1199
- loaded = true;
1200
- dirty = true;
1201
- }
1340
+ if (changed) markDirty();
1202
1341
  return changed;
1203
1342
  },
1204
1343
  getBotState() {
@@ -1208,8 +1347,7 @@ export function createTelegramTopicTargetStore(
1208
1347
  },
1209
1348
  setBotState(state) {
1210
1349
  botState = { ...botState, ...state };
1211
- loaded = true;
1212
- dirty = true;
1350
+ markDirty();
1213
1351
  },
1214
1352
  setStatusSnapshot(snapshot) {
1215
1353
  if (!loadedPath) loadedPath = getPath();
@@ -1245,8 +1383,7 @@ export function createTelegramTopicTargetStore(
1245
1383
  const removedOwner = identities.delete(ownerKey);
1246
1384
  const removedProfile = identities.delete(profileKey);
1247
1385
  if (!removedOwner && !removedProfile) return false;
1248
- loaded = true;
1249
- dirty = true;
1386
+ markDirty();
1250
1387
  return true;
1251
1388
  },
1252
1389
  upsert(record) {
@@ -1277,8 +1414,7 @@ export function createTelegramTopicTargetStore(
1277
1414
  if (!isCurrentThreadRecord(next)) {
1278
1415
  rememberIdentity(next);
1279
1416
  records.delete(nextOwnerKey);
1280
- loaded = true;
1281
- dirty = true;
1417
+ markDirty();
1282
1418
  return cloneRecord(next);
1283
1419
  }
1284
1420
  records.set(nextOwnerKey, next);
@@ -1289,8 +1425,7 @@ export function createTelegramTopicTargetStore(
1289
1425
  rememberSlot(next.slot, next.updatedAtMs);
1290
1426
  }
1291
1427
  rememberIdentity(next);
1292
- loaded = true;
1293
- dirty = true;
1428
+ markDirty();
1294
1429
  return cloneRecord(next);
1295
1430
  },
1296
1431
  markOfflineByInstanceId(instanceId) {
@@ -1304,10 +1439,7 @@ export function createTelegramTopicTargetStore(
1304
1439
  records.delete(getRecordOwnerKey(record));
1305
1440
  count += 1;
1306
1441
  }
1307
- if (count > 0) {
1308
- loaded = true;
1309
- dirty = true;
1310
- }
1442
+ if (count > 0) markDirty();
1311
1443
  return count;
1312
1444
  },
1313
1445
  markStaleByTarget(target, syncStatus = "unknown", lastSyncError) {
@@ -1328,8 +1460,7 @@ export function createTelegramTopicTargetStore(
1328
1460
  });
1329
1461
  rememberIdentity(record);
1330
1462
  records.delete(getRecordOwnerKey(record));
1331
- loaded = true;
1332
- dirty = true;
1463
+ markDirty();
1333
1464
  return true;
1334
1465
  }
1335
1466
  return false;
@@ -1345,8 +1476,7 @@ export function createTelegramTopicTargetStore(
1345
1476
  record.lastReconcileAction = "mark-active";
1346
1477
  delete record.lastError;
1347
1478
  delete record.lastSyncError;
1348
- loaded = true;
1349
- dirty = true;
1479
+ markDirty();
1350
1480
  return true;
1351
1481
  }
1352
1482
  return false;
@@ -1361,8 +1491,7 @@ export function createTelegramTopicTargetStore(
1361
1491
  record.threadName = normalizedThreadName;
1362
1492
  record.updatedAtMs = nowMs;
1363
1493
  rememberIdentity(record);
1364
- loaded = true;
1365
- dirty = true;
1494
+ markDirty();
1366
1495
  return cloneRecord(record);
1367
1496
  }
1368
1497
  return undefined;
@@ -1401,8 +1530,7 @@ export function createTelegramTopicTargetStore(
1401
1530
  record.threadName = threadName;
1402
1531
  delete record.lastError;
1403
1532
  rememberIdentity(record);
1404
- loaded = true;
1405
- dirty = true;
1533
+ markDirty();
1406
1534
  return cloneRecord(record);
1407
1535
  },
1408
1536
  allocateSlot(profileKey, preferredSlot) {
@@ -1456,7 +1584,11 @@ function isTelegramTopicTargetSlotOccupied(
1456
1584
  if (reservation.slot === slot) return true;
1457
1585
  }
1458
1586
  for (const provision of pendingProvisions) {
1459
- if (provision.expiresAtMs !== undefined && provision.expiresAtMs <= nowMs)
1587
+ if (
1588
+ provision.status !== "ambiguous" &&
1589
+ provision.expiresAtMs !== undefined &&
1590
+ provision.expiresAtMs <= nowMs
1591
+ )
1460
1592
  continue;
1461
1593
  if (provision.slot === slot) return true;
1462
1594
  }
@@ -1945,6 +2077,26 @@ export async function provisionOwnBusTopic(
1945
2077
  threadId: record.target.threadId,
1946
2078
  slot: record.slot,
1947
2079
  });
2080
+ if (
2081
+ deps.getCurrentLeaderEpoch &&
2082
+ (action.leaderEpoch === undefined ||
2083
+ deps.getCurrentLeaderEpoch() !== action.leaderEpoch)
2084
+ ) {
2085
+ deps.recordEvent(
2086
+ "bus",
2087
+ "Skipped previous-topic local cleanup after leader epoch loss",
2088
+ {
2089
+ phase: "leader-topic-previous-cleanup-stale-epoch-skip",
2090
+ actionLeaderEpoch: action.leaderEpoch,
2091
+ currentLeaderEpoch: deps.getCurrentLeaderEpoch(),
2092
+ chatId: record.target.chatId,
2093
+ threadId: record.target.threadId,
2094
+ },
2095
+ );
2096
+ throw new Error(
2097
+ "Telegram leader ownership changed during topic reconciliation.",
2098
+ );
2099
+ }
1948
2100
  deps.store.markStaleByTarget(record.target);
1949
2101
  deps.store.reserveThread({
1950
2102
  target: record.target,
@@ -2048,7 +2200,9 @@ export function resolveTelegramInstanceThreadIdentity(options: {
2048
2200
  ) => {
2049
2201
  if (!candidate) return false;
2050
2202
  if (!options.target) return true;
2051
- return !!candidate.target && targetMatches(candidate.target, options.target);
2203
+ return (
2204
+ !!candidate.target && targetMatches(candidate.target, options.target)
2205
+ );
2052
2206
  };
2053
2207
  const local = targetMatchesCandidate(options.follower)
2054
2208
  ? options.follower
@@ -2061,18 +2215,95 @@ export function resolveTelegramInstanceThreadIdentity(options: {
2061
2215
  ? options.record
2062
2216
  : undefined;
2063
2217
  return {
2064
- ...(local?.target ?? record?.target
2218
+ ...((local?.target ?? record?.target)
2065
2219
  ? { target: local?.target ?? record?.target }
2066
2220
  : {}),
2067
- ...(local?.slot ?? record?.slot
2221
+ ...((local?.slot ?? record?.slot)
2068
2222
  ? { slot: local?.slot ?? record?.slot }
2069
2223
  : {}),
2070
- ...(local?.threadName ?? record?.threadName
2224
+ ...((local?.threadName ?? record?.threadName)
2071
2225
  ? { threadName: local?.threadName ?? record?.threadName }
2072
2226
  : {}),
2073
2227
  };
2074
2228
  }
2075
2229
 
2230
+ export interface TelegramLeaderThreadStateRuntime {
2231
+ getTarget(): TelegramTarget | undefined;
2232
+ getIdentity(): TelegramInstanceThreadIdentityCandidate | undefined;
2233
+ set(
2234
+ input: TelegramInstanceThreadIdentityCandidate & { target: TelegramTarget },
2235
+ ): void;
2236
+ clear(): void;
2237
+ }
2238
+
2239
+ export function createTelegramLeaderThreadStateRuntime(): TelegramLeaderThreadStateRuntime {
2240
+ let identity:
2241
+ | (TelegramInstanceThreadIdentityCandidate & { target: TelegramTarget })
2242
+ | undefined;
2243
+ return {
2244
+ getTarget: () => identity?.target,
2245
+ getIdentity: () => identity,
2246
+ set(input) {
2247
+ identity = { ...input, target: { ...input.target } };
2248
+ },
2249
+ clear() {
2250
+ identity = undefined;
2251
+ },
2252
+ };
2253
+ }
2254
+
2255
+ export interface TelegramCurrentInstanceThreadRuntime {
2256
+ findRecord(): TelegramTopicTargetRecord | undefined;
2257
+ getRecord(): TelegramTopicTargetRecord | undefined;
2258
+ getIdentity(target?: TelegramTarget): TelegramInstanceThreadIdentityCandidate;
2259
+ }
2260
+
2261
+ export function createTelegramCurrentInstanceThreadRuntime(deps: {
2262
+ instanceId: string;
2263
+ listRecords(): readonly TelegramTopicTargetRecord[];
2264
+ getPreferredTarget(): TelegramTarget | undefined;
2265
+ getFollower():
2266
+ | (TelegramInstanceThreadIdentityCandidate & { registered: boolean })
2267
+ | undefined;
2268
+ getLeader(): TelegramInstanceThreadIdentityCandidate | undefined;
2269
+ }): TelegramCurrentInstanceThreadRuntime {
2270
+ const findRecord = function (): TelegramTopicTargetRecord | undefined {
2271
+ return findCurrentTelegramInstanceThreadRecord({
2272
+ records: deps.listRecords(),
2273
+ instanceId: deps.instanceId,
2274
+ preferredTarget: deps.getPreferredTarget(),
2275
+ });
2276
+ };
2277
+ const getRecord = function (): TelegramTopicTargetRecord | undefined {
2278
+ const record = findRecord();
2279
+ const follower = deps.getFollower();
2280
+ if (record?.owner?.kind === "manual-follower" && !follower?.registered) {
2281
+ return undefined;
2282
+ }
2283
+ return record;
2284
+ };
2285
+ return {
2286
+ findRecord,
2287
+ getRecord,
2288
+ getIdentity(target) {
2289
+ const follower = deps.getFollower();
2290
+ const record = target
2291
+ ? findCurrentTelegramInstanceThreadRecord({
2292
+ records: deps.listRecords(),
2293
+ instanceId: deps.instanceId,
2294
+ preferredTarget: target,
2295
+ })
2296
+ : getRecord();
2297
+ return resolveTelegramInstanceThreadIdentity({
2298
+ target,
2299
+ follower: follower?.registered ? follower : undefined,
2300
+ leader: deps.getLeader(),
2301
+ record,
2302
+ });
2303
+ },
2304
+ };
2305
+ }
2306
+
2076
2307
  export function findCurrentTelegramInstanceThreadRecord(options: {
2077
2308
  records: readonly TelegramTopicTargetRecord[];
2078
2309
  instanceId: string;
@@ -2111,6 +2342,88 @@ export function resolveTelegramInstanceThreadTarget(options: {
2111
2342
  : undefined;
2112
2343
  }
2113
2344
 
2345
+ export interface TelegramThreadStatusProjectionRuntime {
2346
+ getBusRole(): "leader" | "follower" | undefined;
2347
+ getBusFollowers(): ReturnType<typeof listTelegramThreadStatusFollowers>;
2348
+ getLocalBus(): {
2349
+ leaderSocketPath: string;
2350
+ leaderTransport: "socket" | "pipe";
2351
+ followerSocketPath: string;
2352
+ followerTransport: "socket" | "pipe";
2353
+ followerRegistered: boolean;
2354
+ followerTarget?: TelegramTarget;
2355
+ followerSlot?: string;
2356
+ followerThreadName?: string;
2357
+ };
2358
+ getTopicTargets(): ReturnType<typeof listTelegramThreadStatusTargets>;
2359
+ getThreadReservations(): ReturnType<
2360
+ typeof listTelegramThreadStatusReservations
2361
+ >;
2362
+ getTopicSyncObservations(): ReturnType<
2363
+ typeof listTelegramThreadStatusObservations
2364
+ >;
2365
+ getInstanceSlot(): string | undefined;
2366
+ getInstanceThreadName(): string | undefined;
2367
+ }
2368
+
2369
+ export function createTelegramThreadStatusProjectionRuntime(deps: {
2370
+ getThreadMode(): "unknown" | "enabled" | "disabled";
2371
+ isBusPollingStarted(): boolean;
2372
+ isFollowerRegistered(): boolean;
2373
+ listFollowers(): readonly TelegramThreadStatusFollowerView[];
2374
+ listRecords(): readonly TelegramTopicTargetRecord[];
2375
+ listReservations(): readonly TelegramThreadReservation[];
2376
+ listSyncObservations(): readonly TelegramTopicSyncObservation[];
2377
+ getLeaderSocketPath(): string;
2378
+ getFollowerSocketPath(): string;
2379
+ getTransportKind(path: string): "socket" | "pipe";
2380
+ getFollowerTarget(): TelegramTarget | undefined;
2381
+ getFollowerSlot(): string | undefined;
2382
+ getFollowerThreadName(): string | undefined;
2383
+ getCurrentIdentity(): TelegramInstanceThreadIdentityCandidate;
2384
+ }): TelegramThreadStatusProjectionRuntime {
2385
+ return {
2386
+ getBusRole() {
2387
+ if (deps.getThreadMode() === "disabled") return undefined;
2388
+ if (deps.isBusPollingStarted()) return "leader";
2389
+ return deps.isFollowerRegistered() ? "follower" : undefined;
2390
+ },
2391
+ getBusFollowers() {
2392
+ return listTelegramThreadStatusFollowers({
2393
+ followers: deps.listFollowers(),
2394
+ records: deps.listRecords(),
2395
+ });
2396
+ },
2397
+ getLocalBus() {
2398
+ const leaderSocketPath = deps.getLeaderSocketPath();
2399
+ const followerSocketPath = deps.getFollowerSocketPath();
2400
+ return {
2401
+ leaderSocketPath,
2402
+ leaderTransport: deps.getTransportKind(leaderSocketPath),
2403
+ followerSocketPath,
2404
+ followerTransport: deps.getTransportKind(followerSocketPath),
2405
+ followerRegistered: deps.isFollowerRegistered(),
2406
+ followerTarget: deps.getFollowerTarget(),
2407
+ followerSlot: deps.getFollowerSlot(),
2408
+ followerThreadName: deps.getFollowerThreadName(),
2409
+ };
2410
+ },
2411
+ getTopicTargets: () => listTelegramThreadStatusTargets(deps.listRecords()),
2412
+ getThreadReservations: () =>
2413
+ listTelegramThreadStatusReservations(deps.listReservations()),
2414
+ getTopicSyncObservations: () =>
2415
+ listTelegramThreadStatusObservations(deps.listSyncObservations()),
2416
+ getInstanceSlot() {
2417
+ if (deps.getThreadMode() === "disabled") return undefined;
2418
+ return deps.getCurrentIdentity().slot;
2419
+ },
2420
+ getInstanceThreadName() {
2421
+ if (deps.getThreadMode() === "disabled") return undefined;
2422
+ return deps.getCurrentIdentity().threadName;
2423
+ },
2424
+ };
2425
+ }
2426
+
2114
2427
  export interface TelegramThreadStatusFollowerView {
2115
2428
  instanceId: string;
2116
2429
  cwd?: string;
@@ -2330,10 +2643,27 @@ export function createTelegramTopicTargetProvisioner(
2330
2643
  const getNowMs = deps.getNowMs ?? (() => 0);
2331
2644
  const getRandom = deps.getRandom;
2332
2645
  return async (request) => {
2646
+ const leaderEpoch = deps.getCurrentLeaderEpoch?.();
2647
+ const assertLeaderEpoch = (phase: string): void => {
2648
+ if (
2649
+ deps.getCurrentLeaderEpoch &&
2650
+ (leaderEpoch === undefined ||
2651
+ deps.getCurrentLeaderEpoch() !== leaderEpoch)
2652
+ ) {
2653
+ throw new Error(
2654
+ `Telegram topic provisioning lost leader ownership (${phase}).`,
2655
+ );
2656
+ }
2657
+ };
2658
+ assertLeaderEpoch("start");
2333
2659
  normalizeCurrentThreadNameSlots(deps.store);
2334
2660
  let existing = deps.store.getByProfileKey(request.profileKey);
2335
2661
  const isManualFollowerRequest = request.owner?.kind === "manual-follower";
2336
- if (isManualFollowerRequest && existing && isCurrentThreadRecord(existing)) {
2662
+ if (
2663
+ isManualFollowerRequest &&
2664
+ existing &&
2665
+ isCurrentThreadRecord(existing)
2666
+ ) {
2337
2667
  deps.store.markStaleByTarget(
2338
2668
  existing.target,
2339
2669
  "unknown",
@@ -2342,9 +2672,10 @@ export function createTelegramTopicTargetProvisioner(
2342
2672
  deps.store.forgetIdentityByProfileKey(request.profileKey);
2343
2673
  existing = undefined;
2344
2674
  }
2345
- const identity = isManualFollowerRequest && !existing
2346
- ? undefined
2347
- : deps.store.getIdentityByProfileKey(request.profileKey);
2675
+ const identity =
2676
+ isManualFollowerRequest && !existing
2677
+ ? undefined
2678
+ : deps.store.getIdentityByProfileKey(request.profileKey);
2348
2679
  const nowMs = getNowMs();
2349
2680
  if (existing && isCurrentThreadRecord(existing)) {
2350
2681
  const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
@@ -2371,6 +2702,35 @@ export function createTelegramTopicTargetProvisioner(
2371
2702
  });
2372
2703
  return { target: record.target, reused: true, record };
2373
2704
  }
2705
+ const pendingForRequest = deps.store
2706
+ .listPendingProvisions()
2707
+ .find(
2708
+ (pending) =>
2709
+ pending.profileKey === request.profileKey ||
2710
+ pending.instanceId === request.instanceId,
2711
+ );
2712
+ if (pendingForRequest?.target) {
2713
+ const record = deps.store.upsert({
2714
+ profileKey: request.profileKey,
2715
+ owner: request.owner,
2716
+ target: pendingForRequest.target,
2717
+ status: "active",
2718
+ createdAtMs: pendingForRequest.startedAtMs,
2719
+ updatedAtMs: nowMs,
2720
+ threadName: pendingForRequest.threadName,
2721
+ instanceId: request.instanceId,
2722
+ slot: pendingForRequest.slot,
2723
+ });
2724
+ deps.store.removePendingProvision(pendingForRequest.id);
2725
+ await deps.store.persist();
2726
+ assertLeaderEpoch("after-recovered-binding");
2727
+ return { target: record.target, reused: true, record };
2728
+ }
2729
+ if (pendingForRequest) {
2730
+ throw new Error(
2731
+ `Telegram topic provisioning remains ${pendingForRequest.status ?? "in-flight"} for this instance.`,
2732
+ );
2733
+ }
2374
2734
  const activeForInstance = deps.store.getActiveByInstanceId(
2375
2735
  request.instanceId,
2376
2736
  );
@@ -2403,7 +2763,7 @@ export function createTelegramTopicTargetProvisioner(
2403
2763
  request.profileKey,
2404
2764
  isManualFollowerRequest
2405
2765
  ? request.preferredSlot
2406
- : request.preferredSlot ?? preferredNameSlot,
2766
+ : (request.preferredSlot ?? preferredNameSlot),
2407
2767
  );
2408
2768
  const requestThreadName =
2409
2769
  candidateThreadName &&
@@ -2413,20 +2773,23 @@ export function createTelegramTopicTargetProvisioner(
2413
2773
  const pendingId = `provision:${request.instanceId}:${slot}:${nowMs}`;
2414
2774
  const pendingOwner =
2415
2775
  request.owner?.kind === "leader" ? "leader" : "manual-follower";
2416
- const leaderEpoch = deps.getCurrentLeaderEpoch?.();
2776
+ assertLeaderEpoch("before-pending-intent");
2417
2777
  const pendingBase: TelegramThreadPendingProvision = {
2418
2778
  id: pendingId,
2419
2779
  owner: pendingOwner,
2420
2780
  instanceId: request.instanceId,
2781
+ profileKey: request.profileKey,
2782
+ threadName: requestThreadName,
2421
2783
  slot,
2422
2784
  startedAtMs: nowMs,
2423
- expiresAtMs: nowMs + TELEGRAM_THREAD_RESERVATION_TTL_MS,
2424
2785
  ...(leaderEpoch !== undefined ? { leaderEpoch } : {}),
2425
2786
  };
2426
2787
  deps.store.upsertPendingProvision(pendingBase);
2427
2788
  await deps.store.persist();
2789
+ assertLeaderEpoch("after-pending-intent");
2428
2790
  let threadId: number | undefined;
2429
2791
  try {
2792
+ assertLeaderEpoch("before-createForumTopic");
2430
2793
  const topic = await deps.callApi<TelegramTopicResult>(
2431
2794
  "createForumTopic",
2432
2795
  {
@@ -2445,13 +2808,16 @@ export function createTelegramTopicTargetProvisioner(
2445
2808
  );
2446
2809
  threadId = topic.message_thread_id;
2447
2810
  if (typeof threadId !== "number" || !Number.isInteger(threadId)) {
2448
- throw new Error(
2449
- "Telegram createForumTopic returned no message_thread_id.",
2811
+ throw new TelegramApiCommitUnknownError(
2812
+ "createForumTopic",
2813
+ new Error("Telegram createForumTopic returned no message_thread_id."),
2450
2814
  );
2451
2815
  }
2816
+ assertLeaderEpoch("after-createForumTopic");
2452
2817
  const target = { chatId: deps.topicChatId, threadId };
2453
2818
  deps.store.upsertPendingProvision({ ...pendingBase, target });
2454
2819
  await deps.store.persist();
2820
+ assertLeaderEpoch("after-pending-target");
2455
2821
  deps.store.upsert({
2456
2822
  profileKey: request.profileKey,
2457
2823
  owner: request.owner,
@@ -2464,6 +2830,7 @@ export function createTelegramTopicTargetProvisioner(
2464
2830
  slot,
2465
2831
  });
2466
2832
  await deps.store.persist();
2833
+ assertLeaderEpoch("after-starting-binding");
2467
2834
  const record = deps.store.upsert({
2468
2835
  profileKey: request.profileKey,
2469
2836
  owner: request.owner,
@@ -2477,10 +2844,30 @@ export function createTelegramTopicTargetProvisioner(
2477
2844
  });
2478
2845
  deps.store.removePendingProvision(pendingId);
2479
2846
  await deps.store.persist();
2847
+ assertLeaderEpoch("after-active-binding");
2480
2848
  return { target: record.target, reused: false, record };
2481
2849
  } catch (error) {
2850
+ if (
2851
+ threadId !== undefined &&
2852
+ deps.getCurrentLeaderEpoch &&
2853
+ deps.getCurrentLeaderEpoch() !== leaderEpoch
2854
+ ) {
2855
+ await deps.store.recordPendingProvisionTargetRecovery(pendingBase, {
2856
+ chatId: deps.topicChatId,
2857
+ threadId,
2858
+ });
2859
+ throw error;
2860
+ }
2861
+ assertLeaderEpoch("failure-cleanup");
2482
2862
  if (threadId === undefined) {
2483
- deps.store.removePendingProvision(pendingId);
2863
+ if (isTelegramApiCommitUnknownError(error)) {
2864
+ deps.store.upsertPendingProvision({
2865
+ ...pendingBase,
2866
+ status: "ambiguous",
2867
+ });
2868
+ } else {
2869
+ deps.store.removePendingProvision(pendingId);
2870
+ }
2484
2871
  await deps.store.persist();
2485
2872
  } else {
2486
2873
  try {