@gajae-code/coding-agent 0.11.8 → 0.11.10

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 (101) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/dist/types/cli.d.ts +1 -0
  3. package/dist/types/config/model-profiles.d.ts +2 -3
  4. package/dist/types/config/model-registry.d.ts +2 -0
  5. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  6. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  7. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  8. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  9. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  10. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  11. package/dist/types/gjc-runtime/state-writer.d.ts +45 -0
  12. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  13. package/dist/types/modes/components/welcome.d.ts +2 -0
  14. package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
  15. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  16. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -1
  17. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  18. package/dist/types/sdk/bus/index.d.ts +8 -1
  19. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  20. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +14 -7
  21. package/dist/types/sdk/bus/telegram-daemon.d.ts +74 -5
  22. package/dist/types/sdk/session.d.ts +7 -0
  23. package/dist/types/session/agent-session.d.ts +2 -0
  24. package/dist/types/session/internal/managed-session-scope.d.ts +13 -3
  25. package/dist/types/session/internal/managed-session-storage.d.ts +8 -3
  26. package/dist/types/session/session-manager.d.ts +5 -1
  27. package/dist/types/session/session-storage.d.ts +13 -0
  28. package/dist/types/skill-state/active-state.d.ts +4 -0
  29. package/dist/types/task/provider-retry-status.d.ts +13 -0
  30. package/dist/types/task/types.d.ts +3 -0
  31. package/dist/types/tools/index.d.ts +3 -1
  32. package/package.json +8 -8
  33. package/scripts/generate-sdk-operation-inventory.ts +1 -0
  34. package/src/cli/mcp-cli.ts +4 -21
  35. package/src/cli/notify-cli.ts +1 -0
  36. package/src/cli.ts +32 -0
  37. package/src/commands/deep-interview.ts +9 -4
  38. package/src/commit/agentic/index.ts +3 -3
  39. package/src/commit/map-reduce/index.ts +2 -2
  40. package/src/config/model-profiles.ts +9 -10
  41. package/src/config/model-registry.ts +4 -2
  42. package/src/defaults/gjc/skills/deep-interview/SKILL.md +78 -163
  43. package/src/eval/js/tool-bridge.ts +1 -1
  44. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  45. package/src/gjc-runtime/deep-interview-draft.ts +843 -0
  46. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  47. package/src/gjc-runtime/deep-interview-recorder.ts +310 -227
  48. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  49. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  50. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  51. package/src/gjc-runtime/ralplan-runtime.ts +3 -1
  52. package/src/gjc-runtime/state-writer.ts +166 -2
  53. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  54. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  55. package/src/hooks/skill-state.ts +23 -1
  56. package/src/internal-urls/docs-index.generated.ts +6 -5
  57. package/src/modes/components/welcome.ts +10 -2
  58. package/src/modes/controllers/selector-controller.ts +38 -8
  59. package/src/modes/interactive-mode.ts +29 -9
  60. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  61. package/src/prompts/agents/architect.md +8 -1
  62. package/src/prompts/agents/critic.md +8 -1
  63. package/src/prompts/agents/planner.md +8 -1
  64. package/src/prompts/system/system-prompt.md +1 -0
  65. package/src/runtime-mcp/json-rpc.ts +7 -4
  66. package/src/runtime-mcp/redaction.ts +30 -0
  67. package/src/sdk/broker/identity.ts +22 -12
  68. package/src/sdk/broker/session-index.ts +11 -1
  69. package/src/sdk/bus/chat-daemon-control.ts +5 -3
  70. package/src/sdk/bus/daemon-paths.ts +2 -0
  71. package/src/sdk/bus/index.ts +60 -5
  72. package/src/sdk/bus/notification-service.ts +19 -5
  73. package/src/sdk/bus/operator-runtime.ts +31 -5
  74. package/src/sdk/bus/telegram-cli.ts +6 -13
  75. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  76. package/src/sdk/bus/telegram-daemon-contract.ts +15 -7
  77. package/src/sdk/bus/telegram-daemon-control.ts +14 -5
  78. package/src/sdk/bus/telegram-daemon.ts +685 -120
  79. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  80. package/src/sdk/session.ts +12 -0
  81. package/src/session/agent-session.ts +140 -10
  82. package/src/session/internal/managed-session-scope.ts +339 -228
  83. package/src/session/internal/managed-session-storage.ts +17 -4
  84. package/src/session/session-manager.ts +96 -33
  85. package/src/session/session-storage.ts +23 -0
  86. package/src/skill-state/active-state.ts +10 -0
  87. package/src/skill-state/workflow-mutation-guard.ts +41 -7
  88. package/src/task/executor.ts +154 -44
  89. package/src/task/provider-retry-status.ts +88 -0
  90. package/src/task/render.ts +45 -26
  91. package/src/task/types.ts +3 -0
  92. package/src/tools/ask.ts +1 -0
  93. package/src/tools/bash-allowed-prefixes.ts +45 -1
  94. package/src/tools/index.ts +3 -1
  95. package/src/tools/subagent-render.ts +74 -16
  96. package/src/tools/subagent.ts +2 -0
  97. package/vendor/insane-search/MANIFEST.json +4 -1
  98. package/vendor/insane-search/engine/bias_check.py +5 -0
  99. package/vendor/insane-search/engine/fetch_chain.py +67 -1
  100. package/vendor/insane-search/engine/transport.py +13 -2
  101. package/vendor/insane-search/engine/validators.py +4 -1
@@ -31,7 +31,7 @@ import {
31
31
  releaseDaemonTransitionLock,
32
32
  sanitizeDiagnostic,
33
33
  } from "./notification-service";
34
- import { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
34
+ import { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION, SERVING_EPOCH } from "./telegram-daemon-contract";
35
35
  import {
36
36
  type DaemonProcessReference,
37
37
  type TelegramDaemonControlDeps,
@@ -39,7 +39,7 @@ import {
39
39
  } from "./telegram-daemon-control";
40
40
  import { type TelegramSetupPreflight, withTelegramSetupLease } from "./telegram-setup";
41
41
 
42
- export { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
42
+ export { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION, SERVING_EPOCH } from "./telegram-daemon-contract";
43
43
 
44
44
  import {
45
45
  buildButtonGrid,
@@ -132,6 +132,8 @@ export interface DaemonState {
132
132
  * notification wire protocol version. Absent on pre-generation state.
133
133
  */
134
134
  generation?: number;
135
+ /** Lifecycle-serving compatibility epoch; absent pre-epoch records are epoch 1. */
136
+ servingEpoch?: number;
135
137
  stoppedAt?: number;
136
138
  }
137
139
  export interface TelegramDaemonFs {
@@ -241,6 +243,18 @@ const RELOAD_FRESHNESS_WAIT_MS = 15_000;
241
243
  const RELOAD_CONTROLLER_GRACEFUL_MS = 8_000;
242
244
  const RELOAD_CONTROLLER_KILL_MS = 3_000;
243
245
  const RELOAD_RESERVATION_HEADROOM_MS = 10_000;
246
+ const ROOTS_REGISTRATION_LOCK_RETRY_DELAY_MS = 100;
247
+ const ROOTS_REGISTRATION_LOCK_HEADROOM_MS = 5_000;
248
+ const ROOTS_REGISTRATION_LOCK_RETRIES =
249
+ Math.ceil(
250
+ (RELOAD_CONTROLLER_GRACEFUL_MS + RELOAD_CONTROLLER_KILL_MS + ROOTS_REGISTRATION_LOCK_HEADROOM_MS) /
251
+ ROOTS_REGISTRATION_LOCK_RETRY_DELAY_MS,
252
+ ) + 1;
253
+ const ROOTS_REGISTRATION_LOCK_OPTIONS = {
254
+ staleMs: 10_000,
255
+ retries: ROOTS_REGISTRATION_LOCK_RETRIES,
256
+ retryDelayMs: ROOTS_REGISTRATION_LOCK_RETRY_DELAY_MS,
257
+ };
244
258
 
245
259
  /**
246
260
  * File-lock options whose acquisition budget covers the full reload-reservation
@@ -656,6 +670,162 @@ function ownershipLockMatchesState(lock: OwnershipLockRead, state: DaemonState |
656
670
  );
657
671
  }
658
672
 
673
+ type OwnerHeartbeatSidecar = {
674
+ pid: number;
675
+ incarnation: string;
676
+ ownerId: string;
677
+ acquisitionId: string;
678
+ heartbeatAt: number;
679
+ };
680
+
681
+ function ownerTagFromState(
682
+ state: DaemonState | undefined,
683
+ ): Pick<OwnerHeartbeatSidecar, "pid" | "incarnation" | "ownerId" | "acquisitionId"> | undefined {
684
+ if (
685
+ !state ||
686
+ !hasSafeDaemonStateShape(state) ||
687
+ state.stoppedAt !== undefined ||
688
+ !state.ownerId ||
689
+ !state.acquisitionId
690
+ )
691
+ return undefined;
692
+ return {
693
+ pid: state.pid,
694
+ incarnation: state.incarnation,
695
+ ownerId: state.ownerId,
696
+ acquisitionId: state.acquisitionId,
697
+ };
698
+ }
699
+
700
+ function sidecarMatchesOwnerTag(
701
+ sidecar: OwnerHeartbeatSidecar | undefined,
702
+ tag: Pick<OwnerHeartbeatSidecar, "pid" | "incarnation" | "ownerId" | "acquisitionId"> | undefined,
703
+ ): sidecar is OwnerHeartbeatSidecar {
704
+ return Boolean(
705
+ sidecar &&
706
+ tag &&
707
+ validDaemonPid(sidecar.pid) &&
708
+ isProcessIncarnation(sidecar.incarnation) &&
709
+ typeof sidecar.ownerId === "string" &&
710
+ typeof sidecar.acquisitionId === "string" &&
711
+ Number.isSafeInteger(sidecar.heartbeatAt) &&
712
+ sidecar.pid === tag.pid &&
713
+ sidecar.incarnation === tag.incarnation &&
714
+ sidecar.ownerId === tag.ownerId &&
715
+ sidecar.acquisitionId === tag.acquisitionId,
716
+ );
717
+ }
718
+
719
+ /**
720
+ * A corrupt, truncated, or otherwise unreadable sidecar must never break a
721
+ * freshness read: the sidecar is advisory-only, so any read/parse failure
722
+ * degrades to "no sidecar" (state-floor freshness), never a throw.
723
+ */
724
+ async function readSidecarLenient(fsImpl: TelegramDaemonFs, file: string): Promise<OwnerHeartbeatSidecar | undefined> {
725
+ try {
726
+ const parsed: unknown = JSON.parse(await fsImpl.readFile(file, "utf8"));
727
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
728
+ ? (parsed as OwnerHeartbeatSidecar)
729
+ : undefined;
730
+ } catch {
731
+ return undefined;
732
+ }
733
+ }
734
+
735
+ export interface OwnerFreshnessSnapshot {
736
+ ownerTag: Pick<OwnerHeartbeatSidecar, "pid" | "incarnation" | "ownerId" | "acquisitionId"> | null;
737
+ effectiveHeartbeatAt: number | undefined;
738
+ legacyEmbedded: boolean;
739
+ state: DaemonState | undefined;
740
+ }
741
+
742
+ export async function readOwnerFreshnessSnapshot(input: {
743
+ settings: Settings;
744
+ fs?: TelegramDaemonFs;
745
+ }): Promise<OwnerFreshnessSnapshot> {
746
+ const fsImpl = input.fs ?? nodeFs;
747
+ const paths = daemonPaths(input.settings.getAgentDir());
748
+ const state = await readJson<DaemonState>(fsImpl, paths.state);
749
+ const legacyEmbedded = Boolean(
750
+ state && (isGenerationAbsentParentDaemonState(state) || isGeneration3ReleaseDaemonState(state)),
751
+ );
752
+ const tag = ownerTagFromState(state);
753
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
754
+ const ownerTag = tag && ownershipLockMatchesState(lock, state) ? tag : null;
755
+ if (legacyEmbedded) return { ownerTag, effectiveHeartbeatAt: state?.heartbeatAt, legacyEmbedded, state };
756
+ const sidecar = await readSidecarLenient(fsImpl, paths.heartbeat);
757
+ const rereadState = await readJson<DaemonState>(fsImpl, paths.state);
758
+ const rereadLock = await readOwnershipLock(fsImpl, paths.lock);
759
+ const rereadTag = ownerTagFromState(rereadState);
760
+ const stableTag = rereadTag && ownershipLockMatchesState(rereadLock, rereadState) ? rereadTag : null;
761
+ const stable =
762
+ ownerTag && stableTag && sidecarMatchesOwnerTag({ ...ownerTag, heartbeatAt: 0 }, stableTag) ? stableTag : null;
763
+ const effectiveHeartbeatAt = stable
764
+ ? sidecarMatchesOwnerTag(sidecar, stable)
765
+ ? Math.max(rereadState?.heartbeatAt ?? 0, sidecar.heartbeatAt)
766
+ : rereadState?.heartbeatAt
767
+ : undefined;
768
+ return {
769
+ ownerTag: stable,
770
+ effectiveHeartbeatAt,
771
+ legacyEmbedded: false,
772
+ state: rereadState,
773
+ };
774
+ }
775
+
776
+ /** Marker-free steady heartbeat renewal. The final lock reread fences a stale writer. */
777
+ export async function renewOwnerHeartbeatSidecar(input: {
778
+ settings: Settings;
779
+ ownerId: string;
780
+ acquisitionId?: string;
781
+ fs?: TelegramDaemonFs;
782
+ now?: () => number;
783
+ pid?: number;
784
+ pidIncarnation?: (pid: number) => string | undefined;
785
+ }): Promise<boolean> {
786
+ const fsImpl = input.fs ?? nodeFs;
787
+ const paths = daemonPaths(input.settings.getAgentDir());
788
+ const state = await readJson<DaemonState>(fsImpl, paths.state);
789
+ const pid = input.pid ?? state?.pid;
790
+ const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(pid ?? 0);
791
+ const acquisitionId = input.acquisitionId ?? input.ownerId;
792
+ if (
793
+ !state ||
794
+ !validDaemonPid(pid) ||
795
+ !isProcessIncarnation(incarnation) ||
796
+ state.pid !== pid ||
797
+ state.incarnation !== incarnation ||
798
+ state.ownerId !== input.ownerId ||
799
+ state.acquisitionId !== acquisitionId ||
800
+ state.stoppedAt !== undefined
801
+ )
802
+ return false;
803
+ const sidecar: OwnerHeartbeatSidecar = {
804
+ pid,
805
+ incarnation,
806
+ ownerId: input.ownerId,
807
+ acquisitionId,
808
+ heartbeatAt: (input.now ?? Date.now)(),
809
+ };
810
+ const tmp = `${paths.heartbeat}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
811
+ await fsImpl.writeFile(tmp, `${JSON.stringify(sidecar, null, 2)}\n`, { mode: 0o600 });
812
+ await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
813
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
814
+ if (!ownershipLockMatchesState(lock, state)) {
815
+ await fsImpl.unlink(tmp).catch(() => undefined);
816
+ return false;
817
+ }
818
+ // Revalidate the exact lock after the temporary sidecar exists so a stale
819
+ // writer cannot publish after ownership moved during its write.
820
+ const finalLock = await readOwnershipLock(fsImpl, paths.lock);
821
+ if (!ownershipLockMatchesState(finalLock, state)) {
822
+ await fsImpl.unlink(tmp).catch(() => undefined);
823
+ return false;
824
+ }
825
+ await fsImpl.rename(tmp, paths.heartbeat);
826
+ return true;
827
+ }
828
+
659
829
  function ownershipLockMatchesStoppedState(
660
830
  lock: OwnershipLockRead,
661
831
  state: unknown,
@@ -800,23 +970,29 @@ async function ownershipLockIsReclaimable(input: {
800
970
  );
801
971
  }
802
972
 
803
- interface NotificationRootRegistration {
973
+ export interface NotificationRootRegistration {
804
974
  root?: string;
805
975
  managed?: boolean;
976
+ token?: string;
806
977
  }
807
978
 
808
- async function readNotificationRootRegistration(input: {
979
+ export async function readNotificationRootRegistration(input: {
809
980
  settings: Settings;
810
981
  sessionId: string;
811
982
  fs?: TelegramDaemonFs;
812
983
  }): Promise<NotificationRootRegistration> {
813
984
  const fsImpl = input.fs ?? nodeFs;
814
- const current = await readJson<{ sessions?: Record<string, string>; managedRoots?: string[] }>(
815
- fsImpl,
816
- daemonPaths(input.settings.getAgentDir()).roots,
817
- );
985
+ const current = await readJson<{
986
+ sessions?: Record<string, string>;
987
+ managedRoots?: string[];
988
+ registrationTokens?: Record<string, string>;
989
+ }>(fsImpl, daemonPaths(input.settings.getAgentDir()).roots);
818
990
  const root = current?.sessions?.[input.sessionId];
819
- return { root, managed: root !== undefined && current?.managedRoots?.includes(root) === true };
991
+ return {
992
+ root,
993
+ managed: root !== undefined && current?.managedRoots?.includes(root) === true,
994
+ token: current?.registrationTokens?.[input.sessionId],
995
+ };
820
996
  }
821
997
 
822
998
  /** Restore a session root only if this ensure operation still owns its registration. */
@@ -824,6 +1000,8 @@ async function restoreNotificationRootRegistration(input: {
824
1000
  settings: Settings;
825
1001
  sessionId: string;
826
1002
  registeredRoot: string;
1003
+ /** Token minted by this operation's own registration; a newer replacement registration's token fences the rollback. */
1004
+ registeredToken?: string;
827
1005
  previous: NotificationRootRegistration;
828
1006
  fs?: TelegramDaemonFs;
829
1007
  }): Promise<void> {
@@ -834,14 +1012,24 @@ async function restoreNotificationRootRegistration(input: {
834
1012
  paths.roots,
835
1013
  async () => {
836
1014
  const current =
837
- (await readJson<{ roots?: string[]; managedRoots?: string[]; sessions?: Record<string, string> }>(
838
- fsImpl,
839
- paths.roots,
840
- )) ?? {};
1015
+ (await readJson<{
1016
+ roots?: string[];
1017
+ managedRoots?: string[];
1018
+ sessions?: Record<string, string>;
1019
+ registrationTokens?: Record<string, string>;
1020
+ }>(fsImpl, paths.roots)) ?? {};
841
1021
  const sessions = { ...(current.sessions ?? {}) };
842
1022
  if (sessions[input.sessionId] !== input.registeredRoot) return;
1023
+ if (
1024
+ current.registrationTokens?.[input.sessionId] !== undefined &&
1025
+ current.registrationTokens[input.sessionId] !== input.registeredToken
1026
+ )
1027
+ return;
1028
+ const registrationTokens = { ...(current.registrationTokens ?? {}) };
843
1029
  if (input.previous.root) sessions[input.sessionId] = input.previous.root;
844
1030
  else delete sessions[input.sessionId];
1031
+ if (input.previous.token !== undefined) registrationTokens[input.sessionId] = input.previous.token;
1032
+ else delete registrationTokens[input.sessionId];
845
1033
  const referencedRoots = new Set(Object.values(sessions));
846
1034
  const roots = new Set(current.roots ?? []);
847
1035
  const managedRoots = new Set(current.managedRoots ?? []);
@@ -859,33 +1047,44 @@ async function restoreNotificationRootRegistration(input: {
859
1047
  roots: Array.from(roots).sort(),
860
1048
  managedRoots: Array.from(managedRoots).sort(),
861
1049
  sessions,
1050
+ registrationTokens,
862
1051
  });
863
1052
  },
864
1053
  { staleMs: 10_000 },
865
1054
  );
866
1055
  }
867
1056
 
1057
+ export interface RegisterNotificationRootResult {
1058
+ root: string;
1059
+ /** Per-registration ownership token fencing a stale same-session cleanup from deleting a live replacement registration. */
1060
+ token: string;
1061
+ }
1062
+
868
1063
  export async function registerNotificationRoot(input: {
869
1064
  settings: Settings;
870
1065
  cwd: string;
871
1066
  sessionId: string;
872
1067
  fs?: TelegramDaemonFs;
873
- }): Promise<string> {
1068
+ }): Promise<RegisterNotificationRootResult> {
874
1069
  const fsImpl = input.fs ?? nodeFs;
875
1070
  const paths = daemonPaths(input.settings.getAgentDir());
876
1071
  await ensureDir(fsImpl, paths.dir);
877
1072
  const root = notificationRootForCwd(input.cwd);
1073
+ const token = crypto.randomUUID();
878
1074
  await withFileLock(
879
1075
  paths.roots,
880
1076
  async () => {
881
1077
  const current =
882
- (await readJson<{ roots?: string[]; managedRoots?: string[]; sessions?: Record<string, string> }>(
883
- fsImpl,
884
- paths.roots,
885
- )) ?? {};
1078
+ (await readJson<{
1079
+ roots?: string[];
1080
+ managedRoots?: string[];
1081
+ sessions?: Record<string, string>;
1082
+ registrationTokens?: Record<string, string>;
1083
+ }>(fsImpl, paths.roots)) ?? {};
886
1084
  const roots = new Set(current.roots ?? []);
887
1085
  const managedRoots = new Set(current.managedRoots ?? []);
888
1086
  const sessions = { ...(current.sessions ?? {}) };
1087
+ const registrationTokens = { ...(current.registrationTokens ?? {}) };
889
1088
  const previousRoot = sessions[input.sessionId];
890
1089
  const rootAlreadyPresent = roots.has(root);
891
1090
  roots.add(root);
@@ -893,6 +1092,7 @@ export async function registerNotificationRoot(input: {
893
1092
  // survive a later session rollback or unregister.
894
1093
  if (!rootAlreadyPresent) managedRoots.add(root);
895
1094
  sessions[input.sessionId] = root;
1095
+ registrationTokens[input.sessionId] = token;
896
1096
  if (previousRoot && previousRoot !== root && managedRoots.has(previousRoot)) {
897
1097
  const previousStillReferenced = Object.values(sessions).includes(previousRoot);
898
1098
  if (!previousStillReferenced) {
@@ -905,16 +1105,19 @@ export async function registerNotificationRoot(input: {
905
1105
  roots: Array.from(roots).sort(),
906
1106
  managedRoots: Array.from(managedRoots).sort(),
907
1107
  sessions,
1108
+ registrationTokens,
908
1109
  });
909
1110
  },
910
- { staleMs: 10_000 },
1111
+ ROOTS_REGISTRATION_LOCK_OPTIONS,
911
1112
  );
912
- return root;
1113
+ return { root, token };
913
1114
  }
914
1115
 
915
1116
  export interface UnregisterNotificationRootResult {
916
1117
  root: string;
917
1118
  remainingRoots: number;
1119
+ /** Opaque fingerprint of the exact registry written by this successful unregister. */
1120
+ registryFingerprint?: string;
918
1121
  }
919
1122
 
920
1123
  /** Remove one session's Telegram scan root without disturbing other live session roots. */
@@ -923,20 +1126,19 @@ export async function unregisterNotificationRoot(input: {
923
1126
  cwd: string;
924
1127
  sessionId: string;
925
1128
  fs?: TelegramDaemonFs;
1129
+ /** Ownership token minted by this runtime's own registration; a stale cleanup presenting an older token never deletes a replacement registration. */
1130
+ registrationToken?: string;
926
1131
  }): Promise<UnregisterNotificationRootResult> {
927
1132
  const fsImpl = input.fs ?? nodeFs;
928
1133
  const paths = daemonPaths(input.settings.getAgentDir());
929
1134
  const root = notificationRootForCwd(input.cwd);
930
1135
  await ensureDir(fsImpl, paths.dir);
931
1136
  let remainingRoots = 0;
1137
+ let registryFingerprint: string | undefined;
932
1138
  await withFileLock(
933
1139
  paths.roots,
934
1140
  async () => {
935
- const current = await readJson<{
936
- roots?: string[];
937
- managedRoots?: string[];
938
- sessions?: Record<string, string>;
939
- }>(fsImpl, paths.roots);
1141
+ const current = await readJson<NotificationRootsRegistry>(fsImpl, paths.roots);
940
1142
  if (!current) return;
941
1143
  const sessions = { ...(current.sessions ?? {}) };
942
1144
  // A stale cleanup from a previous registration must not remove a newer
@@ -945,7 +1147,19 @@ export async function unregisterNotificationRoot(input: {
945
1147
  remainingRoots = (current.roots ?? []).length;
946
1148
  return;
947
1149
  }
1150
+ // Same sessionId + same cwd re-registration records an identical root, so
1151
+ // the root match alone cannot distinguish a replaced registration. Any
1152
+ // token-bearing registration requires the caller to present that exact token;
1153
+ // only genuinely legacy token-less rows retain root-match cleanup behavior.
1154
+ const recordedToken = current.registrationTokens?.[input.sessionId];
1155
+ if (recordedToken !== undefined && recordedToken !== input.registrationToken) {
1156
+ logger.warn("notifications: fenced stale Telegram root unregister against a live replacement registration");
1157
+ remainingRoots = (current.roots ?? []).length;
1158
+ return;
1159
+ }
948
1160
  delete sessions[input.sessionId];
1161
+ const registrationTokens = { ...(current.registrationTokens ?? {}) };
1162
+ delete registrationTokens[input.sessionId];
949
1163
  const rootStillReferenced = Object.values(sessions).includes(root);
950
1164
  const managedRoots = new Set(current.managedRoots ?? []);
951
1165
  const roots = (current.roots ?? []).filter(
@@ -953,16 +1167,46 @@ export async function unregisterNotificationRoot(input: {
953
1167
  );
954
1168
  if (!rootStillReferenced) managedRoots.delete(root);
955
1169
  remainingRoots = roots.length;
956
- await writeJsonAtomic(fsImpl, paths.roots, {
1170
+ const nextRegistry: NotificationRootsRegistry = {
957
1171
  version: 1,
958
1172
  roots: Array.from(new Set(roots)).sort(),
959
1173
  managedRoots: Array.from(managedRoots).sort(),
960
1174
  sessions,
961
- });
1175
+ registrationTokens,
1176
+ };
1177
+ await writeJsonAtomic(fsImpl, paths.roots, nextRegistry);
1178
+ registryFingerprint = notificationRootRegistryFingerprint(nextRegistry);
1179
+ },
1180
+ { staleMs: 10_000 },
1181
+ );
1182
+ return registryFingerprint === undefined ? { root, remainingRoots } : { root, remainingRoots, registryFingerprint };
1183
+ }
1184
+
1185
+ /** Run an action while the roots registry lock excludes any registration or unregister. */
1186
+ export async function withNotificationRootRegistryFence(input: {
1187
+ settings: Settings;
1188
+ registryFingerprint: string;
1189
+ action: () => Promise<void>;
1190
+ fs?: TelegramDaemonFs;
1191
+ }): Promise<boolean> {
1192
+ const fsImpl = input.fs ?? nodeFs;
1193
+ const paths = daemonPaths(input.settings.getAgentDir());
1194
+ await ensureDir(fsImpl, paths.dir);
1195
+ return await withFileLock(
1196
+ paths.roots,
1197
+ async () => {
1198
+ const current = await readJson<NotificationRootsRegistry>(fsImpl, paths.roots);
1199
+ if (
1200
+ !current ||
1201
+ (current.roots ?? []).length !== 0 ||
1202
+ notificationRootRegistryFingerprint(current) !== input.registryFingerprint
1203
+ )
1204
+ return false;
1205
+ await input.action();
1206
+ return true;
962
1207
  },
963
1208
  { staleMs: 10_000 },
964
1209
  );
965
- return { root, remainingRoots };
966
1210
  }
967
1211
 
968
1212
  function notificationRootForCwd(cwd: string): string {
@@ -998,8 +1242,32 @@ type NotificationRootsRegistry = {
998
1242
  roots?: string[];
999
1243
  managedRoots?: string[];
1000
1244
  sessions?: Record<string, string>;
1245
+ registrationTokens?: Record<string, string>;
1001
1246
  };
1002
1247
 
1248
+ function notificationRootRegistryFingerprint(registry: NotificationRootsRegistry): string {
1249
+ return crypto
1250
+ .createHash("sha256")
1251
+ .update(
1252
+ JSON.stringify({
1253
+ version: registry.version,
1254
+ roots: Array.from(new Set(registry.roots ?? [])).sort(),
1255
+ managedRoots: Array.from(new Set(registry.managedRoots ?? [])).sort(),
1256
+ sessions: Object.fromEntries(
1257
+ Object.entries(registry.sessions ?? {}).sort(([left], [right]) =>
1258
+ left < right ? -1 : left > right ? 1 : 0,
1259
+ ),
1260
+ ),
1261
+ registrationTokens: Object.fromEntries(
1262
+ Object.entries(registry.registrationTokens ?? {}).sort(([left], [right]) =>
1263
+ left < right ? -1 : left > right ? 1 : 0,
1264
+ ),
1265
+ ),
1266
+ }),
1267
+ )
1268
+ .digest("hex");
1269
+ }
1270
+
1003
1271
  /**
1004
1272
  * Drop permanently missing scan roots from the durable registry under the roots
1005
1273
  * lock. Session map entries that pointed only at those roots are removed too.
@@ -1022,6 +1290,7 @@ export async function pruneMissingNotificationRoots(input: {
1022
1290
  const roots = [...(current.roots ?? [])];
1023
1291
  const managedRoots = new Set(current.managedRoots ?? []);
1024
1292
  const sessions = { ...(current.sessions ?? {}) };
1293
+ const registrationTokens = { ...(current.registrationTokens ?? {}) };
1025
1294
  const candidateSet = input.candidates ? new Set(input.candidates) : undefined;
1026
1295
  const survivors: string[] = [];
1027
1296
  for (const root of roots) {
@@ -1052,7 +1321,10 @@ export async function pruneMissingNotificationRoots(input: {
1052
1321
  pruned.push(root);
1053
1322
  managedRoots.delete(root);
1054
1323
  for (const [sessionId, mapped] of Object.entries(sessions)) {
1055
- if (mapped === root) delete sessions[sessionId];
1324
+ if (mapped === root) {
1325
+ delete sessions[sessionId];
1326
+ delete registrationTokens[sessionId];
1327
+ }
1056
1328
  }
1057
1329
  }
1058
1330
  }
@@ -1063,6 +1335,7 @@ export async function pruneMissingNotificationRoots(input: {
1063
1335
  roots: Array.from(new Set(survivors)).sort(),
1064
1336
  managedRoots: Array.from(managedRoots).sort(),
1065
1337
  sessions,
1338
+ registrationTokens,
1066
1339
  });
1067
1340
  },
1068
1341
  { staleMs: 10_000 },
@@ -1220,7 +1493,9 @@ export function hasSafeDaemonStateShape(state: unknown): state is DaemonState {
1220
1493
  candidate.version === DAEMON_VERSION &&
1221
1494
  (candidate.generation === undefined ||
1222
1495
  (Number.isSafeInteger(candidate.generation) && (candidate.generation as number) > 0)) &&
1223
- (candidate.stoppedAt === undefined || Number.isSafeInteger(candidate.stoppedAt)),
1496
+ (candidate.stoppedAt === undefined || Number.isSafeInteger(candidate.stoppedAt)) &&
1497
+ (candidate.servingEpoch === undefined ||
1498
+ (Number.isSafeInteger(candidate.servingEpoch) && (candidate.servingEpoch as number) > 0)),
1224
1499
  );
1225
1500
  }
1226
1501
 
@@ -1421,6 +1696,7 @@ async function legacyParentHandoffDecision(input: {
1421
1696
  > {
1422
1697
  const { state } = input;
1423
1698
  if (!input.pidAlive(state.pid)) return undefined;
1699
+ if (state.generation !== 3) return { acquired: false, attached: false, blocked: true };
1424
1700
  if (!ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
1425
1701
  return { acquired: false, attached: false, blocked: true };
1426
1702
  const incarnation = input.pidIncarnation(state.pid);
@@ -1522,12 +1798,7 @@ function isRecognizedLegacyGeneration(generation: number | undefined): boolean {
1522
1798
  return generation === undefined || generation === 3;
1523
1799
  }
1524
1800
 
1525
- /**
1526
- * A predecessor is reloadable only when it has the complete modern ownership
1527
- * proof. Generation is absent from the first fully-provenanced modern records,
1528
- * so it is an incompatible predecessor too; parent-format records remain
1529
- * excluded because they lack this ownership proof.
1530
- */
1801
+ /** True when a modern owner carries complete ready-state provenance. */
1531
1802
  function hasFullModernOwnerProvenance(
1532
1803
  state: DaemonState | undefined,
1533
1804
  pidIncarnation?: (pid: number) => string | undefined,
@@ -1542,19 +1813,6 @@ function hasFullModernOwnerProvenance(
1542
1813
  );
1543
1814
  }
1544
1815
 
1545
- function isFullModernPredecessor(
1546
- state: DaemonState | undefined,
1547
- pidIncarnation?: (pid: number) => string | undefined,
1548
- ): boolean {
1549
- return Boolean(
1550
- hasFullModernOwnerProvenance(state, pidIncarnation) &&
1551
- (state?.generation === undefined ||
1552
- (Number.isSafeInteger(state?.generation) &&
1553
- (state?.generation as number) > 0 &&
1554
- (state?.generation as number) < DAEMON_GENERATION)),
1555
- );
1556
- }
1557
-
1558
1816
  /**
1559
1817
  * Classifies a physically live daemon record before any replacement action.
1560
1818
  *
@@ -1628,14 +1886,17 @@ export function isFreshLiveOwner(input: {
1628
1886
  chatId: string;
1629
1887
  pidAlive: (pid: number) => boolean;
1630
1888
  pidIncarnation?: (pid: number) => string | undefined;
1889
+ effectiveHeartbeatAt?: number;
1631
1890
  }): boolean {
1632
1891
  const { state } = input;
1892
+ const heartbeatAt = Object.hasOwn(input, "effectiveHeartbeatAt") ? input.effectiveHeartbeatAt : state?.heartbeatAt;
1633
1893
  return Boolean(
1634
1894
  state &&
1895
+ heartbeatAt !== undefined &&
1635
1896
  hasSafeDaemonStateShape(state) &&
1636
1897
  state.stoppedAt === undefined &&
1637
1898
  ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
1638
- input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
1899
+ input.now - heartbeatAt <= HEARTBEAT_TTL_MS &&
1639
1900
  input.pidAlive(state.pid) &&
1640
1901
  ownerProvenanceMatches(state, input.pidIncarnation),
1641
1902
  );
@@ -1649,6 +1910,7 @@ export function isCurrentCompatibleOwner(input: {
1649
1910
  chatId: string;
1650
1911
  pidAlive: (pid: number) => boolean;
1651
1912
  pidIncarnation?: (pid: number) => string | undefined;
1913
+ effectiveHeartbeatAt?: number;
1652
1914
  }): boolean {
1653
1915
  const state = input.state;
1654
1916
  return Boolean(
@@ -1656,8 +1918,7 @@ export function isCurrentCompatibleOwner(input: {
1656
1918
  state?.ownershipPhase === "ready" &&
1657
1919
  typeof state.acquisitionId === "string" &&
1658
1920
  state.acquisitionId.length > 0 &&
1659
- Number.isSafeInteger(state.generation) &&
1660
- (state.generation as number) >= DAEMON_GENERATION,
1921
+ (state.servingEpoch === undefined ? 1 : state.servingEpoch) >= SERVING_EPOCH,
1661
1922
  );
1662
1923
  }
1663
1924
 
@@ -1701,14 +1962,14 @@ export async function acquireDaemonOwnership(input: {
1701
1962
  input.ownerId ?? input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1702
1963
  const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
1703
1964
 
1704
- // A fresh, identity-matching live owner running an OLDER generation than this
1705
- // build cannot serve our newer wire frames; signal a reload instead of a
1706
- // silent attach. Newer/equal generations attach as before (no downgrade).
1965
+ // Generation is inventory-only: a lower servingEpoch triggers convergence,
1966
+ // while same-epoch cross-generation owners attach.
1707
1967
  const attachDecision = (
1708
- state: DaemonState | undefined,
1968
+ snapshot: OwnerFreshnessSnapshot,
1709
1969
  ):
1710
1970
  | { acquired: false; attached: boolean; blocked?: boolean; provisional?: boolean; reloadRequired?: boolean }
1711
1971
  | undefined => {
1972
+ const state = snapshot.state;
1712
1973
  if (state && !hasSafeDaemonStateShape(state)) {
1713
1974
  const malformed = state as Partial<DaemonState>;
1714
1975
  if (
@@ -1741,6 +2002,7 @@ export async function acquireDaemonOwnership(input: {
1741
2002
  chatId: input.chatId,
1742
2003
  pidAlive,
1743
2004
  pidIncarnation,
2005
+ effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
1744
2006
  })
1745
2007
  )
1746
2008
  return { acquired: false, attached: true };
@@ -1755,8 +2017,17 @@ export async function acquireDaemonOwnership(input: {
1755
2017
  chatId: input.chatId,
1756
2018
  pidAlive,
1757
2019
  pidIncarnation,
2020
+ effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
1758
2021
  }) &&
1759
- (state.version !== DAEMON_VERSION || isFullModernPredecessor(state, pidIncarnation))
2022
+ (state.version !== DAEMON_VERSION ||
2023
+ ((state.servingEpoch === undefined ? 1 : state.servingEpoch) < SERVING_EPOCH &&
2024
+ isSignalableMatchingOwner({
2025
+ state,
2026
+ tokenFingerprint: input.tokenFingerprint,
2027
+ chatId: input.chatId,
2028
+ pidAlive,
2029
+ pidIncarnation,
2030
+ })))
1760
2031
  )
1761
2032
  return { acquired: false, attached: false, reloadRequired: true };
1762
2033
  return { acquired: false, attached: false, provisional: true };
@@ -1800,7 +2071,8 @@ export async function acquireDaemonOwnership(input: {
1800
2071
  const transition = await acquireTransitionLock({ fs: fsImpl, path: paths.steal, pidAlive, pidIncarnation });
1801
2072
  if (!transition) return { acquired: false, attached: false, provisional: true };
1802
2073
  try {
1803
- const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
2074
+ const recheckedSnapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: fsImpl });
2075
+ const rechecked = recheckedSnapshot.state;
1804
2076
  const recheckedLock = await readOwnershipLock(fsImpl, paths.lock);
1805
2077
  const recheckedForeignOwner = classifyForeignLiveOwner({
1806
2078
  state: rechecked,
@@ -1826,7 +2098,7 @@ export async function acquireDaemonOwnership(input: {
1826
2098
  })
1827
2099
  )
1828
2100
  return { acquired: false, attached: false, blocked: true };
1829
- const recheckedDecision = isLegacyParentDaemonState(rechecked) ? undefined : attachDecision(rechecked);
2101
+ const recheckedDecision = isLegacyParentDaemonState(rechecked) ? undefined : attachDecision(recheckedSnapshot);
1830
2102
  if (
1831
2103
  recheckedDecision &&
1832
2104
  (recheckedDecision.attached ||
@@ -1923,6 +2195,7 @@ export async function acquireDaemonOwnership(input: {
1923
2195
  roots,
1924
2196
  version: DAEMON_VERSION,
1925
2197
  generation: DAEMON_GENERATION,
2198
+ servingEpoch: SERVING_EPOCH,
1926
2199
  } satisfies DaemonState);
1927
2200
  if (
1928
2201
  !(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })) ||
@@ -2022,17 +2295,20 @@ export async function renewDaemonHeartbeat(input: {
2022
2295
  })))
2023
2296
  )
2024
2297
  return false;
2298
+ const heartbeatAt = (input.now ?? Date.now)();
2299
+ const boundState: DaemonState = {
2300
+ ...state,
2301
+ // Preserve the source launcher PID so concurrent ensures can recognize
2302
+ // this bound child as its successor while it is still provisional.
2303
+ launcherPid: state.pid,
2304
+ pid,
2305
+ incarnation,
2306
+ ownershipPhase: "provisional",
2307
+ heartbeatAt,
2308
+ servingEpoch: SERVING_EPOCH,
2309
+ };
2025
2310
  try {
2026
- await writeJsonAtomic(fsImpl, paths.state, {
2027
- ...state,
2028
- // Preserve the source launcher PID so concurrent ensures can recognize
2029
- // this ready PID as its child rather than excluding it as the launcher.
2030
- launcherPid: state.pid,
2031
- pid,
2032
- incarnation,
2033
- ownershipPhase: "ready",
2034
- heartbeatAt: (input.now ?? Date.now)(),
2035
- });
2311
+ await writeJsonAtomic(fsImpl, paths.state, boundState);
2036
2312
  } catch {
2037
2313
  if (expectedLock && reboundLock)
2038
2314
  await rollbackOwnershipLockRebind({
@@ -2046,7 +2322,49 @@ export async function renewDaemonHeartbeat(input: {
2046
2322
  });
2047
2323
  return false;
2048
2324
  }
2049
- return true;
2325
+ const retireBoundOwnership = async (): Promise<void> => {
2326
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
2327
+ if (!ownershipLockMatchesState(lock, boundState)) return;
2328
+ await writeJsonAtomic(fsImpl, paths.state, {
2329
+ ...boundState,
2330
+ ownershipPhase: "retired",
2331
+ stoppedAt: (input.now ?? Date.now)(),
2332
+ }).catch(() => undefined);
2333
+ if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
2334
+ await unlinkOwnershipLockExactly(fsImpl, paths.lock, lock);
2335
+ };
2336
+ // The durable sidecar must exist and match the rebound ownership lock before
2337
+ // any observer can see ownershipPhase:"ready". A provisional state may be
2338
+ // fresh, but readiness waiters categorically refuse to attach to it.
2339
+ let sidecarRenewed = false;
2340
+ try {
2341
+ sidecarRenewed = await renewOwnerHeartbeatSidecar({
2342
+ settings: input.settings,
2343
+ ownerId: input.ownerId,
2344
+ acquisitionId,
2345
+ fs: fsImpl,
2346
+ now: () => heartbeatAt,
2347
+ pid,
2348
+ pidIncarnation: input.pidIncarnation,
2349
+ });
2350
+ } catch {
2351
+ sidecarRenewed = false;
2352
+ }
2353
+ if (!sidecarRenewed) {
2354
+ logger.warn(
2355
+ "notifications: Telegram daemon initial heartbeat sidecar proof failed; retiring provisional ownership",
2356
+ );
2357
+ await retireBoundOwnership();
2358
+ return false;
2359
+ }
2360
+ try {
2361
+ await writeJsonAtomic(fsImpl, paths.state, { ...boundState, ownershipPhase: "ready" });
2362
+ return true;
2363
+ } catch {
2364
+ logger.warn("notifications: Telegram daemon ready publication failed after sidecar proof; retiring ownership");
2365
+ await retireBoundOwnership();
2366
+ return false;
2367
+ }
2050
2368
  } finally {
2051
2369
  await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
2052
2370
  }
@@ -2225,7 +2543,7 @@ async function bindProvisionalDaemonPid(input: {
2225
2543
  }
2226
2544
  }
2227
2545
 
2228
- /** Wait for a matching current-generation daemon to publish a ready state. */
2546
+ /** Wait for a matching compatible owner to publish a ready state. Readiness is serving-epoch gated (same-epoch cross-generation owners attach, per isCurrentCompatibleOwner); generation is inventory-only here. Mutation paths still require exact current-generation equality. */
2229
2547
  export async function waitForTelegramDaemonReady(input: {
2230
2548
  settings: Settings;
2231
2549
  ownerId?: string;
@@ -2251,12 +2569,13 @@ export async function waitForTelegramDaemonReady(input: {
2251
2569
  const deadline = now() + timeoutMs;
2252
2570
  const maxPolls = Math.ceil(timeoutMs / waitStepMs);
2253
2571
  for (let poll = 0; poll <= maxPolls; poll++) {
2254
- const state = await readDaemonState(input.settings, input.fs);
2572
+ const snapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: input.fs });
2573
+ const state = snapshot.state;
2255
2574
  if (
2256
2575
  (!input.ownerId || state?.ownerId === input.ownerId) &&
2257
2576
  (!input.acquisitionId || state?.acquisitionId === input.acquisitionId) &&
2258
2577
  state?.ownershipPhase === "ready" &&
2259
- state.generation === DAEMON_GENERATION &&
2578
+ Number.isSafeInteger(state.generation) &&
2260
2579
  ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
2261
2580
  ownerProvenanceMatches(state, pidIncarnation) &&
2262
2581
  pidAlive(state.pid) &&
@@ -2270,6 +2589,7 @@ export async function waitForTelegramDaemonReady(input: {
2270
2589
  chatId: input.chatId,
2271
2590
  pidAlive,
2272
2591
  pidIncarnation,
2592
+ effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
2273
2593
  })
2274
2594
  )
2275
2595
  return true;
@@ -2339,7 +2659,8 @@ export async function confirmTelegramDaemonSpawn(input: {
2339
2659
  sleep: input.sleep,
2340
2660
  });
2341
2661
  if (retired) return false;
2342
- const state = await readDaemonState(input.settings, input.fs);
2662
+ const snapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: input.fs });
2663
+ const state = snapshot.state;
2343
2664
  if (
2344
2665
  hasExactChildPid &&
2345
2666
  isCurrentCompatibleOwner({
@@ -2349,6 +2670,7 @@ export async function confirmTelegramDaemonSpawn(input: {
2349
2670
  chatId: input.chatId,
2350
2671
  pidAlive: input.pidAlive ?? defaultPidAlive,
2351
2672
  pidIncarnation: input.pidIncarnation ?? defaultPidIncarnation,
2673
+ effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
2352
2674
  }) &&
2353
2675
  (state?.ownerId !== input.spawned.acquisition.ownerId ||
2354
2676
  state.acquisitionId !== input.spawned.acquisition.acquisitionId ||
@@ -2812,8 +3134,15 @@ export async function reclaimDeadDaemonOwner(input: {
2812
3134
  * Ensure a configured daemon owns this session root, preserving ownership safety
2813
3135
  * while exposing whether a #2028 generation handoff was required.
2814
3136
  */
2815
- export async function ensureTelegramDaemonRunningDetailed(
2816
- input: { settings: Settings; cwd: string; sessionId: string },
3137
+ async function ensureTelegramDaemonRunningDetailedOnce(
3138
+ input: {
3139
+ settings: Settings;
3140
+ cwd: string;
3141
+ sessionId: string;
3142
+ registerRoot?: boolean;
3143
+ /** Receives each registration this ensure operation minted so the caller can fence its own later cleanup. */
3144
+ onRegistered?: (registration: RegisterNotificationRootResult) => void;
3145
+ },
2817
3146
  deps: TelegramDaemonDeps = {},
2818
3147
  ): Promise<EnsureTelegramDaemonDetailedResult> {
2819
3148
  const cfg = getNotificationConfig(input.settings);
@@ -2919,7 +3248,12 @@ export async function ensureTelegramDaemonRunningDetailed(
2919
3248
  }
2920
3249
  if (spawned.result === "attached" && spawned.reloadRequired) {
2921
3250
  const previous = await readNotificationRootRegistration({ ...input, fs: deps.fs });
2922
- await registerNotificationRoot({ ...input, fs: deps.fs });
3251
+ let registeredToken: string | undefined;
3252
+ if (input.registerRoot !== false) {
3253
+ const registered = await registerNotificationRoot({ ...input, fs: deps.fs });
3254
+ registeredToken = registered.token;
3255
+ input.onRegistered?.(registered);
3256
+ }
2923
3257
  const fsImpl = deps.fs ?? nodeFs;
2924
3258
  const now = deps.now ?? Date.now;
2925
3259
  const pidAlive = deps.pidAlive ?? defaultPidAlive;
@@ -2958,22 +3292,28 @@ export async function ensureTelegramDaemonRunningDetailed(
2958
3292
  // Iteration-capped so a frozen `now` cannot spin forever.
2959
3293
  const maxWaits = Math.ceil(waitBudgetMs / waitStepMs);
2960
3294
  for (let waits = 0; waits <= maxWaits; waits++) {
2961
- const state = await readDaemonState(input.settings, fsImpl);
3295
+ const snapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: fsImpl });
3296
+ const state = snapshot.state;
2962
3297
  const t = now();
2963
3298
  if (
2964
- isFreshLiveOwner({
3299
+ isCurrentCompatibleOwner({
2965
3300
  state,
2966
3301
  now: t,
2967
3302
  tokenFingerprint: fp,
2968
3303
  chatId: cfg.chatId,
2969
3304
  pidAlive,
2970
3305
  pidIncarnation,
3306
+ effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
2971
3307
  })
2972
3308
  )
2973
3309
  return "attached" as const;
2974
3310
  if (t >= deadline) break;
2975
3311
  await sleep(waitStepMs);
2976
3312
  }
3313
+ // Keep the reservation after a failed reload so concurrent ensures do not
3314
+ // repeatedly signal the same incompatible predecessor. Surface the failed
3315
+ // convergence; the next ensure after cooldown expiry retries the reload.
3316
+ return "blocked_identity" as const;
2977
3317
  }
2978
3318
  await writeJsonAtomic(fsImpl, reloadAttemptPath, {
2979
3319
  lastReloadAt: reloadNow,
@@ -2991,11 +3331,23 @@ export async function ensureTelegramDaemonRunningDetailed(
2991
3331
  }),
2992
3332
  );
2993
3333
  if (reloadResult === "attached") return "attached";
3334
+ if (reloadResult === "blocked_identity") {
3335
+ await restoreNotificationRootRegistration({
3336
+ settings: input.settings,
3337
+ sessionId: input.sessionId,
3338
+ registeredRoot: root,
3339
+ registeredToken,
3340
+ previous,
3341
+ fs: deps.fs,
3342
+ });
3343
+ return "blocked_identity";
3344
+ }
2994
3345
  if (reloadResult !== "reloaded") {
2995
3346
  await restoreNotificationRootRegistration({
2996
3347
  settings: input.settings,
2997
3348
  sessionId: input.sessionId,
2998
3349
  registeredRoot: root,
3350
+ registeredToken,
2999
3351
  previous,
3000
3352
  fs: deps.fs,
3001
3353
  });
@@ -3004,7 +3356,10 @@ export async function ensureTelegramDaemonRunningDetailed(
3004
3356
  return "reloaded";
3005
3357
  }
3006
3358
  if (spawned.result !== "owner_spawned") {
3007
- await registerNotificationRoot({ ...input, fs: deps.fs });
3359
+ if (input.registerRoot !== false) {
3360
+ const registered = await registerNotificationRoot({ ...input, fs: deps.fs });
3361
+ input.onRegistered?.(registered);
3362
+ }
3008
3363
  return "attached";
3009
3364
  }
3010
3365
  if (
@@ -3023,12 +3378,54 @@ export async function ensureTelegramDaemonRunningDetailed(
3023
3378
  timeoutMs: deps.readinessTimeoutMs,
3024
3379
  })
3025
3380
  ) {
3026
- await registerNotificationRoot({ ...input, fs: deps.fs });
3381
+ if (input.registerRoot !== false) {
3382
+ const registered = await registerNotificationRoot({ ...input, fs: deps.fs });
3383
+ input.onRegistered?.(registered);
3384
+ }
3027
3385
  return "spawned";
3028
3386
  }
3029
3387
  throw new Error("Telegram daemon did not become ready after spawning");
3030
3388
  }
3031
3389
 
3390
+ /**
3391
+ * Ensure a configured daemon owns this session root, preserving ownership safety
3392
+ * while exposing whether a #2028 generation handoff was required.
3393
+ */
3394
+ export async function ensureTelegramDaemonRunningDetailed(
3395
+ input: {
3396
+ settings: Settings;
3397
+ cwd: string;
3398
+ sessionId: string;
3399
+ registerRoot?: boolean;
3400
+ /** Receives each registration this ensure operation minted so the caller can fence its own later cleanup. */
3401
+ onRegistered?: (registration: RegisterNotificationRootResult) => void;
3402
+ },
3403
+ deps: TelegramDaemonDeps = {},
3404
+ ): Promise<EnsureTelegramDaemonDetailedResult> {
3405
+ const result = await ensureTelegramDaemonRunningDetailedOnce(input, deps);
3406
+ if (input.registerRoot === false || (result !== "attached" && result !== "spawned")) return result;
3407
+
3408
+ const cfg = getNotificationConfig(input.settings);
3409
+ if (!isTelegramConfigured(cfg)) return "disabled";
3410
+ const ownerIsCurrent = async (): Promise<boolean> => {
3411
+ const snapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: deps.fs });
3412
+ return isCurrentCompatibleOwner({
3413
+ state: snapshot.state,
3414
+ now: (deps.now ?? Date.now)(),
3415
+ tokenFingerprint: tokenFingerprint(cfg.botToken),
3416
+ chatId: cfg.chatId,
3417
+ pidAlive: deps.pidAlive ?? defaultPidAlive,
3418
+ pidIncarnation: deps.pidIncarnation ?? defaultPidIncarnation,
3419
+ effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
3420
+ });
3421
+ };
3422
+ if (await ownerIsCurrent()) return result;
3423
+
3424
+ const converged = await ensureTelegramDaemonRunningDetailedOnce({ ...input, registerRoot: false }, deps);
3425
+ if (converged === "disabled" || converged === "blocked_identity") return converged;
3426
+ return (await ownerIsCurrent()) ? converged : "blocked_identity";
3427
+ }
3428
+
3032
3429
  /**
3033
3430
  * Legacy compatibility mapping for callers that only distinguish ownership from
3034
3431
  * attachment. New orchestration should use {@link ensureTelegramDaemonRunningDetailed}.
@@ -3196,7 +3593,7 @@ export type TelegramPollResult =
3196
3593
  export interface TelegramUpdatePollerOptions {
3197
3594
  botApi: BotApi;
3198
3595
  runtime: NotificationOperatorRuntime;
3199
- backoff: OperatorBackoffPolicy;
3596
+ backoff: { next(): number; reset(): void };
3200
3597
  processUpdate: (update: unknown) => Promise<TelegramUpdateOutcome>;
3201
3598
  health?: TelegramPollHealth;
3202
3599
  }
@@ -3258,6 +3655,20 @@ export class TelegramPollHealth {
3258
3655
  }
3259
3656
  }
3260
3657
 
3658
+ class JitteredPollConflictBackoff {
3659
+ #currentMs = 0;
3660
+
3661
+ next(): number {
3662
+ if (this.#currentMs === 0) this.#currentMs = POLL_BACKOFF_MS;
3663
+ else this.#currentMs = Math.min(this.#currentMs * (1.5 + Math.random() * 0.5), 10_000);
3664
+ return this.#currentMs;
3665
+ }
3666
+
3667
+ reset(): void {
3668
+ this.#currentMs = 0;
3669
+ }
3670
+ }
3671
+
3261
3672
  /** Owns getUpdates offset, conflict backoff, and per-update error isolation. */
3262
3673
  export class TelegramUpdatePoller {
3263
3674
  #offset = 0;
@@ -3518,6 +3929,35 @@ type SelectedAckOutcome =
3518
3929
  | { status: "unknown"; reason: "transport_ambiguous" | "shutdown" };
3519
3930
  type BtwQueuedDeliveryOutcome = "accepted" | "not_delivered" | "uncertain" | "stale" | "partial_accepted";
3520
3931
 
3932
+ type BotApiCallOutcome =
3933
+ | { kind: "accepted" }
3934
+ | { kind: "rejected" }
3935
+ | { kind: "retryable"; retryAfterMs: number }
3936
+ | { kind: "unknown" };
3937
+
3938
+ interface BotApiCallResult {
3939
+ response: unknown;
3940
+ outcome: BotApiCallOutcome;
3941
+ }
3942
+
3943
+ function classifyBotApiCallOutcome(response: unknown, cooldownSuppressed = false): BotApiCallOutcome {
3944
+ if (cooldownSuppressed) return { kind: "retryable", retryAfterMs: 0 };
3945
+ if (!response || typeof response !== "object") return { kind: "unknown" };
3946
+ const body = response as {
3947
+ ok?: unknown;
3948
+ error_code?: unknown;
3949
+ parameters?: { retry_after?: unknown };
3950
+ };
3951
+ if (body.ok === false && body.error_code === 429) {
3952
+ const retryAfter = body.parameters?.retry_after;
3953
+ const seconds = typeof retryAfter === "number" && Number.isFinite(retryAfter) ? retryAfter : 30;
3954
+ return { kind: "retryable", retryAfterMs: Math.min(3600, Math.max(1, seconds)) * 1_000 };
3955
+ }
3956
+ if (body.ok === true) return { kind: "accepted" };
3957
+ if (body.ok === false) return { kind: "rejected" };
3958
+ return { kind: "unknown" };
3959
+ }
3960
+
3521
3961
  interface BtwQueuedDelivery {
3522
3962
  pending: PendingBtwTurn;
3523
3963
  body: Record<string, unknown>;
@@ -3658,7 +4098,7 @@ export class TelegramNotificationDaemon {
3658
4098
 
3659
4099
  private readonly runtime: NotificationOperatorRuntime;
3660
4100
  private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
3661
- private readonly pollConflictBackoff = new OperatorBackoffPolicy({ initialMs: 500, maxMs: 5_000 });
4101
+ private readonly pollConflictBackoff = new JitteredPollConflictBackoff();
3662
4102
  private readonly loopBackoff = new OperatorBackoffPolicy({ initialMs: 250, maxMs: 4_000 });
3663
4103
  private running = false;
3664
4104
  /** Once set, a concurrent startup await can never restore a running daemon. */
@@ -3668,6 +4108,12 @@ export class TelegramNotificationDaemon {
3668
4108
  private readonly botApi: BotApi;
3669
4109
  private readonly effects = new TelegramEffectSupervisor();
3670
4110
  private readonly topics = new TopicRegistry();
4111
+ /**
4112
+ * Telegram may accept createForumTopic while returning an unusable success
4113
+ * payload. Remember that ambiguity per endpoint so later frames cannot repeat
4114
+ * the external side effect. Explicit API failures remain retryable.
4115
+ */
4116
+ readonly #malformedTopicCreateEndpoints = new Map<string, string>();
3671
4117
  /** Serializes registry snapshots so an older atomic write cannot overwrite newer rename state. */
3672
4118
  /** Legacy sockets have no durable token, so explicit teardown is their revocation fence. */
3673
4119
  private readonly droppedSessions = new WeakSet<SessionSocket>();
@@ -3692,6 +4138,9 @@ export class TelegramNotificationDaemon {
3692
4138
  private readonly pool: RateLimitPool<TelegramQueuePayload>;
3693
4139
  private readonly poller: TelegramUpdatePoller;
3694
4140
  private readonly dispatchState = new TelegramEventDispatchState();
4141
+ /** Bot-wide flood-control window; inbound polling remains eligible during it. */
4142
+ private botCooldownUntil = 0;
4143
+ private warnedBotCooldownUntil = 0;
3695
4144
  /** Original markdown of rich messages we sent (chat+message_id), for restoring reply context on inbound replies. */
3696
4145
  private readonly replyStore: ReplySentStore;
3697
4146
  /** Per-session debounce + monotonic draft-id state for opt-in draft streaming. */
@@ -4110,8 +4559,8 @@ export class TelegramNotificationDaemon {
4110
4559
 
4111
4560
  private async refreshBotIdentity(): Promise<void> {
4112
4561
  try {
4113
- const response = (await this.botApi.call("getMe", {})) as { result?: { username?: unknown } };
4114
- const username = response.result?.username;
4562
+ const response = (await this.botApi.call("getMe", {})) as { result?: { username?: unknown } } | undefined;
4563
+ const username = response?.result?.username;
4115
4564
  this.botUsername =
4116
4565
  typeof username === "string" && username.trim() ? username.trim().replace(/^@/, "") : undefined;
4117
4566
  } catch {
@@ -4124,6 +4573,47 @@ export class TelegramNotificationDaemon {
4124
4573
  return formatLifecycleOutcome(r, verb);
4125
4574
  }
4126
4575
 
4576
+ private async callBotApi(
4577
+ rawBotApi: BotApi,
4578
+ method: string,
4579
+ body: unknown,
4580
+ callOpts?: { signal?: AbortSignal; noRetry?: boolean },
4581
+ ): Promise<BotApiCallResult> {
4582
+ const now = this.opts.now?.() ?? Date.now();
4583
+ if (method !== "getUpdates" && now < this.botCooldownUntil) {
4584
+ if (this.warnedBotCooldownUntil !== this.botCooldownUntil) {
4585
+ this.warnedBotCooldownUntil = this.botCooldownUntil;
4586
+ logger.warn("notifications: Telegram Bot API flood-control cooldown suppressing outbound calls");
4587
+ }
4588
+ const outcome = classifyBotApiCallOutcome(undefined, true);
4589
+ return { response: undefined, outcome };
4590
+ }
4591
+ const response = await this.effects.call(rawBotApi, method, body, callOpts);
4592
+ const outcome = classifyBotApiCallOutcome(response);
4593
+ if (outcome.kind === "retryable")
4594
+ this.botCooldownUntil = Math.max(
4595
+ this.botCooldownUntil,
4596
+ (this.opts.now?.() ?? Date.now()) + outcome.retryAfterMs,
4597
+ );
4598
+ return { response, outcome };
4599
+ }
4600
+
4601
+ private readonly callBotApiClassified: (
4602
+ method: string,
4603
+ body: unknown,
4604
+ callOpts?: { signal?: AbortSignal; noRetry?: boolean },
4605
+ ) => Promise<BotApiCallResult>;
4606
+
4607
+ private botApiWithOutcomeCollector(outcomes: BotApiCallOutcome[]): BotApi {
4608
+ return {
4609
+ call: async (method, body, callOpts) => {
4610
+ const result = await this.callBotApiClassified(method, body, callOpts);
4611
+ outcomes.push(result.outcome);
4612
+ return result.response;
4613
+ },
4614
+ };
4615
+ }
4616
+
4127
4617
  constructor(private readonly opts: TelegramDaemonOptions) {
4128
4618
  this.fsImpl = opts.fs ?? nodeFs;
4129
4619
  this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
@@ -4136,8 +4626,9 @@ export class TelegramNotificationDaemon {
4136
4626
  fetchImpl: opts.fetchImpl,
4137
4627
  setTimeoutImpl: opts.setTimeoutImpl,
4138
4628
  });
4629
+ this.callBotApiClassified = (method, body, callOpts) => this.callBotApi(rawBotApi, method, body, callOpts);
4139
4630
  this.botApi = {
4140
- call: (method, body, callOpts) => this.effects.call(rawBotApi, method, body, callOpts),
4631
+ call: async (method, body, callOpts) => (await this.callBotApiClassified(method, body, callOpts)).response,
4141
4632
  };
4142
4633
  this.runtime = new NotificationOperatorRuntime({
4143
4634
  now: opts.now,
@@ -5844,6 +6335,9 @@ export class TelegramNotificationDaemon {
5844
6335
  if (session && sessionId === session.sessionId && this.#logicalSessionId(session) !== sessionId) return undefined;
5845
6336
  const capturedCreationLease = creationLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
5846
6337
  if (session?.logicalSessionIdTrusted && !capturedCreationLease) return undefined;
6338
+ const creationEndpointKey = session?.endpointDigest ?? session?.endpointKey ?? "unbound";
6339
+ if (this.#malformedTopicCreateEndpoints.get(sessionId) === creationEndpointKey)
6340
+ throw new Error("createForumTopic: invalid message_thread_id");
5847
6341
  const existing = this.topics.get(sessionId);
5848
6342
  if (existing?.authorityState === "delete_pending" || existing?.bindingMalformed) return undefined;
5849
6343
  if (existing) return existing.topicId;
@@ -5859,18 +6353,32 @@ export class TelegramNotificationDaemon {
5859
6353
  let acceptedTopicId: string | undefined;
5860
6354
  let acceptedTopicCompensated = false;
5861
6355
  let acceptedTopicDeleteAttempted = false;
6356
+ let creationSuppressed = false;
5862
6357
  try {
5863
6358
  const rec = await this.topics.getOrCreateTopic(
5864
6359
  sessionId,
5865
6360
  async () => {
5866
6361
  if (capturedCreationLease && !this.#leaseTokenAllows(capturedCreationLease))
5867
6362
  throw new Error("topic authority was revoked during creation");
5868
- const res = await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name });
6363
+ const res = (await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name })) as
6364
+ | { result?: { message_thread_id?: unknown } }
6365
+ | undefined;
6366
+ if (res === undefined) {
6367
+ creationSuppressed = true;
6368
+ return undefined;
6369
+ }
5869
6370
  if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
5870
- const tid = (res as { result?: { message_thread_id?: unknown } }).result?.message_thread_id;
5871
- if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0)
6371
+ const response = res as {
6372
+ ok?: unknown;
6373
+ result?: { message_thread_id?: unknown };
6374
+ };
6375
+ const tid = response.result?.message_thread_id;
6376
+ if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0) {
6377
+ if (response.ok === true) this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey);
5872
6378
  throw new Error("createForumTopic: invalid message_thread_id");
6379
+ }
5873
6380
  acceptedTopicId = String(tid);
6381
+ this.#malformedTopicCreateEndpoints.delete(sessionId);
5874
6382
  if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
5875
6383
  if (
5876
6384
  !this.topics.fenceAcceptedCreateForLease(
@@ -5942,7 +6450,7 @@ export class TelegramNotificationDaemon {
5942
6450
  }
5943
6451
  return rec.topicId;
5944
6452
  } catch (err) {
5945
- if (err instanceof ThreadedModeCapabilityRefusal) return undefined;
6453
+ if (creationSuppressed || err instanceof ThreadedModeCapabilityRefusal) return undefined;
5946
6454
  if (
5947
6455
  acceptedTopicId &&
5948
6456
  !acceptedTopicCompensated &&
@@ -6480,14 +6988,32 @@ export class TelegramNotificationDaemon {
6480
6988
  continue;
6481
6989
  }
6482
6990
  try {
6483
- const response = await this.botApi.call("sendMessage", btwDelivery.body, {
6991
+ const { outcome } = await this.callBotApiClassified("sendMessage", btwDelivery.body, {
6484
6992
  noRetry: true,
6485
6993
  signal: btwDelivery.signal,
6486
6994
  });
6487
- const accepted = response && typeof response === "object" && (response as { ok?: unknown }).ok === true;
6488
- const rejected = response && typeof response === "object" && (response as { ok?: unknown }).ok === false;
6489
- btwDelivery.finish(accepted ? "accepted" : rejected ? "not_delivered" : "uncertain");
6490
- this.pool.settle(item.itemId!, accepted ? "accepted" : rejected ? "rejected" : "ambiguous");
6995
+ if (outcome.kind === "retryable") {
6996
+ this.submitPool({
6997
+ sessionId: item.sessionId,
6998
+ lane: item.lane,
6999
+ coalesceKey: item.coalesceKey,
7000
+ deadlineAt: item.deadlineAt,
7001
+ payload: item.payload,
7002
+ });
7003
+ this.pool.settle(item.itemId!, "ambiguous");
7004
+ continue;
7005
+ }
7006
+ btwDelivery.finish(
7007
+ outcome.kind === "accepted"
7008
+ ? "accepted"
7009
+ : outcome.kind === "rejected"
7010
+ ? "not_delivered"
7011
+ : "uncertain",
7012
+ );
7013
+ this.pool.settle(
7014
+ item.itemId!,
7015
+ outcome.kind === "accepted" ? "accepted" : outcome.kind === "rejected" ? "rejected" : "ambiguous",
7016
+ );
6491
7017
  } catch {
6492
7018
  btwDelivery.finish("uncertain");
6493
7019
  this.pool.settle(item.itemId!, "ambiguous");
@@ -6533,7 +7059,7 @@ export class TelegramNotificationDaemon {
6533
7059
  this.pool.settle(item.itemId!, "rejected");
6534
7060
  continue;
6535
7061
  }
6536
- const response = (await this.botApi.call(
7062
+ const { response, outcome } = await this.callBotApiClassified(
6537
7063
  "sendMessage",
6538
7064
  {
6539
7065
  chat_id: this.opts.chatId,
@@ -6541,9 +7067,28 @@ export class TelegramNotificationDaemon {
6541
7067
  text: "Selected!",
6542
7068
  },
6543
7069
  { signal: controller.signal, noRetry: true },
6544
- )) as { ok?: unknown; result?: { message_id?: unknown } };
6545
- const messageId = response.result?.message_id;
6546
- const delivered = response.ok === true && typeof messageId === "number";
7070
+ );
7071
+ if (outcome.kind === "retryable") {
7072
+ this.pool.settle(item.itemId!, "ambiguous");
7073
+ if (this.effects.stopping) {
7074
+ this.finishSelectedAck(selectedAck, { status: "unknown", reason: "shutdown" });
7075
+ } else {
7076
+ const retry = this.pool.submit({
7077
+ sessionId: item.sessionId,
7078
+ lane: item.lane,
7079
+ coalesceKey: item.coalesceKey,
7080
+ deadlineAt: item.deadlineAt,
7081
+ payload: item.payload,
7082
+ });
7083
+ selectedAck.itemId = retry.itemId;
7084
+ selectedAck.state = "queued";
7085
+ selectedAck.controller = undefined;
7086
+ }
7087
+ continue;
7088
+ }
7089
+ const typedResponse = response as { ok?: unknown; result?: { message_id?: unknown } } | undefined;
7090
+ const messageId = typedResponse?.result?.message_id;
7091
+ const delivered = typedResponse?.ok === true && typeof messageId === "number";
6547
7092
  this.finishSelectedAck(
6548
7093
  selectedAck,
6549
7094
  delivered ? { status: "delivered", messageId } : { status: "failed", reason: "telegram_rejected" },
@@ -6589,6 +7134,8 @@ export class TelegramNotificationDaemon {
6589
7134
  this.pool.settle(item.itemId!, "removed");
6590
7135
  continue;
6591
7136
  }
7137
+ const itemOutcomes: BotApiCallOutcome[] = [];
7138
+ const itemBotApi = this.botApiWithOutcomeCollector(itemOutcomes);
6592
7139
  let disposition: "accepted" | "ambiguous" | "rejected" = "accepted";
6593
7140
  try {
6594
7141
  // Draft streaming (opt-in, off by default): stream a live turn frame as a
@@ -6627,7 +7174,7 @@ export class TelegramNotificationDaemon {
6627
7174
  }
6628
7175
  if (send.method === "sendPhoto" && send.photoBase64) {
6629
7176
  // Real photo upload (the default botApi multiparts base64 -> file).
6630
- await this.botApi.call("sendPhoto", {
7177
+ await itemBotApi.call("sendPhoto", {
6631
7178
  chat_id: this.opts.chatId,
6632
7179
  ...threadField,
6633
7180
  photo: send.photoBase64,
@@ -6636,7 +7183,7 @@ export class TelegramNotificationDaemon {
6636
7183
  parse_mode: TELEGRAM_PARSE_MODE,
6637
7184
  });
6638
7185
  } else if (send.method === "sendDocument" && send.documentBase64) {
6639
- await this.botApi.call("sendDocument", {
7186
+ await itemBotApi.call("sendDocument", {
6640
7187
  chat_id: this.opts.chatId,
6641
7188
  ...threadField,
6642
7189
  document: send.documentBase64,
@@ -6666,7 +7213,7 @@ export class TelegramNotificationDaemon {
6666
7213
  (topicLease && !this.topicLeaseIsCurrent(topicLease))
6667
7214
  )
6668
7215
  return;
6669
- await this.botApi.call("sendMessage", {
7216
+ await itemBotApi.call("sendMessage", {
6670
7217
  chat_id: this.opts.chatId,
6671
7218
  ...threadField,
6672
7219
  text: chunks[0]!,
@@ -6696,7 +7243,7 @@ export class TelegramNotificationDaemon {
6696
7243
  }
6697
7244
  };
6698
7245
  const richMessageId = await deliverRichWithFallback(
6699
- this.botApi,
7246
+ itemBotApi,
6700
7247
  { chat_id: this.opts.chatId, ...threadField },
6701
7248
  send,
6702
7249
  AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
@@ -6730,13 +7277,13 @@ export class TelegramNotificationDaemon {
6730
7277
  (topicLease && !this.topicLeaseIsCurrent(topicLease))
6731
7278
  )
6732
7279
  return;
6733
- const res = (await this.botApi.call("editMessageText", {
7280
+ const res = (await itemBotApi.call("editMessageText", {
6734
7281
  chat_id: this.opts.chatId,
6735
7282
  message_id: existingId,
6736
7283
  text: chunks[0],
6737
7284
  parse_mode: TELEGRAM_PARSE_MODE,
6738
7285
  })) as { ok?: boolean; description?: string } | null;
6739
- edited = res?.ok !== false || /not modified/i.test(String(res?.description ?? ""));
7286
+ edited = res?.ok === true || /not modified/i.test(String(res?.description ?? ""));
6740
7287
  } catch {
6741
7288
  edited = false;
6742
7289
  }
@@ -6746,7 +7293,7 @@ export class TelegramNotificationDaemon {
6746
7293
  (!socketLease || this.#leaseTokenAllows(socketLease)) &&
6747
7294
  (!topicLease || this.topicLeaseIsCurrent(topicLease))
6748
7295
  ) {
6749
- const res = (await this.botApi.call("sendMessage", {
7296
+ const res = (await itemBotApi.call("sendMessage", {
6750
7297
  chat_id: this.opts.chatId,
6751
7298
  ...threadField,
6752
7299
  text: chunks[0]!,
@@ -6762,7 +7309,7 @@ export class TelegramNotificationDaemon {
6762
7309
  (topicLease && !this.topicLeaseIsCurrent(topicLease))
6763
7310
  )
6764
7311
  return;
6765
- const res = (await this.botApi.call("sendMessage", {
7312
+ const res = (await itemBotApi.call("sendMessage", {
6766
7313
  chat_id: this.opts.chatId,
6767
7314
  ...threadField,
6768
7315
  text: chunks[0]!,
@@ -6810,6 +7357,23 @@ export class TelegramNotificationDaemon {
6810
7357
  disposition = "ambiguous";
6811
7358
  if (item.payload.toolActivity?.phase === "started") this.toolActivityAmbiguous = true;
6812
7359
  } finally {
7360
+ const retryable = itemOutcomes.some(outcome => outcome.kind === "retryable");
7361
+ const accepted = itemOutcomes.some(outcome => outcome.kind === "accepted");
7362
+ const rejected = itemOutcomes.some(outcome => outcome.kind === "rejected");
7363
+ if (retryable) {
7364
+ if (!accepted) {
7365
+ this.submitPool({
7366
+ sessionId: item.sessionId,
7367
+ lane: item.lane,
7368
+ coalesceKey: item.coalesceKey,
7369
+ deadlineAt: item.deadlineAt,
7370
+ payload: item.payload,
7371
+ });
7372
+ }
7373
+ disposition = "ambiguous";
7374
+ } else if (disposition === "accepted" && !accepted && rejected) {
7375
+ disposition = "rejected";
7376
+ }
6813
7377
  this.pool.settle(item.itemId!, disposition);
6814
7378
  // A terminal tool frame owns the end of this coalescing key even when both
6815
7379
  // edit and fallback delivery fail. Retaining the old message id would leak
@@ -6904,6 +7468,7 @@ export class TelegramNotificationDaemon {
6904
7468
  ok?: unknown;
6905
7469
  result?: { type?: unknown };
6906
7470
  };
7471
+ if (res === undefined) return "indeterminate";
6907
7472
  if (res?.ok !== true) {
6908
7473
  logger.warn("notifications: getChat privacy check indeterminate (non-success response)");
6909
7474
  return "indeterminate";
@@ -6966,12 +7531,10 @@ export class TelegramNotificationDaemon {
6966
7531
  this.runtime.stopInterval("telegram-flush");
6967
7532
  }
6968
7533
  private async renewOwnershipHeartbeat(): Promise<boolean> {
6969
- return renewDaemonHeartbeat({
7534
+ return renewOwnerHeartbeatSidecar({
6970
7535
  settings: this.opts.settings,
6971
7536
  ownerId: this.opts.ownerId,
6972
7537
  acquisitionId: this.opts.ownerId,
6973
- tokenFingerprint: tokenFingerprint(this.opts.botToken),
6974
- chatId: this.opts.chatId,
6975
7538
  fs: this.fsImpl,
6976
7539
  now: this.opts.now,
6977
7540
  pid: this.opts.pid ?? process.pid,
@@ -7129,7 +7692,7 @@ export class TelegramNotificationDaemon {
7129
7692
  parse_mode: TELEGRAM_PARSE_MODE,
7130
7693
  reply_markup: { inline_keyboard },
7131
7694
  });
7132
- if (response && typeof response === "object" && (response as { ok?: unknown }).ok === false) {
7695
+ if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
7133
7696
  for (const alias of aliases) this.#modelChoiceAliases.delete(alias);
7134
7697
  logger.warn("notifications: failed to send model selection keyboard");
7135
7698
  return false;
@@ -7710,7 +8273,7 @@ export class TelegramNotificationDaemon {
7710
8273
  // returns the last chunk's message_id (the reply-routable message).
7711
8274
  const sendHtmlChunks = async (): Promise<number | undefined> => {
7712
8275
  const chunks = splitTelegramHtml(rendered.text);
7713
- let result: { result?: { message_id?: number } } = {};
8276
+ let result: { result?: { message_id?: number } } | undefined;
7714
8277
  for (let i = 0; i < chunks.length; i++) {
7715
8278
  if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease)))
7716
8279
  return undefined;
@@ -7720,9 +8283,10 @@ export class TelegramNotificationDaemon {
7720
8283
  text: chunks[i]!,
7721
8284
  parse_mode: TELEGRAM_PARSE_MODE,
7722
8285
  ...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
7723
- })) as { result?: { message_id?: number } };
8286
+ })) as { result?: { message_id?: number } } | undefined;
8287
+ if (result === undefined) return undefined;
7724
8288
  }
7725
- return result.result?.message_id;
8289
+ return result?.result?.message_id;
7726
8290
  };
7727
8291
  const kind = msg.kind === "idle" ? "idle" : "ask";
7728
8292
  if (this.opts.rich?.enabled !== false) {
@@ -8566,12 +9130,10 @@ export class TelegramNotificationDaemon {
8566
9130
  while (this.running) {
8567
9131
  if (await this.controlStopRequested()) break;
8568
9132
  if (
8569
- !(await renewDaemonHeartbeat({
9133
+ !(await renewOwnerHeartbeatSidecar({
8570
9134
  settings: this.opts.settings,
8571
9135
  ownerId: this.opts.ownerId,
8572
9136
  acquisitionId: this.opts.ownerId,
8573
- tokenFingerprint: tokenFingerprint(this.opts.botToken),
8574
- chatId: this.opts.chatId,
8575
9137
  fs: this.fsImpl,
8576
9138
  now: this.opts.now,
8577
9139
  pid: this.opts.pid ?? process.pid,
@@ -8613,6 +9175,11 @@ export class TelegramNotificationDaemon {
8613
9175
  await this.runtime.sleep(10);
8614
9176
  }
8615
9177
  } finally {
9178
+ this.running = false;
9179
+ this.runtime.stop();
9180
+ this.stopOwnershipHeartbeatTimer();
9181
+ const heartbeatJoined = await this.runtime.joinExclusive("telegram-owner-heartbeat", BTW_SHUTDOWN_JOIN_MS);
9182
+ if (!heartbeatJoined) logger.warn("heartbeat join timed out; retaining daemon ownership (release aborted)");
8616
9183
  // A contender must not mutate durable owner state while unwinding startup.
8617
9184
  if (ownershipProved) {
8618
9185
  let toolShutdownError: unknown;
@@ -8628,8 +9195,6 @@ export class TelegramNotificationDaemon {
8628
9195
  if (toolShutdownError) throw toolShutdownError;
8629
9196
  await this.#drainBtwTurns();
8630
9197
  await this.toolTerminalizationChain;
8631
- this.runtime.stop();
8632
- this.stopOwnershipHeartbeatTimer();
8633
9198
  this.stopFlushTimer();
8634
9199
  this.stopScanTimer();
8635
9200
  this.stopTypingTimer();
@@ -8654,7 +9219,7 @@ export class TelegramNotificationDaemon {
8654
9219
  deadline.promise,
8655
9220
  ]);
8656
9221
  clearTimeout(deadlineTimer);
8657
- const quiesced = completed && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
9222
+ const quiesced = completed && heartbeatJoined && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
8658
9223
  if (!quiesced || !persisted) {
8659
9224
  logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
8660
9225
  } else {