@llblab/pi-kit 0.35.1 → 0.35.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.
Files changed (43) hide show
  1. package/BACKLOG.md +1 -0
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +1 -1
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -1
  5. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -11
  6. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +12 -0
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +22 -0
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +75 -16
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +1 -1
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +11 -1
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +30 -19
  12. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +21 -1
  13. package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +36 -0
  14. package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +80 -0
  15. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +2 -1
  16. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +18 -3
  17. package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +13 -0
  18. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +33 -12
  19. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +11 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.js +31 -8
  21. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +2 -0
  22. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +2 -1
  23. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +5 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +5 -1
  25. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +3 -2
  26. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +6 -2
  27. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +57 -12
  28. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  29. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +3 -1
  30. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +111 -15
  31. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -0
  32. package/node_modules/@llblab/pi-telegram/lib/bus.ts +30 -21
  33. package/node_modules/@llblab/pi-telegram/lib/extension.ts +21 -1
  34. package/node_modules/@llblab/pi-telegram/lib/logging.ts +122 -0
  35. package/node_modules/@llblab/pi-telegram/lib/paths.ts +22 -3
  36. package/node_modules/@llblab/pi-telegram/lib/polling.ts +44 -10
  37. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +40 -7
  38. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +8 -5
  39. package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +6 -1
  40. package/node_modules/@llblab/pi-telegram/lib/threads.ts +12 -5
  41. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +69 -17
  42. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  43. package/package.json +2 -2
@@ -111,6 +111,7 @@ import * as WorkspaceIdentity from "./workspace-identity.ts";
111
111
  const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
112
112
  const TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS = 30_000;
113
113
  const TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS = 30_000;
114
+ const TELEGRAM_BUS_FOLLOWER_API_SLOW_CALL_MS = 1_500;
114
115
  const TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS = 30_000;
115
116
  export const TELEGRAM_BUS_FOLLOWER_HEARTBEAT_TIMEOUT_MS =
116
117
  TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS;
@@ -326,6 +327,20 @@ export interface TelegramBusFollowerClientRuntimeDeps<TMessage = unknown> {
326
327
  timeoutMs?: number;
327
328
  }
328
329
 
330
+ /** Structural port for run-collapsing diagnostics, implemented by the logging domain. */
331
+ export interface TelegramFailureRunLogPort {
332
+ record: (error: unknown, details?: Record<string, unknown>) => boolean;
333
+ settled: () => TelegramFailureRunSummaryPort | undefined;
334
+ reset: () => void;
335
+ }
336
+
337
+ export interface TelegramFailureRunSummaryPort {
338
+ signature: string;
339
+ failedAttempts: number;
340
+ suppressedFailures: number;
341
+ failedForMs: number;
342
+ }
343
+
329
344
  export interface TelegramBusFollowerApiCallerDeps {
330
345
  socketPath: TelegramBusSocketPathSource;
331
346
  instanceId: string;
@@ -337,11 +352,23 @@ export interface TelegramBusFollowerApiCallerDeps {
337
352
  ) => Promise<string | undefined>;
338
353
  getNowMs?: () => number;
339
354
  timeoutMs?: number;
355
+ /** Receives one diagnostic per call slower than `slowCallMs`; observation only. */
356
+ recordRuntimeEvent?: (
357
+ category: string,
358
+ error: unknown,
359
+ details?: Record<string, unknown>,
360
+ ) => void;
361
+ slowCallMs?: number;
340
362
  }
341
363
 
342
364
  export interface TelegramBusFollowerRegistrationRuntimeDeps<
343
365
  TContext extends { cwd?: string },
344
366
  > {
367
+ /**
368
+ * Collapses one sustained heartbeat failure run. Optional at this unit boundary, where an absent port records
369
+ * every failure; the runtime assembly requires it so production always supplies the logging domain's owner.
370
+ */
371
+ failureLog?: TelegramFailureRunLogPort;
345
372
  instanceId: string;
346
373
  createRequestId: () => string;
347
374
  protocolIdentity: TelegramBusProtocolIdentity;
@@ -673,6 +700,8 @@ export interface TelegramBusFollowerRuntimeAssemblyPorts<
673
700
  TContext extends { cwd?: string },
674
701
  > {
675
702
  instanceId: string;
703
+ /** Collapses one sustained heartbeat failure run; the composition root supplies the logging domain's owner. */
704
+ failureLog: TelegramFailureRunLogPort;
676
705
  registrationState: TelegramBusFollowerRegistrationState;
677
706
  recordRuntimeEvent: (
678
707
  category: string,
@@ -789,6 +818,7 @@ export function createTelegramBusFollowerRuntimeAssembly<
789
818
  });
790
819
  registration = createTelegramBusFollowerRegistrationRuntime({
791
820
  ...ports.registration,
821
+ failureLog: ports.failureLog,
792
822
  ...sharedRuntimeDeps,
793
823
  startReceiving: receiver.start,
794
824
  stopReceiving: receiver.stop,
@@ -1495,6 +1525,7 @@ export function createTelegramBusFollowerClientRuntime<
1495
1525
  instanceId: deps.instanceId,
1496
1526
  getAuthSecret: deps.getApiAuthSecret,
1497
1527
  getRegistrationGeneration: deps.getRegistrationGeneration,
1528
+ recordRuntimeEvent: deps.recordRuntimeEvent,
1498
1529
  }),
1499
1530
  agentMessages: createTelegramBusAgentMessageClient({
1500
1531
  ...sharedClientDeps,
@@ -1881,7 +1912,13 @@ export function createTelegramBusFollowerApiCaller(
1881
1912
  ): (method: string, args: unknown[], localOptions?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<unknown> {
1882
1913
  const getNowMs = deps.getNowMs ?? Date.now;
1883
1914
  const timeoutMs = deps.timeoutMs ?? TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS;
1884
- return async (method, args, localOptions) => {
1915
+ const slowCallMs = deps.slowCallMs ?? TELEGRAM_BUS_FOLLOWER_API_SLOW_CALL_MS;
1916
+ const caller = async (
1917
+ method: string,
1918
+ args: unknown[],
1919
+ localOptions: Pick<TelegramApiCallOptions, "assertAuthority"> | undefined,
1920
+ timing: { registeredAtMs?: number },
1921
+ ): Promise<unknown> => {
1885
1922
  const assertion = localOptions?.assertAuthority;
1886
1923
  const assertCurrent = () => assertTelegramApiCallAuthority(assertion, false);
1887
1924
  assertCurrent();
@@ -1889,6 +1926,7 @@ export function createTelegramBusFollowerApiCaller(
1889
1926
  deps,
1890
1927
  timeoutMs,
1891
1928
  );
1929
+ timing.registeredAtMs = getNowMs();
1892
1930
  assertCurrent();
1893
1931
  const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
1894
1932
  let response: TelegramBusEnvelope | undefined;
@@ -1950,6 +1988,45 @@ export function createTelegramBusFollowerApiCaller(
1950
1988
  }
1951
1989
  throw new Error(message ?? "Telegram bus API call failed.");
1952
1990
  };
1991
+ return async (method, args, localOptions) => {
1992
+ const startedAtMs = getNowMs();
1993
+ const timing: { registeredAtMs?: number } = {};
1994
+ let outcome = "ok";
1995
+ try {
1996
+ return await caller(method, args, localOptions, timing);
1997
+ } catch (error) {
1998
+ outcome = "error";
1999
+ throw error;
2000
+ } finally {
2001
+ const elapsedMs = getNowMs() - startedAtMs;
2002
+ if (elapsedMs >= slowCallMs) {
2003
+ const apiMethod =
2004
+ (method === "call" || method === "callMultipart") &&
2005
+ typeof args[0] === "string"
2006
+ ? args[0]
2007
+ : method;
2008
+ try {
2009
+ deps.recordRuntimeEvent?.(
2010
+ "bus",
2011
+ `Telegram bus follower API call was slow (${elapsedMs}ms).`,
2012
+ {
2013
+ phase: "follower-api-slow",
2014
+ method: apiMethod,
2015
+ elapsedMs,
2016
+ registrationWaitMs:
2017
+ (timing.registeredAtMs ?? getNowMs()) - startedAtMs,
2018
+ ...(timing.registeredAtMs === undefined
2019
+ ? {}
2020
+ : { transportMs: getNowMs() - timing.registeredAtMs }),
2021
+ outcome,
2022
+ },
2023
+ );
2024
+ } catch {
2025
+ // Diagnostics cannot change the API call outcome.
2026
+ }
2027
+ }
2028
+ }
2029
+ };
1953
2030
  }
1954
2031
 
1955
2032
  async function resolveTelegramBusFollowerRegistration(
@@ -2521,10 +2598,16 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
2521
2598
  election: TelegramBusFollowerElection,
2522
2599
  ) => {
2523
2600
  if (!isCurrent()) return;
2524
- const activeCtx = deps.getActiveContext?.();
2525
- if (deps.getActiveContext && activeCtx !== ctx) {
2526
- scheduleRecovery(reason, ctx, binding);
2527
- return;
2601
+ if (deps.getActiveContext) {
2602
+ // Pi hands every event and command a fresh context object, so a manually connected follower never holds
2603
+ // the stored session object. Promote through the live session context instead of rescheduling: heartbeat
2604
+ // failures re-request recovery faster than the retry grace, so a reschedule-only path never fires.
2605
+ const activeCtx = deps.getActiveContext();
2606
+ if (!activeCtx) {
2607
+ scheduleRecovery(reason, ctx, binding);
2608
+ return;
2609
+ }
2610
+ ctx = activeCtx;
2528
2611
  }
2529
2612
  deps.setLifecyclePhase("electing");
2530
2613
  safeUpdateStatus(ctx);
@@ -2765,6 +2848,8 @@ export function createTelegramBusFollowerRegistrationRuntime<
2765
2848
  (!deps.registrationState ||
2766
2849
  deps.registrationState.getGeneration() === generation);
2767
2850
  const stopHeartbeat = () => {
2851
+ // A stopped heartbeat ends the current failure run, so a later run logs its own first failure.
2852
+ heartbeatFailureLog.reset();
2768
2853
  if (!heartbeatInterval) return;
2769
2854
  clearInterval(heartbeatInterval);
2770
2855
  heartbeatInterval = undefined;
@@ -2793,6 +2878,22 @@ export function createTelegramBusFollowerRegistrationRuntime<
2793
2878
  }
2794
2879
  });
2795
2880
  };
2881
+ /** A sustained leader outage repeats one identical failure every interval; the port collapses the run. */
2882
+ const heartbeatFailureLog: TelegramFailureRunLogPort =
2883
+ deps.failureLog ?? {
2884
+ record(error) {
2885
+ try {
2886
+ deps.recordRuntimeEvent?.("bus", error, {
2887
+ phase: "follower-heartbeat",
2888
+ });
2889
+ } catch {
2890
+ // Diagnostics cannot replace heartbeat recovery.
2891
+ }
2892
+ return true;
2893
+ },
2894
+ settled: () => undefined,
2895
+ reset: () => undefined,
2896
+ };
2796
2897
  const sendHeartbeat = async () => {
2797
2898
  const leaderSocketPath = activeLeaderSocketPath;
2798
2899
  const registrationGeneration = activeRegistrationGeneration;
@@ -2818,6 +2919,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
2818
2919
  );
2819
2920
  if (!isCurrentHeartbeat()) return;
2820
2921
  if (response?.kind === "bus.ack" && response.ok) {
2922
+ heartbeatFailureLog.settled();
2821
2923
  const heartbeatResult = isRecord(response.result)
2822
2924
  ? response.result
2823
2925
  : undefined;
@@ -2850,13 +2952,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
2850
2952
  }
2851
2953
  } catch (error) {
2852
2954
  if (!isCurrentHeartbeat()) return;
2853
- try {
2854
- deps.recordRuntimeEvent?.("bus", error, {
2855
- phase: "follower-heartbeat",
2856
- });
2857
- } catch {
2858
- // Diagnostics cannot replace heartbeat recovery.
2859
- }
2955
+ heartbeatFailureLog.record(error);
2860
2956
  if (!heartbeatContext) return;
2861
2957
  try {
2862
2958
  await deps.onHeartbeatFailure?.(error, heartbeatContext);
@@ -3120,11 +3216,11 @@ export function createTelegramBusFollowerRegistrationRuntime<
3120
3216
  return false;
3121
3217
  },
3122
3218
  setContext(ctx) {
3219
+ // Pi hands every event a fresh context object. Only a replaced session drops an in-flight attempt.
3123
3220
  if (
3124
3221
  registrationAttempt &&
3125
- (registrationAttempt.ctx !== ctx ||
3126
- registrationAttempt.sessionGeneration !==
3127
- deps.getSessionGeneration?.() ||
3222
+ (registrationAttempt.sessionGeneration !==
3223
+ deps.getSessionGeneration?.() ||
3128
3224
  registrationAttempt.sessionId !== deps.getSessionId?.(ctx))
3129
3225
  )
3130
3226
  registrationAttempt = undefined;
@@ -167,6 +167,7 @@ export interface TelegramBusFollowerTargetProvisionerDeps {
167
167
  callApi: <TResponse>(
168
168
  method: string,
169
169
  body: Record<string, unknown>,
170
+ options?: TelegramApiCallOptions,
170
171
  ) => Promise<TResponse>;
171
172
  getCurrentLeaderEpoch?: () => number | string | undefined;
172
173
  getSyncState: () => Sync.TelegramSyncState;
@@ -1925,6 +1926,8 @@ export function createTelegramBusFollowerTargetProvisioner(
1925
1926
  text: connectedAnnouncement.text,
1926
1927
  parse_mode: connectedAnnouncement.parseMode,
1927
1928
  },
1929
+ // A deleted Thread is this probe's expected answer; the recovery below records it.
1930
+ { isExpectedError: Threads.isTelegramTopicTargetStaleError },
1928
1931
  );
1929
1932
  assertRestoreRegistration();
1930
1933
  if (recoverableTarget || probeRequiredRecord) {
@@ -1968,7 +1971,19 @@ export function createTelegramBusFollowerTargetProvisioner(
1968
1971
  error instanceof Error ? error.message : String(error),
1969
1972
  );
1970
1973
  await deps.topicTargetStore.persist();
1974
+ const staleTarget = result.target;
1971
1975
  result = await provisionTarget();
1976
+ deps.recordRuntimeEvent(
1977
+ "telegram",
1978
+ "Telegram follower restore probe found its Thread deleted; re-created it.",
1979
+ {
1980
+ phase: "topic-lifecycle-restore-probe-recreated",
1981
+ instanceId: registration.instanceId,
1982
+ chatId: staleTarget.chatId,
1983
+ staleThreadId: staleTarget.threadId,
1984
+ threadId: result.target.threadId,
1985
+ },
1986
+ );
1972
1987
  connectedAnnouncement =
1973
1988
  createTelegramBusInstanceLifecycleAnnouncement({
1974
1989
  target: result.target,
@@ -3297,33 +3297,42 @@ export async function sendTelegramBusLocalEnvelope(
3297
3297
  };
3298
3298
  const attempts = Math.max(1, resolvedOptions.retry?.attempts ?? 1);
3299
3299
  const delayMs = Math.max(0, resolvedOptions.retry?.delayMs ?? 0);
3300
+ // Log the first failure, then only the outcome: a reloading leader would otherwise log every attempt.
3300
3301
  for (let attempt = 1; ; attempt += 1) {
3301
3302
  try {
3302
- return await sendTelegramBusLocalEnvelopeOnce(resolvedOptions);
3303
+ const response = await sendTelegramBusLocalEnvelopeOnce(resolvedOptions);
3304
+ if (attempt > 1)
3305
+ resolvedOptions.recordTransportEvent?.("client-recovered", {
3306
+ ...getTelegramBusEndpointDiagnostics(resolvedOptions.socketPath),
3307
+ ...getTelegramBusEnvelopeDiagnostics(resolvedOptions.envelope),
3308
+ attempt,
3309
+ attempts,
3310
+ });
3311
+ return response;
3303
3312
  } catch (error) {
3304
3313
  const info = classifyTelegramBusTransportError(error);
3305
- resolvedOptions.recordTransportEvent?.("client-failed", {
3306
- ...getTelegramBusEndpointDiagnostics(resolvedOptions.socketPath),
3307
- ...getTelegramBusEnvelopeDiagnostics(resolvedOptions.envelope),
3308
- attempt,
3309
- attempts,
3310
- ...info,
3311
- });
3312
- if (
3314
+ const final =
3313
3315
  error instanceof TelegramBusLocalAuthorityError ||
3314
3316
  attempt >= attempts ||
3315
- !isRetryableTelegramBusTransportError(error)
3316
- ) {
3317
- throw error;
3318
- }
3319
- resolvedOptions.recordTransportEvent?.("client-retry", {
3320
- ...getTelegramBusEndpointDiagnostics(resolvedOptions.socketPath),
3321
- ...getTelegramBusEnvelopeDiagnostics(resolvedOptions.envelope),
3322
- attempt,
3323
- attempts,
3324
- delayMs,
3325
- ...info,
3326
- });
3317
+ !isRetryableTelegramBusTransportError(error);
3318
+ if (attempt === 1 || final)
3319
+ resolvedOptions.recordTransportEvent?.("client-failed", {
3320
+ ...getTelegramBusEndpointDiagnostics(resolvedOptions.socketPath),
3321
+ ...getTelegramBusEnvelopeDiagnostics(resolvedOptions.envelope),
3322
+ attempt,
3323
+ attempts,
3324
+ ...info,
3325
+ });
3326
+ if (final) throw error;
3327
+ if (attempt === 1)
3328
+ resolvedOptions.recordTransportEvent?.("client-retry", {
3329
+ ...getTelegramBusEndpointDiagnostics(resolvedOptions.socketPath),
3330
+ ...getTelegramBusEnvelopeDiagnostics(resolvedOptions.envelope),
3331
+ attempt,
3332
+ attempts,
3333
+ delayMs,
3334
+ ...info,
3335
+ });
3327
3336
  await delayTelegramBusTransportRetry(delayMs);
3328
3337
  }
3329
3338
  }
@@ -861,6 +861,12 @@ export default function (pi: Pi.ExtensionAPI) {
861
861
  const promptDispatchRuntime = Runtime.createTelegramPromptDispatchRuntime({
862
862
  lifecycle,
863
863
  typing,
864
+ failureLog: runtimeDiagnostics.createFailureLog({
865
+ category: "typing",
866
+ failurePhase: "typing-presence",
867
+ outcomePhase: "typing-presence-recovered",
868
+ label: "typing presence",
869
+ }),
864
870
  getDefaultChatId: proactivePushChatIdGetter,
865
871
  isThreadedMode() {
866
872
  return deliveryTargetPolicyRuntime.getTargetPolicyView().threaded === true;
@@ -1576,12 +1582,14 @@ export default function (pi: Pi.ExtensionAPI) {
1576
1582
  target.threadId === undefined
1577
1583
  )
1578
1584
  return undefined;
1585
+ // Pi creates a fresh context object per event and command, so authority follows the session, not the object.
1586
+ const sessionGeneration = telegramSessionContextStore.getGeneration();
1579
1587
  return Threads.createTelegramWorkspaceThreadRenameRecipient({
1580
1588
  store: threadStore,
1581
1589
  instanceId: telegramInstanceId,
1582
1590
  target,
1583
1591
  assertAuthority() {
1584
- if (telegramSessionContextStore.get() !== ctx)
1592
+ if (!telegramSessionContextStore.isCurrent(ctx, sessionGeneration))
1585
1593
  throw new Error("Telegram Thread name dialog lost Pi context.");
1586
1594
  },
1587
1595
  getAuthority: threadNameRecipientAuthority.getAuthority,
@@ -1968,6 +1976,12 @@ export default function (pi: Pi.ExtensionAPI) {
1968
1976
  const telegramBusFollowerAssembly: BusFollower.TelegramBusFollowerRuntimeAssembly<Pi.ExtensionContext> =
1969
1977
  BusFollower.createTelegramBusFollowerRuntimeAssembly<Pi.ExtensionContext>({
1970
1978
  instanceId: telegramInstanceId,
1979
+ failureLog: runtimeDiagnostics.createFailureLog({
1980
+ category: "bus",
1981
+ failurePhase: "follower-heartbeat",
1982
+ outcomePhase: "follower-heartbeat-recovered",
1983
+ label: "follower heartbeat",
1984
+ }),
1971
1985
  registrationState: telegramBusFollowerRegistrationState,
1972
1986
  recordRuntimeEvent,
1973
1987
  receiver: {
@@ -2370,6 +2384,12 @@ export default function (pi: Pi.ExtensionAPI) {
2370
2384
  Locks.TelegramLockEntry
2371
2385
  >({
2372
2386
  state: telegramThreadCapabilityState,
2387
+ failureLog: runtimeDiagnostics.createFailureLog({
2388
+ category: "bus",
2389
+ failurePhase: "capability-monitor",
2390
+ outcomePhase: "capability-monitor-recovered",
2391
+ label: "Threaded Mode capability probe",
2392
+ }),
2373
2393
  getAllowedUserId: configStore.getAllowedUserId,
2374
2394
  callApi: callTelegramApi,
2375
2395
  topicTargetStore: threadStore,
@@ -23,6 +23,118 @@ import {
23
23
  } from "./paths.ts";
24
24
  import * as Status from "./status.ts";
25
25
 
26
+ /** Sink shape shared with the runtime event recorder this domain already owns. */
27
+ export type TelegramFailureRecordSink = (
28
+ category: string,
29
+ errorOrMessage: unknown,
30
+ details: Record<string, unknown>,
31
+ ) => void;
32
+
33
+ export interface TelegramFailureRunSummary {
34
+ signature: string;
35
+ failedAttempts: number;
36
+ suppressedFailures: number;
37
+ failedForMs: number;
38
+ }
39
+
40
+ export interface TelegramFailureRunLog {
41
+ /** Records one failure; a repeated identical failure only extends the current run and returns false. */
42
+ record: (error: unknown, details?: Record<string, unknown>) => boolean;
43
+ /** Closes the run after a success, emitting its single outcome record. */
44
+ settled: () => TelegramFailureRunSummary | undefined;
45
+ /** Ends the run without an outcome record, for a stop or replacement that reports its own outcome. */
46
+ reset: () => void;
47
+ }
48
+
49
+ export interface TelegramFailureRunLogInput {
50
+ category: string;
51
+ failurePhase: string;
52
+ outcomePhase: string;
53
+ /** Human-readable subject for the outcome record, e.g. `follower heartbeat`. */
54
+ label: string;
55
+ record?: TelegramFailureRecordSink;
56
+ /** Static context reapplied to every record of this phase, e.g. a session or profile key. */
57
+ context?: () => Record<string, unknown>;
58
+ getNowMs?: () => number;
59
+ }
60
+
61
+ /** Stable identity of one failure kind: errno when present, otherwise the failure text. */
62
+ export function getTelegramFailureSignature(error: unknown): string {
63
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
64
+ if (typeof code === "string" && code) return code;
65
+ return error instanceof Error ? error.message : String(error);
66
+ }
67
+
68
+ /**
69
+ * Consecutive identical failure runs for one diagnostic phase: a repeat extends the run instead of logging again,
70
+ * and a closing success or reset emits at most one outcome record. This never gates detection or recovery.
71
+ */
72
+ export function createTelegramFailureRunLog(
73
+ input: TelegramFailureRunLogInput,
74
+ ): TelegramFailureRunLog {
75
+ const getNowMs = input.getNowMs ?? Date.now;
76
+ let run: { signature: string; suppressed: number; startedAtMs: number; details: Record<string, unknown> } | undefined;
77
+ const emit = (errorOrMessage: unknown, details: Record<string, unknown>): void => {
78
+ try {
79
+ input.record?.(input.category, errorOrMessage, {
80
+ ...(input.context?.() ?? {}),
81
+ ...details,
82
+ });
83
+ } catch {
84
+ // A diagnostic sink never converts a recorded failure into another failure.
85
+ }
86
+ };
87
+ return {
88
+ record(error, details) {
89
+ const signature = getTelegramFailureSignature(error);
90
+ const admitted = { ...(details ?? {}) };
91
+ if (run?.signature === signature) {
92
+ run.suppressed += 1;
93
+ Object.assign(run.details, admitted);
94
+ return false;
95
+ }
96
+ const previous = run;
97
+ run = { signature, suppressed: 0, startedAtMs: getNowMs(), details: admitted };
98
+ emit(error, {
99
+ ...admitted,
100
+ phase: input.failurePhase,
101
+ failureSignature: signature,
102
+ ...(previous
103
+ ? {
104
+ previousFailureSignature: previous.signature,
105
+ previousFailedAttempts: previous.suppressed + 1,
106
+ previousFailedForMs: getNowMs() - previous.startedAtMs,
107
+ }
108
+ : {}),
109
+ });
110
+ return true;
111
+ },
112
+ settled() {
113
+ const closed = run;
114
+ if (!closed) return undefined;
115
+ run = undefined;
116
+ const summary: TelegramFailureRunSummary = {
117
+ signature: closed.signature,
118
+ failedAttempts: closed.suppressed + 1,
119
+ suppressedFailures: closed.suppressed,
120
+ failedForMs: getNowMs() - closed.startedAtMs,
121
+ };
122
+ emit(`Telegram ${input.label} recovered after ${summary.failedAttempts} failed attempts.`, {
123
+ ...closed.details,
124
+ phase: input.outcomePhase,
125
+ failureSignature: summary.signature,
126
+ failedAttempts: summary.failedAttempts,
127
+ suppressedFailures: summary.suppressedFailures,
128
+ failedForMs: summary.failedForMs,
129
+ });
130
+ return summary;
131
+ },
132
+ reset() {
133
+ run = undefined;
134
+ },
135
+ };
136
+ }
137
+
26
138
  type TelegramLogPathInput = string | (() => string);
27
139
 
28
140
  interface TelegramRuntimeJsonlEvent {
@@ -391,6 +503,10 @@ interface TelegramRuntimeDiagnosticsRuntime<TContext> {
391
503
  error: unknown,
392
504
  details?: Record<string, unknown>,
393
505
  ): void;
506
+ /** A phase-scoped failure log whose records travel through this runtime's own event pipeline. */
507
+ createFailureLog(
508
+ input: Omit<TelegramFailureRunLogInput, "record">,
509
+ ): TelegramFailureRunLog;
394
510
  bindStorage(ports: {
395
511
  getBotToken(): string | undefined;
396
512
  getProfileName(): string | undefined;
@@ -498,6 +614,12 @@ export function createTelegramRuntimeDiagnosticsRuntime<TContext>(
498
614
  };
499
615
  return {
500
616
  events,
617
+ createFailureLog: (input) =>
618
+ createTelegramFailureRunLog({
619
+ ...input,
620
+ record: (category, errorOrMessage, details) =>
621
+ recordRuntimeEvent(category, errorOrMessage, details),
622
+ }),
501
623
  recordRuntimeEvent,
502
624
  bindStorage(ports) {
503
625
  getBotToken = ports.getBotToken;
@@ -167,13 +167,32 @@ export function resolveTelegramProfileTempFilePath(
167
167
  );
168
168
  }
169
169
 
170
- export function getTelegramDiagnosticsDisplayPaths(_profileName?: string): {
170
+ /** Shared state/log paths for every profile, under the resolved agent dir (honors `PI_CODING_AGENT_DIR`). */
171
+ export function getTelegramDiagnosticsDisplayPaths(
172
+ _profileName?: string,
173
+ agentDir = resolveAgentDir(),
174
+ ): {
171
175
  state: string;
172
176
  logs: string;
173
177
  } {
178
+ // Storage paths are canonical, so also match a canonical home (e.g. /home -> /var/home).
179
+ const homes = [homedir()];
180
+ try {
181
+ homes.push(realpathSync(homes[0]!));
182
+ } catch {
183
+ /* An unreadable home only loses the ~ abbreviation. */
184
+ }
185
+ const display = (path: string): string => {
186
+ const home = homes.find(
187
+ (candidate) => path === candidate || path.startsWith(`${candidate}${sep}`),
188
+ );
189
+ return home
190
+ ? `~${path.slice(home.length).split(sep).join("/")}`
191
+ : path;
192
+ };
174
193
  return {
175
- state: "~/.pi/agent/tmp/pi-telegram/state.json",
176
- logs: "~/.pi/agent/tmp/pi-telegram/logs.jsonl",
194
+ state: display(resolveTelegramStatePath(agentDir)),
195
+ logs: display(resolveTelegramRuntimeLogPath(agentDir)),
177
196
  };
178
197
  }
179
198
 
@@ -6,6 +6,16 @@
6
6
 
7
7
  type MaybePromise<T> = T | Promise<T>;
8
8
 
9
+ /**
10
+ * Structural port for run-collapsing diagnostics: a repeated identical failure extends its run instead of logging
11
+ * again, and the closing success records one outcome. The composition root owns the collapsed implementation.
12
+ */
13
+ export interface TelegramRepeatedFailureLogPort {
14
+ record: (error: unknown, details?: Record<string, unknown>) => void;
15
+ settled: () => void;
16
+ reset: () => void;
17
+ }
18
+
9
19
  export interface TelegramPollingConfig {
10
20
  botToken?: string;
11
21
  }
@@ -682,6 +692,8 @@ export interface TelegramStartupThreadCapabilityProbeDeps extends TelegramThread
682
692
  export interface TelegramThreadCapabilityRuntimeDeps<
683
693
  TContext,
684
694
  > extends TelegramThreadCapabilityReaderDeps {
695
+ /** Collapses one sustained probe failure run; the caller supplies its owner. */
696
+ failureLog: TelegramRepeatedFailureLogPort;
685
697
  lifecycle?: TelegramThreadCapabilityLifecycle;
686
698
  topicTargetStore: TelegramThreadCapabilityStore;
687
699
  ownsLock: (ctx: TContext) => boolean;
@@ -803,6 +815,8 @@ export interface TelegramThreadCapabilityOrchestrationDeps<TContext, TOwner>
803
815
  extends
804
816
  TelegramThreadCapabilityReaderDeps,
805
817
  TelegramThreadAwarePollingControls<TContext, TOwner> {
818
+ /** Collapses one sustained probe failure run; the composition root supplies its owner. */
819
+ failureLog: TelegramRepeatedFailureLogPort;
806
820
  state: TelegramThreadCapabilityStateRuntime;
807
821
  topicTargetStore: TelegramThreadCapabilityStore;
808
822
  ownsLock: (ctx: TContext) => boolean;
@@ -882,6 +896,7 @@ export function createTelegramThreadCapabilityOrchestration<TContext, TOwner>(
882
896
  isTopicModeUnavailableError: deps.isTopicModeUnavailableError,
883
897
  updateStatus: deps.updateStatus,
884
898
  recordEvent: deps.recordEvent,
899
+ failureLog: deps.failureLog,
885
900
  };
886
901
  return {
887
902
  monitor: createTelegramThreadCapabilityMonitor(capabilityDeps),
@@ -1269,8 +1284,34 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1269
1284
  let generation = 0;
1270
1285
  let transitionPromise: Promise<void> | undefined;
1271
1286
  let consecutiveDisabledProbes = 0;
1287
+ // An absent leader endpoint repeats one identical probe failure every interval; the port collapses the run.
1288
+ // Running it through one guard keeps a faulty diagnostics port from breaking probes or the interval callback.
1289
+ const capabilityFailureLog: TelegramRepeatedFailureLogPort = {
1290
+ record(error, details) {
1291
+ try {
1292
+ deps.failureLog.record(error, details);
1293
+ } catch {
1294
+ // Monitor diagnostics cannot create an uncaught interval exception.
1295
+ }
1296
+ },
1297
+ settled() {
1298
+ try {
1299
+ deps.failureLog.settled();
1300
+ } catch {
1301
+ // Monitor diagnostics cannot fail a completed probe.
1302
+ }
1303
+ },
1304
+ reset() {
1305
+ try {
1306
+ deps.failureLog.reset();
1307
+ } catch {
1308
+ // Monitor diagnostics cannot fail a monitor stop.
1309
+ }
1310
+ },
1311
+ };
1272
1312
  const stop = (): void => {
1273
1313
  generation += 1;
1314
+ capabilityFailureLog.reset();
1274
1315
  if (interval) clearInterval(interval);
1275
1316
  interval = undefined;
1276
1317
  deps.lifecycle?.invalidate();
@@ -1287,6 +1328,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1287
1328
  tracked = readTelegramThreadCapability(deps)
1288
1329
  .then(async (threadModeEnabled) => {
1289
1330
  if (!isCurrent()) return;
1331
+ capabilityFailureLog.settled();
1290
1332
  if (threadModeEnabled === undefined) {
1291
1333
  if (
1292
1334
  deps.topicTargetStore.getBotState().threadMode !== "enabled" &&
@@ -1357,11 +1399,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1357
1399
  })
1358
1400
  .catch((error) => {
1359
1401
  if (!isCurrent()) return;
1360
- try {
1361
- deps.recordEvent("bus", error, { phase: "capability-monitor" });
1362
- } catch {
1363
- // Monitor diagnostics cannot create an unhandled interval rejection.
1364
- }
1402
+ capabilityFailureLog.record(error);
1365
1403
  })
1366
1404
  .finally(() => {
1367
1405
  if (transitionPromise === tracked) transitionPromise = undefined;
@@ -1384,11 +1422,7 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1384
1422
  } catch {
1385
1423
  /* Timer shutdown must not escape the callback. */
1386
1424
  }
1387
- try {
1388
- deps.recordEvent("bus", error, { phase: "capability-monitor" });
1389
- } catch {
1390
- // Monitor diagnostics cannot create an uncaught interval exception.
1391
- }
1425
+ capabilityFailureLog.record(error);
1392
1426
  }
1393
1427
  }, intervalMs);
1394
1428
  interval.unref?.();