@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.
- package/CHANGELOG.md +35 -1
- package/dist/types/cli.d.ts +1 -0
- package/dist/types/config/model-profiles.d.ts +2 -3
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
- package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
- package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
- package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
- package/dist/types/gjc-runtime/state-writer.d.ts +45 -0
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
- package/dist/types/modes/components/welcome.d.ts +2 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
- package/dist/types/runtime-mcp/redaction.d.ts +2 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -1
- package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +8 -1
- package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +14 -7
- package/dist/types/sdk/bus/telegram-daemon.d.ts +74 -5
- package/dist/types/sdk/session.d.ts +7 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +13 -3
- package/dist/types/session/internal/managed-session-storage.d.ts +8 -3
- package/dist/types/session/session-manager.d.ts +5 -1
- package/dist/types/session/session-storage.d.ts +13 -0
- package/dist/types/skill-state/active-state.d.ts +4 -0
- package/dist/types/task/provider-retry-status.d.ts +13 -0
- package/dist/types/task/types.d.ts +3 -0
- package/dist/types/tools/index.d.ts +3 -1
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +1 -0
- package/src/cli/mcp-cli.ts +4 -21
- package/src/cli/notify-cli.ts +1 -0
- package/src/cli.ts +32 -0
- package/src/commands/deep-interview.ts +9 -4
- package/src/commit/agentic/index.ts +3 -3
- package/src/commit/map-reduce/index.ts +2 -2
- package/src/config/model-profiles.ts +9 -10
- package/src/config/model-registry.ts +4 -2
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +78 -163
- package/src/eval/js/tool-bridge.ts +1 -1
- package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
- package/src/gjc-runtime/deep-interview-draft.ts +843 -0
- package/src/gjc-runtime/deep-interview-payload.ts +262 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +310 -227
- package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
- package/src/gjc-runtime/deep-interview-state.ts +916 -4
- package/src/gjc-runtime/ralplan-runtime.ts +3 -1
- package/src/gjc-runtime/state-writer.ts +166 -2
- package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
- package/src/gjc-runtime/workflow-manifest.ts +191 -5
- package/src/hooks/skill-state.ts +23 -1
- package/src/internal-urls/docs-index.generated.ts +6 -5
- package/src/modes/components/welcome.ts +10 -2
- package/src/modes/controllers/selector-controller.ts +38 -8
- package/src/modes/interactive-mode.ts +29 -9
- package/src/prompts/agent-fragments/restricted-bash.md +1 -1
- package/src/prompts/agents/architect.md +8 -1
- package/src/prompts/agents/critic.md +8 -1
- package/src/prompts/agents/planner.md +8 -1
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/runtime-mcp/json-rpc.ts +7 -4
- package/src/runtime-mcp/redaction.ts +30 -0
- package/src/sdk/broker/identity.ts +22 -12
- package/src/sdk/broker/session-index.ts +11 -1
- package/src/sdk/bus/chat-daemon-control.ts +5 -3
- package/src/sdk/bus/daemon-paths.ts +2 -0
- package/src/sdk/bus/index.ts +60 -5
- package/src/sdk/bus/notification-service.ts +19 -5
- package/src/sdk/bus/operator-runtime.ts +31 -5
- package/src/sdk/bus/telegram-cli.ts +6 -13
- package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +15 -7
- package/src/sdk/bus/telegram-daemon-control.ts +14 -5
- package/src/sdk/bus/telegram-daemon.ts +685 -120
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/session.ts +12 -0
- package/src/session/agent-session.ts +140 -10
- package/src/session/internal/managed-session-scope.ts +339 -228
- package/src/session/internal/managed-session-storage.ts +17 -4
- package/src/session/session-manager.ts +96 -33
- package/src/session/session-storage.ts +23 -0
- package/src/skill-state/active-state.ts +10 -0
- package/src/skill-state/workflow-mutation-guard.ts +41 -7
- package/src/task/executor.ts +154 -44
- package/src/task/provider-retry-status.ts +88 -0
- package/src/task/render.ts +45 -26
- package/src/task/types.ts +3 -0
- package/src/tools/ask.ts +1 -0
- package/src/tools/bash-allowed-prefixes.ts +45 -1
- package/src/tools/index.ts +3 -1
- package/src/tools/subagent-render.ts +74 -16
- package/src/tools/subagent.ts +2 -0
- package/vendor/insane-search/MANIFEST.json +4 -1
- package/vendor/insane-search/engine/bias_check.py +5 -0
- package/vendor/insane-search/engine/fetch_chain.py +67 -1
- package/vendor/insane-search/engine/transport.py +13 -2
- 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<{
|
|
815
|
-
|
|
816
|
-
|
|
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 {
|
|
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<{
|
|
838
|
-
|
|
839
|
-
|
|
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<
|
|
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<{
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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 -
|
|
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
|
-
|
|
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
|
-
//
|
|
1705
|
-
//
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2816
|
-
input: {
|
|
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
|
-
|
|
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
|
|
3295
|
+
const snapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: fsImpl });
|
|
3296
|
+
const state = snapshot.state;
|
|
2962
3297
|
const t = now();
|
|
2963
3298
|
if (
|
|
2964
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
5871
|
-
|
|
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
|
|
6991
|
+
const { outcome } = await this.callBotApiClassified("sendMessage", btwDelivery.body, {
|
|
6484
6992
|
noRetry: true,
|
|
6485
6993
|
signal: btwDelivery.signal,
|
|
6486
6994
|
});
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
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 =
|
|
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
|
-
)
|
|
6545
|
-
|
|
6546
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|