@gajae-code/coding-agent 0.11.9 → 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 +18 -1
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
- 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 +12 -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 +1 -1
- 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/notify-cli.ts +1 -0
- package/src/config/model-profiles.ts +7 -7
- package/src/config/model-registry.ts +4 -2
- package/src/eval/js/tool-bridge.ts +1 -1
- package/src/gjc-runtime/deep-interview-draft.ts +2 -1
- package/src/gjc-runtime/deep-interview-recorder.ts +16 -1
- package/src/gjc-runtime/state-writer.ts +1 -1
- package/src/hooks/skill-state.ts +23 -1
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/modes/controllers/selector-controller.ts +38 -8
- 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/sdk/broker/identity.ts +22 -12
- 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 +13 -7
- package/src/sdk/bus/telegram-daemon-control.ts +14 -5
- package/src/sdk/bus/telegram-daemon.ts +667 -118
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/session.ts +12 -0
- package/src/session/agent-session.ts +54 -10
- package/src/session/internal/managed-session-scope.ts +133 -117
- package/src/skill-state/workflow-mutation-guard.ts +41 -7
- package/src/task/executor.ts +141 -39
- 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/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. */
|
|
@@ -3698,6 +4138,9 @@ export class TelegramNotificationDaemon {
|
|
|
3698
4138
|
private readonly pool: RateLimitPool<TelegramQueuePayload>;
|
|
3699
4139
|
private readonly poller: TelegramUpdatePoller;
|
|
3700
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;
|
|
3701
4144
|
/** Original markdown of rich messages we sent (chat+message_id), for restoring reply context on inbound replies. */
|
|
3702
4145
|
private readonly replyStore: ReplySentStore;
|
|
3703
4146
|
/** Per-session debounce + monotonic draft-id state for opt-in draft streaming. */
|
|
@@ -4116,8 +4559,8 @@ export class TelegramNotificationDaemon {
|
|
|
4116
4559
|
|
|
4117
4560
|
private async refreshBotIdentity(): Promise<void> {
|
|
4118
4561
|
try {
|
|
4119
|
-
const response = (await this.botApi.call("getMe", {})) as { result?: { username?: unknown } };
|
|
4120
|
-
const username = response
|
|
4562
|
+
const response = (await this.botApi.call("getMe", {})) as { result?: { username?: unknown } } | undefined;
|
|
4563
|
+
const username = response?.result?.username;
|
|
4121
4564
|
this.botUsername =
|
|
4122
4565
|
typeof username === "string" && username.trim() ? username.trim().replace(/^@/, "") : undefined;
|
|
4123
4566
|
} catch {
|
|
@@ -4130,6 +4573,47 @@ export class TelegramNotificationDaemon {
|
|
|
4130
4573
|
return formatLifecycleOutcome(r, verb);
|
|
4131
4574
|
}
|
|
4132
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
|
+
|
|
4133
4617
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
4134
4618
|
this.fsImpl = opts.fs ?? nodeFs;
|
|
4135
4619
|
this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
|
|
@@ -4142,8 +4626,9 @@ export class TelegramNotificationDaemon {
|
|
|
4142
4626
|
fetchImpl: opts.fetchImpl,
|
|
4143
4627
|
setTimeoutImpl: opts.setTimeoutImpl,
|
|
4144
4628
|
});
|
|
4629
|
+
this.callBotApiClassified = (method, body, callOpts) => this.callBotApi(rawBotApi, method, body, callOpts);
|
|
4145
4630
|
this.botApi = {
|
|
4146
|
-
call: (method, body, callOpts) => this.
|
|
4631
|
+
call: async (method, body, callOpts) => (await this.callBotApiClassified(method, body, callOpts)).response,
|
|
4147
4632
|
};
|
|
4148
4633
|
this.runtime = new NotificationOperatorRuntime({
|
|
4149
4634
|
now: opts.now,
|
|
@@ -5868,13 +6353,20 @@ export class TelegramNotificationDaemon {
|
|
|
5868
6353
|
let acceptedTopicId: string | undefined;
|
|
5869
6354
|
let acceptedTopicCompensated = false;
|
|
5870
6355
|
let acceptedTopicDeleteAttempted = false;
|
|
6356
|
+
let creationSuppressed = false;
|
|
5871
6357
|
try {
|
|
5872
6358
|
const rec = await this.topics.getOrCreateTopic(
|
|
5873
6359
|
sessionId,
|
|
5874
6360
|
async () => {
|
|
5875
6361
|
if (capturedCreationLease && !this.#leaseTokenAllows(capturedCreationLease))
|
|
5876
6362
|
throw new Error("topic authority was revoked during creation");
|
|
5877
|
-
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
|
+
}
|
|
5878
6370
|
if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
|
|
5879
6371
|
const response = res as {
|
|
5880
6372
|
ok?: unknown;
|
|
@@ -5958,7 +6450,7 @@ export class TelegramNotificationDaemon {
|
|
|
5958
6450
|
}
|
|
5959
6451
|
return rec.topicId;
|
|
5960
6452
|
} catch (err) {
|
|
5961
|
-
if (err instanceof ThreadedModeCapabilityRefusal) return undefined;
|
|
6453
|
+
if (creationSuppressed || err instanceof ThreadedModeCapabilityRefusal) return undefined;
|
|
5962
6454
|
if (
|
|
5963
6455
|
acceptedTopicId &&
|
|
5964
6456
|
!acceptedTopicCompensated &&
|
|
@@ -6496,14 +6988,32 @@ export class TelegramNotificationDaemon {
|
|
|
6496
6988
|
continue;
|
|
6497
6989
|
}
|
|
6498
6990
|
try {
|
|
6499
|
-
const
|
|
6991
|
+
const { outcome } = await this.callBotApiClassified("sendMessage", btwDelivery.body, {
|
|
6500
6992
|
noRetry: true,
|
|
6501
6993
|
signal: btwDelivery.signal,
|
|
6502
6994
|
});
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
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
|
+
);
|
|
6507
7017
|
} catch {
|
|
6508
7018
|
btwDelivery.finish("uncertain");
|
|
6509
7019
|
this.pool.settle(item.itemId!, "ambiguous");
|
|
@@ -6549,7 +7059,7 @@ export class TelegramNotificationDaemon {
|
|
|
6549
7059
|
this.pool.settle(item.itemId!, "rejected");
|
|
6550
7060
|
continue;
|
|
6551
7061
|
}
|
|
6552
|
-
const response =
|
|
7062
|
+
const { response, outcome } = await this.callBotApiClassified(
|
|
6553
7063
|
"sendMessage",
|
|
6554
7064
|
{
|
|
6555
7065
|
chat_id: this.opts.chatId,
|
|
@@ -6557,9 +7067,28 @@ export class TelegramNotificationDaemon {
|
|
|
6557
7067
|
text: "Selected!",
|
|
6558
7068
|
},
|
|
6559
7069
|
{ signal: controller.signal, noRetry: true },
|
|
6560
|
-
)
|
|
6561
|
-
|
|
6562
|
-
|
|
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";
|
|
6563
7092
|
this.finishSelectedAck(
|
|
6564
7093
|
selectedAck,
|
|
6565
7094
|
delivered ? { status: "delivered", messageId } : { status: "failed", reason: "telegram_rejected" },
|
|
@@ -6605,6 +7134,8 @@ export class TelegramNotificationDaemon {
|
|
|
6605
7134
|
this.pool.settle(item.itemId!, "removed");
|
|
6606
7135
|
continue;
|
|
6607
7136
|
}
|
|
7137
|
+
const itemOutcomes: BotApiCallOutcome[] = [];
|
|
7138
|
+
const itemBotApi = this.botApiWithOutcomeCollector(itemOutcomes);
|
|
6608
7139
|
let disposition: "accepted" | "ambiguous" | "rejected" = "accepted";
|
|
6609
7140
|
try {
|
|
6610
7141
|
// Draft streaming (opt-in, off by default): stream a live turn frame as a
|
|
@@ -6643,7 +7174,7 @@ export class TelegramNotificationDaemon {
|
|
|
6643
7174
|
}
|
|
6644
7175
|
if (send.method === "sendPhoto" && send.photoBase64) {
|
|
6645
7176
|
// Real photo upload (the default botApi multiparts base64 -> file).
|
|
6646
|
-
await
|
|
7177
|
+
await itemBotApi.call("sendPhoto", {
|
|
6647
7178
|
chat_id: this.opts.chatId,
|
|
6648
7179
|
...threadField,
|
|
6649
7180
|
photo: send.photoBase64,
|
|
@@ -6652,7 +7183,7 @@ export class TelegramNotificationDaemon {
|
|
|
6652
7183
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
6653
7184
|
});
|
|
6654
7185
|
} else if (send.method === "sendDocument" && send.documentBase64) {
|
|
6655
|
-
await
|
|
7186
|
+
await itemBotApi.call("sendDocument", {
|
|
6656
7187
|
chat_id: this.opts.chatId,
|
|
6657
7188
|
...threadField,
|
|
6658
7189
|
document: send.documentBase64,
|
|
@@ -6682,7 +7213,7 @@ export class TelegramNotificationDaemon {
|
|
|
6682
7213
|
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6683
7214
|
)
|
|
6684
7215
|
return;
|
|
6685
|
-
await
|
|
7216
|
+
await itemBotApi.call("sendMessage", {
|
|
6686
7217
|
chat_id: this.opts.chatId,
|
|
6687
7218
|
...threadField,
|
|
6688
7219
|
text: chunks[0]!,
|
|
@@ -6712,7 +7243,7 @@ export class TelegramNotificationDaemon {
|
|
|
6712
7243
|
}
|
|
6713
7244
|
};
|
|
6714
7245
|
const richMessageId = await deliverRichWithFallback(
|
|
6715
|
-
|
|
7246
|
+
itemBotApi,
|
|
6716
7247
|
{ chat_id: this.opts.chatId, ...threadField },
|
|
6717
7248
|
send,
|
|
6718
7249
|
AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
|
|
@@ -6746,13 +7277,13 @@ export class TelegramNotificationDaemon {
|
|
|
6746
7277
|
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6747
7278
|
)
|
|
6748
7279
|
return;
|
|
6749
|
-
const res = (await
|
|
7280
|
+
const res = (await itemBotApi.call("editMessageText", {
|
|
6750
7281
|
chat_id: this.opts.chatId,
|
|
6751
7282
|
message_id: existingId,
|
|
6752
7283
|
text: chunks[0],
|
|
6753
7284
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
6754
7285
|
})) as { ok?: boolean; description?: string } | null;
|
|
6755
|
-
edited = res?.ok
|
|
7286
|
+
edited = res?.ok === true || /not modified/i.test(String(res?.description ?? ""));
|
|
6756
7287
|
} catch {
|
|
6757
7288
|
edited = false;
|
|
6758
7289
|
}
|
|
@@ -6762,7 +7293,7 @@ export class TelegramNotificationDaemon {
|
|
|
6762
7293
|
(!socketLease || this.#leaseTokenAllows(socketLease)) &&
|
|
6763
7294
|
(!topicLease || this.topicLeaseIsCurrent(topicLease))
|
|
6764
7295
|
) {
|
|
6765
|
-
const res = (await
|
|
7296
|
+
const res = (await itemBotApi.call("sendMessage", {
|
|
6766
7297
|
chat_id: this.opts.chatId,
|
|
6767
7298
|
...threadField,
|
|
6768
7299
|
text: chunks[0]!,
|
|
@@ -6778,7 +7309,7 @@ export class TelegramNotificationDaemon {
|
|
|
6778
7309
|
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6779
7310
|
)
|
|
6780
7311
|
return;
|
|
6781
|
-
const res = (await
|
|
7312
|
+
const res = (await itemBotApi.call("sendMessage", {
|
|
6782
7313
|
chat_id: this.opts.chatId,
|
|
6783
7314
|
...threadField,
|
|
6784
7315
|
text: chunks[0]!,
|
|
@@ -6826,6 +7357,23 @@ export class TelegramNotificationDaemon {
|
|
|
6826
7357
|
disposition = "ambiguous";
|
|
6827
7358
|
if (item.payload.toolActivity?.phase === "started") this.toolActivityAmbiguous = true;
|
|
6828
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
|
+
}
|
|
6829
7377
|
this.pool.settle(item.itemId!, disposition);
|
|
6830
7378
|
// A terminal tool frame owns the end of this coalescing key even when both
|
|
6831
7379
|
// edit and fallback delivery fail. Retaining the old message id would leak
|
|
@@ -6920,6 +7468,7 @@ export class TelegramNotificationDaemon {
|
|
|
6920
7468
|
ok?: unknown;
|
|
6921
7469
|
result?: { type?: unknown };
|
|
6922
7470
|
};
|
|
7471
|
+
if (res === undefined) return "indeterminate";
|
|
6923
7472
|
if (res?.ok !== true) {
|
|
6924
7473
|
logger.warn("notifications: getChat privacy check indeterminate (non-success response)");
|
|
6925
7474
|
return "indeterminate";
|
|
@@ -6982,12 +7531,10 @@ export class TelegramNotificationDaemon {
|
|
|
6982
7531
|
this.runtime.stopInterval("telegram-flush");
|
|
6983
7532
|
}
|
|
6984
7533
|
private async renewOwnershipHeartbeat(): Promise<boolean> {
|
|
6985
|
-
return
|
|
7534
|
+
return renewOwnerHeartbeatSidecar({
|
|
6986
7535
|
settings: this.opts.settings,
|
|
6987
7536
|
ownerId: this.opts.ownerId,
|
|
6988
7537
|
acquisitionId: this.opts.ownerId,
|
|
6989
|
-
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
6990
|
-
chatId: this.opts.chatId,
|
|
6991
7538
|
fs: this.fsImpl,
|
|
6992
7539
|
now: this.opts.now,
|
|
6993
7540
|
pid: this.opts.pid ?? process.pid,
|
|
@@ -7145,7 +7692,7 @@ export class TelegramNotificationDaemon {
|
|
|
7145
7692
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
7146
7693
|
reply_markup: { inline_keyboard },
|
|
7147
7694
|
});
|
|
7148
|
-
if (response
|
|
7695
|
+
if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
|
|
7149
7696
|
for (const alias of aliases) this.#modelChoiceAliases.delete(alias);
|
|
7150
7697
|
logger.warn("notifications: failed to send model selection keyboard");
|
|
7151
7698
|
return false;
|
|
@@ -7726,7 +8273,7 @@ export class TelegramNotificationDaemon {
|
|
|
7726
8273
|
// returns the last chunk's message_id (the reply-routable message).
|
|
7727
8274
|
const sendHtmlChunks = async (): Promise<number | undefined> => {
|
|
7728
8275
|
const chunks = splitTelegramHtml(rendered.text);
|
|
7729
|
-
let result: { result?: { message_id?: number } }
|
|
8276
|
+
let result: { result?: { message_id?: number } } | undefined;
|
|
7730
8277
|
for (let i = 0; i < chunks.length; i++) {
|
|
7731
8278
|
if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease)))
|
|
7732
8279
|
return undefined;
|
|
@@ -7736,9 +8283,10 @@ export class TelegramNotificationDaemon {
|
|
|
7736
8283
|
text: chunks[i]!,
|
|
7737
8284
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
7738
8285
|
...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
|
|
7739
|
-
})) as { result?: { message_id?: number } };
|
|
8286
|
+
})) as { result?: { message_id?: number } } | undefined;
|
|
8287
|
+
if (result === undefined) return undefined;
|
|
7740
8288
|
}
|
|
7741
|
-
return result
|
|
8289
|
+
return result?.result?.message_id;
|
|
7742
8290
|
};
|
|
7743
8291
|
const kind = msg.kind === "idle" ? "idle" : "ask";
|
|
7744
8292
|
if (this.opts.rich?.enabled !== false) {
|
|
@@ -8582,12 +9130,10 @@ export class TelegramNotificationDaemon {
|
|
|
8582
9130
|
while (this.running) {
|
|
8583
9131
|
if (await this.controlStopRequested()) break;
|
|
8584
9132
|
if (
|
|
8585
|
-
!(await
|
|
9133
|
+
!(await renewOwnerHeartbeatSidecar({
|
|
8586
9134
|
settings: this.opts.settings,
|
|
8587
9135
|
ownerId: this.opts.ownerId,
|
|
8588
9136
|
acquisitionId: this.opts.ownerId,
|
|
8589
|
-
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
8590
|
-
chatId: this.opts.chatId,
|
|
8591
9137
|
fs: this.fsImpl,
|
|
8592
9138
|
now: this.opts.now,
|
|
8593
9139
|
pid: this.opts.pid ?? process.pid,
|
|
@@ -8629,6 +9175,11 @@ export class TelegramNotificationDaemon {
|
|
|
8629
9175
|
await this.runtime.sleep(10);
|
|
8630
9176
|
}
|
|
8631
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)");
|
|
8632
9183
|
// A contender must not mutate durable owner state while unwinding startup.
|
|
8633
9184
|
if (ownershipProved) {
|
|
8634
9185
|
let toolShutdownError: unknown;
|
|
@@ -8644,8 +9195,6 @@ export class TelegramNotificationDaemon {
|
|
|
8644
9195
|
if (toolShutdownError) throw toolShutdownError;
|
|
8645
9196
|
await this.#drainBtwTurns();
|
|
8646
9197
|
await this.toolTerminalizationChain;
|
|
8647
|
-
this.runtime.stop();
|
|
8648
|
-
this.stopOwnershipHeartbeatTimer();
|
|
8649
9198
|
this.stopFlushTimer();
|
|
8650
9199
|
this.stopScanTimer();
|
|
8651
9200
|
this.stopTypingTimer();
|
|
@@ -8670,7 +9219,7 @@ export class TelegramNotificationDaemon {
|
|
|
8670
9219
|
deadline.promise,
|
|
8671
9220
|
]);
|
|
8672
9221
|
clearTimeout(deadlineTimer);
|
|
8673
|
-
const quiesced = completed && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
|
|
9222
|
+
const quiesced = completed && heartbeatJoined && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
|
|
8674
9223
|
if (!quiesced || !persisted) {
|
|
8675
9224
|
logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
|
|
8676
9225
|
} else {
|