@llblab/pi-kit 0.19.2 → 0.21.0

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.
Files changed (102) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +6 -6
  4. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +9 -0
  5. package/node_modules/@llblab/pi-state-flow/README.md +4 -4
  6. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +2 -2
  7. package/node_modules/@llblab/pi-state-flow/dist/index.js +2 -2
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +1 -0
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +7 -3
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +41 -11
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +6 -0
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +94 -1
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +2 -5
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +2 -0
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +11 -0
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +4 -2
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +8 -2
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +6 -1
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +19 -6
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +5 -0
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +27 -3
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +2 -2
  23. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  24. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +10 -8
  25. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +2 -2
  26. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +4 -3
  27. package/node_modules/@llblab/pi-state-flow/docs/usage.md +5 -5
  28. package/node_modules/@llblab/pi-state-flow/index.ts +2 -2
  29. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +8 -3
  30. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +41 -9
  31. package/node_modules/@llblab/pi-state-flow/lib/git.ts +83 -1
  32. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +2 -4
  33. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +11 -0
  34. package/node_modules/@llblab/pi-state-flow/lib/status.ts +10 -4
  35. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +23 -6
  36. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +29 -3
  37. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +2 -2
  38. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  39. package/node_modules/@llblab/pi-telegram/AGENTS.md +9 -7
  40. package/node_modules/@llblab/pi-telegram/BACKLOG.md +13 -32
  41. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
  42. package/node_modules/@llblab/pi-telegram/README.md +4 -3
  43. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -2
  44. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +3 -5
  45. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +1 -1
  46. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +93 -9
  47. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +9 -3
  48. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +231 -71
  49. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +4 -0
  50. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +34 -7
  51. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +63 -2
  52. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +3 -0
  53. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +13 -0
  54. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -0
  55. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +25 -4
  56. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +1 -0
  57. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +32 -12
  58. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +10 -0
  59. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +22 -1
  60. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +2 -2
  61. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +1 -1
  62. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +1 -2
  63. package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +9 -0
  64. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +41 -1
  65. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +9 -1
  66. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +23 -3
  67. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +7 -2
  68. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +2 -0
  69. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +68 -37
  70. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +6 -4
  71. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +134 -61
  72. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +9 -0
  73. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +49 -3
  74. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +77 -6
  75. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +384 -8
  76. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +3 -0
  77. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +6 -0
  78. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  79. package/node_modules/@llblab/pi-telegram/docs/architecture.md +21 -14
  80. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +31 -10
  81. package/node_modules/@llblab/pi-telegram/docs/updates.md +2 -0
  82. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +5 -7
  83. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +69 -12
  84. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +236 -94
  85. package/node_modules/@llblab/pi-telegram/lib/bus.ts +39 -9
  86. package/node_modules/@llblab/pi-telegram/lib/extension.ts +69 -2
  87. package/node_modules/@llblab/pi-telegram/lib/journal.ts +14 -0
  88. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +23 -4
  89. package/node_modules/@llblab/pi-telegram/lib/locks.ts +32 -13
  90. package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -1
  91. package/node_modules/@llblab/pi-telegram/lib/polling.ts +2 -2
  92. package/node_modules/@llblab/pi-telegram/lib/queue.ts +2 -3
  93. package/node_modules/@llblab/pi-telegram/lib/sync.ts +45 -1
  94. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +30 -2
  95. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +11 -3
  96. package/node_modules/@llblab/pi-telegram/lib/threads.ts +73 -36
  97. package/node_modules/@llblab/pi-telegram/lib/updates.ts +145 -78
  98. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +67 -4
  99. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +465 -7
  100. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +7 -0
  101. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  102. package/package.json +3 -3
@@ -104,7 +104,7 @@ export default function (pi: Pi.ExtensionAPI) {
104
104
  WorkspaceAdmission.createTelegramWorkspaceAdmissionRuntimeBinding({
105
105
  getProfileName: configStore.getActiveProfileName,
106
106
  getBotToken: configStore.getBotToken,
107
- getPath: Paths.resolveTelegramWorkspaceAdmissionPath,
107
+ getPath: Paths.resolveTelegramWorkspaceAdmissionPathForProfile,
108
108
  owner: {
109
109
  processId: telegramProcessId,
110
110
  processBirthId: telegramQueueProcessBirthId,
@@ -187,7 +187,7 @@ export default function (pi: Pi.ExtensionAPI) {
187
187
  },
188
188
  getWorkspaceAdmission: workspaceAdmissionRuntime.resolve,
189
189
  },
190
- getLeaderJournalPath: Paths.resolveTelegramUpdateJournalPath,
190
+ getLeaderJournalPath: Paths.resolveTelegramUpdateJournalPathForProfile,
191
191
  getFollowerJournalPath(bindingKey, profileName) {
192
192
  return Paths.resolveTelegramFollowerJournalPath(
193
193
  bindingKey,
@@ -387,6 +387,51 @@ export default function (pi: Pi.ExtensionAPI) {
387
387
  : "clear";
388
388
  },
389
389
  });
390
+ const reclaimTelegramWorkspaceDeadOwnerQueue =
391
+ WorkspaceRetirement.createTelegramWorkspaceDeadOwnerQueueReclaimer({
392
+ getExternalProtection: captureWorkspaceExternalProtection,
393
+ getActiveTurnTarget: activeTurnRuntime.getTarget,
394
+ getQueuedItems: telegramQueueStore.getQueuedItems,
395
+ resolveLeaderJournal: resolveTelegramUpdateJournalBinding,
396
+ createFollowerJournalResolver:
397
+ telegramJournalBindingRuntime.createRecipientResolver,
398
+ discoverFollowerJournals() {
399
+ return Journal.discoverTelegramFollowerJournalPaths({
400
+ directory: Paths.resolveTelegramTempDir(),
401
+ profileName: configStore.getActiveProfileName(),
402
+ });
403
+ },
404
+ createJournalPathResolver: telegramJournalBindingRuntime.createPathResolver,
405
+ withJournalReference(binding, operation) {
406
+ if (!binding.recoveryKey) throw new Error(
407
+ "Telegram workspace journal reference identity is unavailable.",
408
+ );
409
+ return telegramJournalReferenceRegistry.withReference({
410
+ referenceClass: "workspace-retirement",
411
+ recoveryKey: binding.recoveryKey,
412
+ }, operation);
413
+ },
414
+ getRecoveryOwner() {
415
+ return {
416
+ instanceId: telegramInstanceId,
417
+ processId: telegramProcessId,
418
+ processBirthId: telegramQueueProcessBirthId,
419
+ sessionGeneration: telegramSessionContextStore.getGeneration(),
420
+ };
421
+ },
422
+ getQueueOwnerLiveness: Bus.getTelegramProcessLiveness,
423
+ onMutationError(error) {
424
+ recordRuntimeEvent("Telegram dead-owner queue reclamation was refused.", {
425
+ error: error instanceof Error ? error.message : String(error),
426
+ });
427
+ },
428
+ isBindingCurrent(binding) {
429
+ return WorkspaceRetirement.isCurrentTelegramWorkspaceBinding(
430
+ threadStore,
431
+ binding,
432
+ );
433
+ },
434
+ });
390
435
  const inactiveThreadCleanupReviewRuntime =
391
436
  ThreadCleanupManager.createTelegramInactiveThreadCleanupReviewRuntime({
392
437
  getProfileName() { return configStore.getActiveProfileName() ?? "default"; },
@@ -1410,6 +1455,18 @@ export default function (pi: Pi.ExtensionAPI) {
1410
1455
  getWorkspaceAdmission: workspaceAdmissionRuntime.resolve,
1411
1456
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1412
1457
  captureWorkspaceExternalProtection,
1458
+ workspaceRotation: {
1459
+ getAdmission: workspaceAdmissionRuntime.resolve,
1460
+ runExclusive: telegramWorkspaceOperationRuntime.runExclusive,
1461
+ deleteThread: directTelegramApiRuntime.deleteWorkspaceThread,
1462
+ reclaimDeadOwnerQueuedWork(binding, isCurrent) {
1463
+ return telegramWorkspaceOperationRuntime.run({
1464
+ operationId: WorkspaceAdmission.createTelegramWorkspaceAdmissionOperationId(),
1465
+ operationKind: "workspace.reclaim-dead-owner-queue",
1466
+ scopes: [{ kind: "target", target: binding.target }],
1467
+ }, reclaimTelegramWorkspaceDeadOwnerQueue.bind(undefined, binding, isCurrent));
1468
+ },
1469
+ },
1413
1470
  callApi(method, body, options) {
1414
1471
  return directTelegramApiRuntime.call(method, body, options);
1415
1472
  },
@@ -1545,6 +1602,15 @@ export default function (pi: Pi.ExtensionAPI) {
1545
1602
  recordRuntimeEvent,
1546
1603
  runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1547
1604
  });
1605
+ const prepareThreadPreservationOnQuit = Sync.createTelegramPreservedLeaderQuitHandler({
1606
+ instanceId: telegramInstanceId,
1607
+ topicTargetStore: threadStore,
1608
+ getCurrentLeaderEpoch,
1609
+ getProfileName: configStore.getActiveProfileName,
1610
+ isPollingSuspended: lockedPollingRuntime.isSuspended,
1611
+ resolveAutomaticThreadCleanupEnabled: configControls.resolveAutomaticThreadCleanupEnabled,
1612
+ runWorkspaceOperation: telegramWorkspaceOperationRuntime.run,
1613
+ });
1548
1614
  const telegramBridgeSessionLifecycleDeps =
1549
1615
  Lifecycle.createTelegramBridgeSessionLifecycleDeps({
1550
1616
  contextStore: telegramSessionContextStore,
@@ -1599,6 +1665,7 @@ export default function (pi: Pi.ExtensionAPI) {
1599
1665
  capabilityMonitor: telegramThreadCapabilityMonitor,
1600
1666
  queueWatchdog: queueDispatchWatchdogRuntime,
1601
1667
  guestPlaceholder: { stopAll: guestPlaceholderRuntime.stopAll },
1668
+ prepareThreadPreservationOnQuit,
1602
1669
  },
1603
1670
  });
1604
1671
  const sessionLifecycleRuntime =
@@ -2428,6 +2428,7 @@ export interface TelegramUpdateJournalRuntimeBinding {
2428
2428
  runtimeKey: string;
2429
2429
  recoveryKey: string;
2430
2430
  journal: TelegramUpdateJournalStore;
2431
+ readForProtection?: () => { entries: readonly TelegramUpdateJournalEntry[] };
2431
2432
  }
2432
2433
 
2433
2434
  export interface TelegramUpdateJournalRuntimeBindingResolverDeps {
@@ -2470,6 +2471,19 @@ export function createTelegramUpdateJournalRuntimeBindingResolver(
2470
2471
  profileName,
2471
2472
  botIdentity,
2472
2473
  }),
2474
+ readForProtection() {
2475
+ // Protection must never turn corruption/recovery into empty-work evidence.
2476
+ const evidence = inspectTelegramUpdateJournalFamily({
2477
+ directory: dirname(path), path, profile: profileName, botIdentity,
2478
+ limits: {
2479
+ maxFiles: 1024,
2480
+ maxBytes: TELEGRAM_UPDATE_JOURNAL_MAX_BYTES * 2,
2481
+ maxEntries: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES,
2482
+ maxWork: TELEGRAM_UPDATE_JOURNAL_MAX_ENTRIES * 1024,
2483
+ },
2484
+ });
2485
+ return { entries: evidence.kind === "present" ? evidence.file.entries : [] };
2486
+ },
2473
2487
  journal: createTelegramUpdateJournalStore({
2474
2488
  path,
2475
2489
  profileName,
@@ -244,7 +244,7 @@ export function createTelegramSessionGenerationFence(
244
244
  const generation = store.getGeneration();
245
245
  if (!store.isCurrent(ctx, generation)) return;
246
246
  await hooks.onSessionShutdown(event, ctx);
247
- store.clear(ctx);
247
+ if (store.isCurrent(ctx, generation)) store.clear(ctx);
248
248
  },
249
249
  };
250
250
  }
@@ -266,6 +266,7 @@ export interface TelegramBridgeSessionServiceRuntime {
266
266
  capabilityMonitor: { start(ctx: ExtensionContext): void; stop(): void };
267
267
  queueWatchdog: { start(ctx: ExtensionContext): void; stop(): void };
268
268
  guestPlaceholder?: { stopAll(): void };
269
+ prepareThreadPreservationOnQuit?: (isSessionCurrent: () => boolean) => (() => Promise<void>) | undefined;
269
270
  }
270
271
 
271
272
  export interface TelegramBridgeSessionLifecycleAssemblyDeps<
@@ -322,6 +323,7 @@ export interface TelegramBridgeSessionLifecyclePorts<
322
323
  capabilityMonitor: TelegramBridgeSessionServiceRuntime["capabilityMonitor"];
323
324
  queueWatchdog: TelegramBridgeSessionServiceRuntime["queueWatchdog"];
324
325
  guestPlaceholder?: TelegramBridgeSessionServiceRuntime["guestPlaceholder"];
326
+ prepareThreadPreservationOnQuit?: TelegramBridgeSessionServiceRuntime["prepareThreadPreservationOnQuit"];
325
327
  };
326
328
  }
327
329
 
@@ -350,6 +352,7 @@ export function createTelegramBridgeSessionLifecycleDeps<
350
352
  capabilityMonitor: ports.services.capabilityMonitor,
351
353
  queueWatchdog: ports.services.queueWatchdog,
352
354
  guestPlaceholder: ports.services.guestPlaceholder,
355
+ prepareThreadPreservationOnQuit: ports.services.prepareThreadPreservationOnQuit,
353
356
  },
354
357
  };
355
358
  }
@@ -385,15 +388,31 @@ export function createTelegramBridgeSessionLifecycleAssembly<
385
388
  deps.services.queueWatchdog.start(ctx);
386
389
  },
387
390
  async onSessionShutdown(event, ctx) {
388
- if (!isSessionActive(ctx)) return;
391
+ const generation = deps.contextStore.getGeneration();
392
+ const isCurrent = () => deps.contextStore.isCurrent(ctx, generation);
393
+ if (!isCurrent()) return;
394
+ let preserveThread: (() => Promise<void>) | undefined;
395
+ if (event.reason === "quit") {
396
+ try {
397
+ preserveThread = deps.services.prepareThreadPreservationOnQuit?.(isCurrent);
398
+ } catch (error) {
399
+ deps.follower.recordRuntimeEvent("session", error, { phase: "preserve-thread-on-quit" });
400
+ }
401
+ }
389
402
  deps.services.guestPlaceholder?.stopAll();
390
403
  await deps.services.delivery.onSessionShutdown();
391
- if (!isSessionActive(ctx)) return;
404
+ if (!isCurrent()) return;
392
405
  deps.services.queueWatchdog.stop();
393
406
  deps.services.capabilityMonitor.stop();
394
407
  await queueLifecycle.onSessionShutdown(event, ctx);
395
- if (!isSessionActive(ctx)) return;
408
+ if (!isCurrent()) return;
396
409
  await deps.services.inboundWorker.onSessionShutdown();
410
+ if (!isCurrent()) return;
411
+ try {
412
+ await preserveThread?.();
413
+ } catch (error) {
414
+ deps.follower.recordRuntimeEvent("session", error, { phase: "preserve-thread-on-quit" });
415
+ }
397
416
  },
398
417
  };
399
418
  const followerLifecycle = appendTelegramLifecycleHooks(
@@ -739,7 +739,8 @@ export function isProcessAlive(pid: number): boolean {
739
739
  process.kill(pid, 0);
740
740
  return true;
741
741
  } catch (error) {
742
- return (error as { code?: string }).code === "EPERM";
742
+ // Only an absent PID proves death; permission and unexpected failures do not.
743
+ return (error as { code?: string }).code !== "ESRCH";
743
744
  }
744
745
  }
745
746
 
@@ -1126,6 +1127,7 @@ export interface TelegramLockedPollingRuntime<
1126
1127
  ) => Promise<TelegramLockedPollingStartResult>;
1127
1128
  stop: () => Promise<string>;
1128
1129
  suspend: () => Promise<void>;
1130
+ isSuspended: () => boolean;
1129
1131
  onPersistentConflict: (ctx: TContext, count: number) => Promise<void>;
1130
1132
  onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
1131
1133
  registerFollowerWithOwner?: (
@@ -1190,6 +1192,9 @@ export function createTelegramLockedPollingRuntime<
1190
1192
  let takeoverCandidate: TelegramLockEntry | undefined;
1191
1193
  let sessionAutoStartRun: Promise<void> | undefined;
1192
1194
  let pollingGeneration = 0;
1195
+ let suspendedGeneration: number | undefined;
1196
+ let suspensionsInFlight = 0;
1197
+ let startupsInFlight = 0;
1193
1198
  const ownershipCheckMs =
1194
1199
  deps.ownershipCheckMs ?? TELEGRAM_OWNERSHIP_CHECK_MS;
1195
1200
  const ownershipRefreshMs =
@@ -1201,20 +1206,29 @@ export function createTelegramLockedPollingRuntime<
1201
1206
  ownershipRefreshInterval = undefined;
1202
1207
  };
1203
1208
  const suspendPolling = async () => {
1204
- pollingGeneration += 1;
1205
- activeContext = undefined;
1206
- deps.transportMonitor?.stop();
1207
- deps.stopFollowerRegistration?.();
1208
- stopOwnershipWatcher();
1209
- if (sessionAutoStartRun) {
1210
- await sessionAutoStartRun;
1209
+ const generation = ++pollingGeneration;
1210
+ suspensionsInFlight += 1;
1211
+ try {
1212
+ activeContext = undefined;
1213
+ deps.transportMonitor?.stop();
1211
1214
  deps.stopFollowerRegistration?.();
1215
+ stopOwnershipWatcher();
1216
+ if (sessionAutoStartRun) {
1217
+ await sessionAutoStartRun;
1218
+ deps.stopFollowerRegistration?.();
1219
+ }
1220
+ if (ownershipStop) {
1221
+ await ownershipStop;
1222
+ return;
1223
+ }
1224
+ await deps.stopPolling();
1225
+ // Unsettled starts, overlapping stops or stale completion cannot certify quiescence.
1226
+ if (generation === pollingGeneration && suspensionsInFlight === 1 && startupsInFlight === 0) {
1227
+ suspendedGeneration = generation;
1228
+ }
1229
+ } finally {
1230
+ suspensionsInFlight -= 1;
1212
1231
  }
1213
- if (ownershipStop) {
1214
- await ownershipStop;
1215
- return;
1216
- }
1217
- await deps.stopPolling();
1218
1232
  };
1219
1233
  const stopAfterOwnershipLoss = () => {
1220
1234
  if (ownershipStop) return;
@@ -1261,6 +1275,7 @@ export function createTelegramLockedPollingRuntime<
1261
1275
  if (!isCurrent()) return false;
1262
1276
  activeContext = ctx;
1263
1277
  startOwnershipWatcher(ctx);
1278
+ startupsInFlight += 1;
1264
1279
  try {
1265
1280
  if (!deps.lock.refresh(snapshotLockContext(ctx))) {
1266
1281
  stopOwnershipWatcher();
@@ -1283,6 +1298,8 @@ export function createTelegramLockedPollingRuntime<
1283
1298
  deps.lock.release();
1284
1299
  deps.onTransportAvailabilityChanged?.();
1285
1300
  throw error;
1301
+ } finally {
1302
+ startupsInFlight -= 1;
1286
1303
  }
1287
1304
  if (!isCurrent()) return false;
1288
1305
  if (deps.lock.owns(ctx)) {
@@ -1421,6 +1438,8 @@ export function createTelegramLockedPollingRuntime<
1421
1438
  return "Telegram bridge disconnected.";
1422
1439
  },
1423
1440
  suspend: suspendPolling,
1441
+ isSuspended: () => suspendedGeneration === pollingGeneration &&
1442
+ suspensionsInFlight === 0 && startupsInFlight === 0 && !sessionAutoStartRun && !ownershipStop,
1424
1443
  onPersistentConflict: async (ctx, count) => {
1425
1444
  if (activeContext === undefined || ownershipStop) return;
1426
1445
  if (!(deps.isContextCurrent?.(ctx) ?? activeContext === ctx)) return;
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { createHash } from "node:crypto";
11
11
  import { homedir } from "node:os";
12
- import { join, resolve } from "node:path";
12
+ import { isAbsolute, join, resolve } from "node:path";
13
13
 
14
14
  export const TELEGRAM_DEFAULT_PROFILE_NAME = "default";
15
15
 
@@ -43,6 +43,20 @@ export function resolveAgentDir(
43
43
  return join(homedir(), ".pi", "agent");
44
44
  }
45
45
 
46
+ /**
47
+ * Pure reference preflight against an independently approved resource path.
48
+ * Reject aliases; never repair relative historical references or redirect storage.
49
+ * Equality proves spelling only, not file identity, consumer closure or migration readiness.
50
+ */
51
+ export function requireTelegramStoragePathReference(path: string, expectedPath: string): string {
52
+ if (typeof path !== "string" || typeof expectedPath !== "string" ||
53
+ !isAbsolute(path) || resolve(path) !== path ||
54
+ !isAbsolute(expectedPath) || resolve(expectedPath) !== expectedPath || path !== expectedPath) {
55
+ throw new Error("Telegram storage reference does not match its approved absolute path.");
56
+ }
57
+ return path;
58
+ }
59
+
46
60
  /** Telegram bridge configuration file (<agentDir>/telegram.json). */
47
61
  export function resolveTelegramConfigPath(): string {
48
62
  return join(resolveAgentDir(), "telegram.json");
@@ -100,6 +114,13 @@ export function resolveTelegramWorkspaceAdmissionPath(
100
114
  );
101
115
  }
102
116
 
117
+ /** Profile-only callback shape; binds storage to the configured agent directory. */
118
+ export function resolveTelegramWorkspaceAdmissionPathForProfile(
119
+ profileName?: string,
120
+ ): string {
121
+ return resolveTelegramWorkspaceAdmissionPath(resolveAgentDir(), profileName);
122
+ }
123
+
103
124
  /** Durable inactive Thread cleanup work-set journal. */
104
125
  export function resolveTelegramThreadCleanupWorkPath(
105
126
  agentDir = resolveAgentDir(),
@@ -129,6 +150,13 @@ export function resolveTelegramUpdateJournalPath(
129
150
  );
130
151
  }
131
152
 
153
+ /** Profile-only callback shape; binds storage to the configured agent directory. */
154
+ export function resolveTelegramUpdateJournalPathForProfile(
155
+ profileName?: string,
156
+ ): string {
157
+ return resolveTelegramUpdateJournalPath(resolveAgentDir(), profileName);
158
+ }
159
+
132
160
  /** Durable follower delivery journal, isolated by stable recipient binding. */
133
161
  export function resolveTelegramFollowerJournalPath(
134
162
  recipientBindingKey: string,
@@ -28,8 +28,8 @@ const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
28
28
  const TELEGRAM_POLLING_RETRY_MS = 3_000;
29
29
  export const TELEGRAM_GET_UPDATES_GRACE_MS = 10_000;
30
30
 
31
- // Standard Telegram DM polling does not expose ordinary message-deletion events,
32
- // so queue removal stays reaction-driven while delete-like business updates remain defensive-only.
31
+ // Standard Telegram DM polling does not expose ordinary message-deletion events.
32
+ // Business deletions belong to a separate namespace and default routing ignores them.
33
33
  export const TELEGRAM_ALLOWED_UPDATES = [
34
34
  "message",
35
35
  "edited_message",
@@ -1615,7 +1615,7 @@ export interface TelegramAgentEndHookRuntimeDeps<
1615
1615
  extractAssistant: (
1616
1616
  messages: readonly TMessage[],
1617
1617
  ) => TelegramAgentEndAssistantResult;
1618
- isRecoveredAssistantAlreadyPublished?: (assistant: TelegramAgentEndAssistantResult) => boolean;
1618
+ isAssistantAlreadyPublished?: (assistant: TelegramAgentEndAssistantResult) => boolean;
1619
1619
  getFoldQueuedPromptsIntoHistory: () => boolean;
1620
1620
  resetRuntimeState: () => void;
1621
1621
  isSessionActive?: (ctx: TContext) => boolean;
@@ -1761,8 +1761,7 @@ export function createTelegramAgentEndHook<
1761
1761
  if (deps.isSessionActive?.(ctx) === false) return;
1762
1762
  const turn = deps.getActiveTurn();
1763
1763
  const extractedAssistant = assistantOverride ?? (turn ? deps.extractAssistant(event.messages) : {});
1764
- const assistant = extractedAssistant.recoveredFromEarlier &&
1765
- deps.isRecoveredAssistantAlreadyPublished?.(extractedAssistant)
1764
+ const assistant = deps.isAssistantAlreadyPublished?.(extractedAssistant)
1766
1765
  ? { stopReason: extractedAssistant.stopReason }
1767
1766
  : extractedAssistant;
1768
1767
  const hasPublication = !!assistant.text || assistant.stopReason === "error" || !!turn?.queuedAttachments.length;
@@ -7,6 +7,7 @@
7
7
  import { getTelegramApiErrorRequestTarget, isTelegramStaleTargetHttpError } from "./telegram-api.ts";
8
8
  import { getTelegramTargetKey, type TelegramTarget } from "./target.ts";
9
9
  import * as ThreadReconciler from "./thread-reconciler.ts";
10
+ import { TelegramWorkspaceSlotUnavailableError } from "./workspace-slots.ts";
10
11
  import {
11
12
  createTelegramWorkspaceAdmissionOperationId,
12
13
  runWithTelegramWorkspaceAdmissionsAsync,
@@ -197,6 +198,47 @@ export function markTelegramConfigSyncChange<
197
198
  return nextState;
198
199
  }
199
200
 
201
+ export function createTelegramPreservedLeaderQuitHandler(deps: {
202
+ instanceId: string;
203
+ topicTargetStore: Pick<TelegramTopicTargetStore,
204
+ "load" | "list" | "listPendingCleanups" | "detachTargetOwner">;
205
+ getCurrentLeaderEpoch: () => number | string | undefined;
206
+ getProfileName: () => string | undefined;
207
+ isPollingSuspended: () => boolean;
208
+ resolveAutomaticThreadCleanupEnabled: () => boolean | Promise<boolean>;
209
+ runWorkspaceOperation: TelegramSyncWorkspaceOperationRunner;
210
+ }): (isSessionCurrent: () => boolean) => (() => Promise<void>) | undefined {
211
+ return (isSessionCurrent) => {
212
+ const epoch = deps.getCurrentLeaderEpoch();
213
+ const profile = deps.getProfileName();
214
+ if (epoch === undefined || !isSessionCurrent()) return undefined;
215
+ const isCurrent = () => isSessionCurrent() && deps.getCurrentLeaderEpoch() === epoch &&
216
+ deps.getProfileName() === profile && deps.isPollingSuspended();
217
+ return async () => {
218
+ if (!isCurrent() || await deps.resolveAutomaticThreadCleanupEnabled() !== false) return;
219
+ if (!isCurrent()) return;
220
+ await deps.runWorkspaceOperation({
221
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
222
+ operationKind: "workspace.preserve-leader-quit",
223
+ scopes: [{ kind: "profile" }],
224
+ }, async () => {
225
+ if (!isCurrent()) return;
226
+ await deps.topicTargetStore.load();
227
+ if (!isCurrent()) return;
228
+ const records = deps.topicTargetStore.list().filter((record) =>
229
+ record.instanceId === deps.instanceId && (record.status === "active" || record.status === "starting"));
230
+ if (records.length !== 1) return;
231
+ const record = records[0]!;
232
+ if (deps.topicTargetStore.listPendingCleanups().some((intent) =>
233
+ intent.target.chatId === record.target.chatId && intent.target.threadId === record.target.threadId)) return;
234
+ if (!await deps.topicTargetStore.detachTargetOwner(record, isCurrent) && isCurrent()) {
235
+ throw new Error("Telegram preserved leader detachment was not committed.");
236
+ }
237
+ });
238
+ };
239
+ };
240
+ }
241
+
200
242
  export interface TelegramSessionRestartThreadCleanupDeps<
201
243
  TSyncState extends TelegramSyncState,
202
244
  > extends Omit<TelegramManualThreadDisconnectDeps<TSyncState>, "stopPolling"> {
@@ -650,15 +692,17 @@ export async function ensureTelegramLeaderThreadBinding(
650
692
  const leaderProfileKey = getTelegramThreadOwnerKey(leaderOwner);
651
693
  const legacyLeaderRecord =
652
694
  deps.topicTargetStore.getByProfileKey(leaderProfileKey);
695
+ let capacityUnavailable = false;
653
696
  const workspaceIdentity = normalizedLeaderCwd
654
697
  ? deps.topicTargetStore.claimWorkspaceIdentity(
655
698
  normalizedLeaderCwd,
656
699
  deps.instanceId,
657
700
  legacyLeaderRecord?.instanceId,
658
- { sessionId: deps.sessionId },
701
+ { sessionId: deps.sessionId, onCapacityUnavailable() { capacityUnavailable = true; } },
659
702
  )
660
703
  : undefined;
661
704
  if (deps.cwd && !workspaceIdentity) {
705
+ if (capacityUnavailable) throw new TelegramWorkspaceSlotUnavailableError();
662
706
  throw new Error("Telegram Workspace identity is already claimed.");
663
707
  }
664
708
  const commitWorkspaceBinding = async (
@@ -882,15 +882,18 @@ export function isTelegramApiCommitUnknownError(
882
882
  class TelegramApiHttpError extends Error {
883
883
  readonly status: number | undefined;
884
884
  readonly retryAfterSeconds: number | undefined;
885
+ readonly rejectedRequestMethod: string | undefined;
885
886
  requestTarget?: { chatId: number; threadId: number };
886
887
  constructor(
887
888
  message: string,
888
889
  status: number | undefined,
889
890
  retryAfterSeconds: number | undefined,
891
+ rejectedRequestMethod?: string,
890
892
  ) {
891
893
  super(message);
892
894
  this.status = status;
893
895
  this.retryAfterSeconds = retryAfterSeconds;
896
+ this.rejectedRequestMethod = rejectedRequestMethod;
894
897
  }
895
898
  }
896
899
 
@@ -934,6 +937,12 @@ export function isTelegramStaleTargetHttpError(error: unknown): boolean {
934
937
  return /^Telegram API \w+ failed: HTTP 400: Bad Request: (message thread not found|thread not found|topic not found|topic deleted|topic closed|thread closed|forum topic closed|message thread closed|topic_id_invalid|topic_closed)$/i.test(error.message);
935
938
  }
936
939
 
940
+ /** Only a parsed Telegram rejection of this method proves a request had no effect. */
941
+ export function isTelegramApiRequestRejected(error: unknown, method: string): boolean {
942
+ return error instanceof TelegramApiHttpError && error.rejectedRequestMethod !== undefined &&
943
+ error.rejectedRequestMethod === method;
944
+ }
945
+
937
946
  export function isTelegramMessageNotModifiedError(error: unknown): boolean {
938
947
  return (
939
948
  error instanceof Error && error.message.includes("message is not modified")
@@ -1106,6 +1115,8 @@ async function parseTelegramApiResponse<TResponse>(
1106
1115
  `Telegram API ${method} failed: ${status}${description}`,
1107
1116
  response.status,
1108
1117
  Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined,
1118
+ data?.ok === false && data.error_code === response.status &&
1119
+ [400, 401, 403, 404, 429].includes(response.status) ? method : undefined,
1109
1120
  );
1110
1121
  }
1111
1122
  if (!data) {
@@ -1785,6 +1796,10 @@ export function buildTelegramAnswerGuestQueryBody(
1785
1796
  return body;
1786
1797
  }
1787
1798
 
1799
+ export type TelegramWorkspaceThreadDeletionTransport = (
1800
+ authorize: () => { chatId: number; threadId: number },
1801
+ ) => Promise<void>;
1802
+
1788
1803
  export function createDefaultTelegramBridgeApiRuntime(deps: {
1789
1804
  getBotToken: () => string | undefined;
1790
1805
  recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
@@ -1793,7 +1808,9 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
1793
1808
  workspaceAdmission?:
1794
1809
  | TelegramApiWorkspaceAdmissionPort
1795
1810
  | (() => TelegramApiWorkspaceAdmissionPort | undefined);
1796
- }): TelegramBridgeApiRuntime {
1811
+ }): TelegramBridgeApiRuntime & {
1812
+ deleteWorkspaceThread: TelegramWorkspaceThreadDeletionTransport;
1813
+ } {
1797
1814
  const client = createTelegramApiClient(deps.getBotToken, {
1798
1815
  recordRuntimeEvent: deps.recordRuntimeEvent,
1799
1816
  });
@@ -1807,7 +1824,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
1807
1824
  },
1808
1825
  })
1809
1826
  : client;
1810
- return createTelegramBridgeApiRuntime({
1827
+ const runtime = createTelegramBridgeApiRuntime({
1811
1828
  client: deps.targetActivity
1812
1829
  ? createTelegramApiTargetTrackingClient(admittedClient, deps.targetActivity)
1813
1830
  : admittedClient,
@@ -1817,6 +1834,17 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
1817
1834
  recordRuntimeEvent: deps.recordRuntimeEvent,
1818
1835
  captureRequestErrorHandler: deps.captureRequestErrorHandler,
1819
1836
  });
1837
+ return {
1838
+ ...runtime,
1839
+ async deleteWorkspaceThread(authorize) {
1840
+ // The exclusive deletion permit replaces ordinary target admission.
1841
+ const target = authorize();
1842
+ const deleted = await client.call<boolean>("deleteForumTopic", {
1843
+ chat_id: target.chatId, message_thread_id: target.threadId,
1844
+ }, { maxAttempts: 1, retrySafety: "non-idempotent" });
1845
+ if (deleted !== true) throw new Error("Telegram Workspace Thread deletion was not confirmed.");
1846
+ },
1847
+ };
1820
1848
  }
1821
1849
 
1822
1850
  export function createTelegramBridgeApiRuntime(
@@ -433,7 +433,12 @@ export function createTelegramThreadCleanupWorkStore(options: {
433
433
  publish(file); return { confirmed: true, entry: structuredClone(entry) };
434
434
  });
435
435
  },
436
- list() { return structuredClone(read().workSets); },
436
+ list() {
437
+ return structuredClone(withTelegramFileTransaction(
438
+ `${options.path}.transaction`,
439
+ () => read().workSets,
440
+ ));
441
+ },
437
442
  };
438
443
  }
439
444
 
@@ -542,8 +547,11 @@ export function createTelegramThreadCleanupPermitRuntime(deps: {
542
547
  if (fence.leaderEpoch !== leaderEpoch || fence.owner.processId !== owner.processId ||
543
548
  fence.owner.processBirthId !== owner.processBirthId) {
544
549
  if (!deps.canAdoptFence(fence)) return undefined;
545
- try { fence = deps.ledger.adoptThreadCleanupFence(fence,
546
- { owner, leaderEpoch }); } catch { return undefined; }
550
+ try {
551
+ const adopted = deps.ledger.adoptThreadCleanupFence(fence, { owner, leaderEpoch });
552
+ if (adopted.destructiveKind !== "manual-thread-cleanup") return undefined;
553
+ fence = adopted;
554
+ } catch { return undefined; }
547
555
  }
548
556
  return deps.getLeaderEpoch() === leaderEpoch ? fence : undefined;
549
557
  };