@gajae-code/coding-agent 0.11.5 → 0.11.7
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 +37 -0
- package/dist/types/capability/index.d.ts +10 -20
- package/dist/types/capability/types.d.ts +3 -0
- package/dist/types/commands/daemon.d.ts +2 -2
- package/dist/types/commands/sdk.d.ts +20 -2
- package/dist/types/config/keybindings.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +67 -7
- package/dist/types/eval/py/runner-artifact.d.ts +8 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +5 -5
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +19 -5
- package/dist/types/main.d.ts +24 -1
- package/dist/types/modes/components/btw-panel.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-status-header.d.ts +1 -0
- package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -0
- package/dist/types/sdk/bus/index.d.ts +1 -1
- package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
- package/dist/types/sdk/bus/notification-service.d.ts +34 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -3
- package/dist/types/sdk/bus/telegram-daemon.d.ts +61 -3
- package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
- package/dist/types/sdk/client/discovery.d.ts +2 -0
- package/dist/types/sdk/client/liveness.d.ts +7 -0
- package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
- package/dist/types/sdk/transport/index.d.ts +12 -0
- package/dist/types/sdk/transport/relay.d.ts +43 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
- package/dist/types/sdk/transport/socket.d.ts +7 -0
- package/dist/types/sdk/transport/stdio.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +28 -5
- package/dist/types/session/btw-contract.d.ts +16 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +21 -7
- package/dist/types/session/internal/managed-session-storage.d.ts +10 -8
- package/dist/types/session/session-manager.d.ts +35 -4
- package/dist/types/session/yield-queue.d.ts +8 -0
- package/dist/types/setup/credential-import.d.ts +5 -1
- package/dist/types/setup/provider-onboarding.d.ts +2 -0
- package/dist/types/ssh/utils.d.ts +1 -0
- package/dist/types/task/discovery.d.ts +3 -1
- package/dist/types/tools/fetch.d.ts +5 -8
- package/dist/types/tools/image-gen.d.ts +16 -0
- package/dist/types/tools/skill-discovery.d.ts +7 -0
- package/dist/types/tools/sqlite-reader.d.ts +7 -0
- package/dist/types/utils/shell-snapshot.d.ts +12 -0
- package/dist/types/web/insane/bridge.d.ts +1 -0
- package/dist/types/web/insane/url-guard.d.ts +17 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +3 -0
- package/src/capability/index.ts +50 -42
- package/src/capability/types.ts +3 -0
- package/src/cli/setup-cli.ts +10 -4
- package/src/cli.ts +34 -9
- package/src/commands/daemon.ts +22 -5
- package/src/commands/sdk.ts +42 -9
- package/src/config/keybindings.ts +1 -1
- package/src/config/model-profiles.ts +18 -0
- package/src/config/settings-schema.ts +71 -7
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
- package/src/discovery/ssh.ts +12 -1
- package/src/eval/py/kernel.ts +1 -21
- package/src/eval/py/runner-artifact.ts +84 -0
- package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
- package/src/gjc-runtime/ultragoal-guard.ts +88 -10
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
- package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
- package/src/gjc-runtime/workflow-manifest.ts +23 -0
- package/src/internal-urls/docs-index.generated.ts +9 -8
- package/src/internal-urls/local-protocol.ts +32 -6
- package/src/main.ts +87 -28
- package/src/modes/action-registry.ts +1 -1
- package/src/modes/components/btw-panel.ts +82 -16
- package/src/modes/components/model-selector.ts +31 -3
- package/src/modes/components/tool-status-header.ts +6 -1
- package/src/modes/controllers/btw-controller.ts +119 -36
- package/src/modes/controllers/command-controller.ts +15 -0
- package/src/modes/controllers/extension-ui-controller.ts +14 -2
- package/src/modes/controllers/input-controller.ts +31 -0
- package/src/modes/controllers/selector-controller.ts +106 -12
- package/src/modes/interactive-mode.ts +16 -7
- package/src/modes/runtime-init.ts +16 -2
- package/src/modes/types.ts +1 -0
- package/src/prompts/system/btw-user.md +3 -8
- package/src/prompts/tools/skill-discovery.md +1 -0
- package/src/runtime-mcp/client.ts +41 -55
- package/src/runtime-mcp/content-limits.ts +71 -0
- package/src/runtime-mcp/json-rpc.ts +23 -9
- package/src/runtime-mcp/transports/http.ts +50 -16
- package/src/sdk/bus/chat-daemon-control.ts +20 -7
- package/src/sdk/bus/index.ts +8 -7
- package/src/sdk/bus/lifecycle-commands.ts +16 -7
- package/src/sdk/bus/notification-service.ts +41 -18
- package/src/sdk/bus/telegram-daemon-contract.ts +13 -3
- package/src/sdk/bus/telegram-daemon-control.ts +26 -8
- package/src/sdk/bus/telegram-daemon.ts +1736 -338
- package/src/sdk/bus/topic-registry.ts +451 -12
- package/src/sdk/client/discovery.ts +19 -3
- package/src/sdk/client/liveness.ts +49 -0
- package/src/sdk/host/control/dispatch.ts +14 -5
- package/src/sdk/protocol/operation-inventory.generated.json +29 -0
- package/src/sdk/session.ts +29 -5
- package/src/sdk/transport/auth-preface.ts +82 -0
- package/src/sdk/transport/index.ts +18 -0
- package/src/sdk/transport/relay.ts +252 -0
- package/src/sdk/transport/serve-cli.ts +105 -0
- package/src/sdk/transport/socket.ts +197 -0
- package/src/sdk/transport/stdio.ts +17 -0
- package/src/session/agent-session.ts +1212 -768
- package/src/session/blob-store.ts +19 -6
- package/src/session/btw-contract.ts +59 -0
- package/src/session/internal/managed-session-scope.ts +313 -57
- package/src/session/internal/managed-session-storage.ts +146 -35
- package/src/session/session-manager.ts +385 -85
- package/src/session/yield-queue.ts +20 -0
- package/src/setup/credential-import.ts +28 -3
- package/src/setup/provider-onboarding.ts +30 -1
- package/src/setup/provider-presets.json +2 -1
- package/src/slash-commands/builtin-registry.ts +37 -1
- package/src/ssh/utils.ts +14 -0
- package/src/task/discovery.ts +8 -2
- package/src/tools/ask-answer-registry.ts +15 -1
- package/src/tools/ask.ts +1 -1
- package/src/tools/fetch.ts +13 -131
- package/src/tools/image-gen.ts +301 -20
- package/src/tools/read.ts +2 -0
- package/src/tools/skill-discovery.ts +39 -3
- package/src/tools/sqlite-reader.ts +63 -5
- package/src/utils/pasted-image-path.ts +15 -6
- package/src/utils/shell-snapshot.ts +173 -63
- package/src/web/insane/bridge.ts +5 -1
- package/src/web/insane/url-guard.ts +138 -27
- package/src/web/scrapers/types.ts +19 -49
- package/src/web/scrapers/utils.ts +12 -26
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
|
|
21
21
|
import {
|
|
22
22
|
acquireDaemonTransitionLock,
|
|
23
|
+
classifyNotificationEndpoint,
|
|
23
24
|
type DaemonTransitionLock,
|
|
24
25
|
daemonTransitionLockIsHeld,
|
|
25
26
|
exactUnlinkNotificationFile,
|
|
@@ -60,6 +61,7 @@ import {
|
|
|
60
61
|
formatLifecycleOutcome,
|
|
61
62
|
isLifecycleCommandLikeText,
|
|
62
63
|
isLifecycleCommandText,
|
|
64
|
+
type LifecycleCommandVerb,
|
|
63
65
|
lifecycleUsage,
|
|
64
66
|
parseLifecycleCommand,
|
|
65
67
|
validateLifecycleTarget,
|
|
@@ -95,7 +97,7 @@ import {
|
|
|
95
97
|
} from "./telegram-reference";
|
|
96
98
|
import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound";
|
|
97
99
|
import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
|
|
98
|
-
import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
|
|
100
|
+
import { type TopicEndpointBinding, TopicRegistry, type TopicRegistryState } from "./topic-registry";
|
|
99
101
|
|
|
100
102
|
export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked";
|
|
101
103
|
/** Detailed result for orchestration that must distinguish a #2028 handoff from a fresh spawn. */
|
|
@@ -221,6 +223,9 @@ const RATE_LIMIT_FLUSH_INTERVAL_MS = 1_000;
|
|
|
221
223
|
// buffered ask is delivered up to 25s late — or never, if the user answers the
|
|
222
224
|
// local ask first (which clears the buffered ask).
|
|
223
225
|
const SESSION_SCAN_INTERVAL_MS = 1_000;
|
|
226
|
+
// Retry a session endpoint whose socket never completes its WebSocket handshake.
|
|
227
|
+
const CONNECTING_RECONNECT_MS = 1_000;
|
|
228
|
+
|
|
224
229
|
// Transient Telegram API delivery is retried this many times before giving up.
|
|
225
230
|
const BOT_API_RETRY_ATTEMPTS = 3;
|
|
226
231
|
// Backoff after a failed getUpdates long-poll so a persistent outage does not
|
|
@@ -245,8 +250,13 @@ const BTW_USAGE_TEXT = "Usage: /btw <question>";
|
|
|
245
250
|
const BTW_CAPACITY_TEXT = "Too many /btw questions are pending. Wait for one to finish and try again.";
|
|
246
251
|
export const BTW_QUESTION_MAX_UNICODE_SCALARS = 4_096;
|
|
247
252
|
export const BTW_QUESTION_MAX_UTF8_BYTES = 16_384;
|
|
253
|
+
const TELEGRAM_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
|
|
254
|
+
const TELEGRAM_SESSION_ATTACHMENT_MAX_COUNT = 20;
|
|
255
|
+
const TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024;
|
|
256
|
+
const TELEGRAM_ATTACHMENT_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
248
257
|
const BTW_QUESTION_LIMIT_TEXT = "Question must be at most 4096 Unicode scalar values and 16384 UTF-8 bytes.";
|
|
249
258
|
type ParsedBtwCommand = { kind: "question"; question: string } | { kind: "ignored" };
|
|
259
|
+
type TelegramFileDownload = { bytes: Buffer } | { failure: "download_failed" | "too_large" };
|
|
250
260
|
class ThreadedModeCapabilityRefusal extends Error {}
|
|
251
261
|
|
|
252
262
|
/** Only an explicit Bot API capability refusal may enable flat private-chat delivery. */
|
|
@@ -591,8 +601,15 @@ function ownershipLockMatchesState(lock: OwnershipLockRead, state: DaemonState |
|
|
|
591
601
|
);
|
|
592
602
|
}
|
|
593
603
|
|
|
594
|
-
function ownershipLockMatchesStoppedState(lock: OwnershipLockRead, state:
|
|
595
|
-
|
|
604
|
+
function ownershipLockMatchesStoppedState(lock: OwnershipLockRead, state: unknown): boolean {
|
|
605
|
+
if (isExplicitlyStoppedDaemonState(state)) return ownershipLockMatchesState(lock, state);
|
|
606
|
+
if (!isLegacyStoppedDaemonState(state) || lock.kind !== "legacy") return false;
|
|
607
|
+
const legacyState = state as Pick<DaemonState, "pid" | "startedAt"> & { stoppedAt: number };
|
|
608
|
+
return (
|
|
609
|
+
lock.metadata.pid === legacyState.pid &&
|
|
610
|
+
lock.metadata.startedAt <= legacyState.startedAt &&
|
|
611
|
+
legacyState.startedAt <= legacyState.stoppedAt
|
|
612
|
+
);
|
|
596
613
|
}
|
|
597
614
|
|
|
598
615
|
async function transitionLockIsHeldByCaller(input: {
|
|
@@ -854,7 +871,7 @@ function notificationRootForCwd(cwd: string): string {
|
|
|
854
871
|
}
|
|
855
872
|
|
|
856
873
|
function validBotToken(token: unknown): token is string {
|
|
857
|
-
return typeof token === "string" && token.length > 0;
|
|
874
|
+
return typeof token === "string" && token.trim().length > 0;
|
|
858
875
|
}
|
|
859
876
|
|
|
860
877
|
function isExplicitlyStoppedDaemonState(state: unknown): state is DaemonState {
|
|
@@ -865,6 +882,39 @@ function isExplicitlyStoppedDaemonState(state: unknown): state is DaemonState {
|
|
|
865
882
|
state.acquisitionId.length > 0,
|
|
866
883
|
);
|
|
867
884
|
}
|
|
885
|
+
/**
|
|
886
|
+
* v0.11.4 and earlier generations wrote stopped tombstones before acquisition
|
|
887
|
+
* and incarnation fields existed. A live, reused PID must not turn that durable
|
|
888
|
+
* stop marker into a foreign live owner during an upgrade.
|
|
889
|
+
*/
|
|
890
|
+
function isLegacyStoppedDaemonState(state: unknown): boolean {
|
|
891
|
+
const candidate = state as Partial<DaemonState> | undefined;
|
|
892
|
+
return Boolean(
|
|
893
|
+
candidate &&
|
|
894
|
+
Number.isSafeInteger(candidate.pid) &&
|
|
895
|
+
(candidate.pid ?? 0) > 0 &&
|
|
896
|
+
typeof candidate.ownerId === "string" &&
|
|
897
|
+
candidate.ownerId.length > 0 &&
|
|
898
|
+
typeof candidate.tokenFingerprint === "string" &&
|
|
899
|
+
typeof candidate.chatId === "string" &&
|
|
900
|
+
Number.isSafeInteger(candidate.startedAt) &&
|
|
901
|
+
Number.isSafeInteger(candidate.heartbeatAt) &&
|
|
902
|
+
Number.isSafeInteger(candidate.stoppedAt) &&
|
|
903
|
+
(candidate.launcherPid === undefined ||
|
|
904
|
+
(Number.isSafeInteger(candidate.launcherPid) && (candidate.launcherPid ?? 0) > 0)) &&
|
|
905
|
+
Array.isArray(candidate.roots) &&
|
|
906
|
+
candidate.roots.every(root => typeof root === "string") &&
|
|
907
|
+
candidate.version === DAEMON_VERSION &&
|
|
908
|
+
isRecognizedLegacyGeneration(candidate.generation) &&
|
|
909
|
+
candidate.incarnation === undefined &&
|
|
910
|
+
candidate.acquisitionId === undefined &&
|
|
911
|
+
candidate.ownershipPhase === undefined,
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function isStoppedDaemonState(state: unknown): boolean {
|
|
916
|
+
return isExplicitlyStoppedDaemonState(state) || isLegacyStoppedDaemonState(state);
|
|
917
|
+
}
|
|
868
918
|
|
|
869
919
|
function ownerIdentityMatches(
|
|
870
920
|
state: Pick<DaemonState, "tokenFingerprint" | "chatId">,
|
|
@@ -1016,8 +1066,8 @@ function classifyForeignLiveOwner(input: {
|
|
|
1016
1066
|
// Validate before probing so malformed PIDs never reach the liveness source.
|
|
1017
1067
|
if (!state || !validDaemonPid(state.pid) || !input.pidAlive(state.pid)) return undefined;
|
|
1018
1068
|
// A stopped tombstone with a canonical acquisition is not a live owner.
|
|
1019
|
-
if (
|
|
1020
|
-
const currentIncarnation = input.pidIncarnation
|
|
1069
|
+
if (isStoppedDaemonState(state) || isLegacyParentDaemonState(state)) return undefined;
|
|
1070
|
+
const currentIncarnation = input.pidIncarnation ? input.pidIncarnation(state.pid) : defaultPidIncarnation(state.pid);
|
|
1021
1071
|
if (
|
|
1022
1072
|
!hasSafeDaemonStateShape(state) ||
|
|
1023
1073
|
!isProcessIncarnation(state.incarnation) ||
|
|
@@ -1156,7 +1206,7 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1156
1206
|
if (state && !hasSafeDaemonStateShape(state)) {
|
|
1157
1207
|
const malformed = state as Partial<DaemonState>;
|
|
1158
1208
|
if (
|
|
1159
|
-
!
|
|
1209
|
+
!isStoppedDaemonState(malformed) &&
|
|
1160
1210
|
validDaemonPid(malformed.pid) &&
|
|
1161
1211
|
typeof malformed.incarnation === "string" &&
|
|
1162
1212
|
pidAlive(malformed.pid)
|
|
@@ -1206,6 +1256,16 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1206
1256
|
return { acquired: false, attached: false, provisional: true };
|
|
1207
1257
|
};
|
|
1208
1258
|
const existing = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1259
|
+
if (
|
|
1260
|
+
existing &&
|
|
1261
|
+
validDaemonPid(existing.pid) &&
|
|
1262
|
+
!ownerIdentityMatches(existing, input.tokenFingerprint, input.chatId) &&
|
|
1263
|
+
(!hasSafeDaemonStateShape(existing) ||
|
|
1264
|
+
!isProcessIncarnation(existing.incarnation) ||
|
|
1265
|
+
!isProcessIncarnation(pidIncarnation(existing.pid))) &&
|
|
1266
|
+
pidAlive(existing.pid)
|
|
1267
|
+
)
|
|
1268
|
+
return { acquired: false, attached: false, blocked: true };
|
|
1209
1269
|
const foreignOwner = classifyForeignLiveOwner({
|
|
1210
1270
|
state: existing,
|
|
1211
1271
|
tokenFingerprint: input.tokenFingerprint,
|
|
@@ -1825,8 +1885,8 @@ function defaultPidAlive(pid: number): boolean {
|
|
|
1825
1885
|
try {
|
|
1826
1886
|
process.kill(pid, 0);
|
|
1827
1887
|
return true;
|
|
1828
|
-
} catch {
|
|
1829
|
-
return
|
|
1888
|
+
} catch (error) {
|
|
1889
|
+
return (error as NodeJS.ErrnoException).code !== "ESRCH";
|
|
1830
1890
|
}
|
|
1831
1891
|
}
|
|
1832
1892
|
|
|
@@ -2010,6 +2070,135 @@ export async function spawnTelegramDaemonOwner(
|
|
|
2010
2070
|
return { result: "owner_spawned", acquisition, runtime, warnings: [] };
|
|
2011
2071
|
}
|
|
2012
2072
|
|
|
2073
|
+
/**
|
|
2074
|
+
* Owner-bound reclamation of a confirmed-dead daemon owner, mirroring the
|
|
2075
|
+
* daemon step of `gjc notify recovery`. It returns a structured, actionable
|
|
2076
|
+
* result and removes only identity-verified dead-owner artifacts while holding
|
|
2077
|
+
* the transition fence; live, successor, unknown, or unreadable evidence is
|
|
2078
|
+
* retained.
|
|
2079
|
+
*/
|
|
2080
|
+
export type DeadOwnerRecoveryResult =
|
|
2081
|
+
| { recovered: true; reason: "cleared" }
|
|
2082
|
+
| {
|
|
2083
|
+
recovered: false;
|
|
2084
|
+
reason:
|
|
2085
|
+
| "not-confirmed-dead"
|
|
2086
|
+
| "unsafe-lock"
|
|
2087
|
+
| "transition-contended"
|
|
2088
|
+
| "owner-superseded"
|
|
2089
|
+
| "unsafe-endpoint"
|
|
2090
|
+
| "endpoint-changed"
|
|
2091
|
+
| "endpoint-directory-unreadable"
|
|
2092
|
+
| "lock-changed";
|
|
2093
|
+
};
|
|
2094
|
+
|
|
2095
|
+
/** Preflight cleanup for a confirmed-dead owner, with identity-bound removal. */
|
|
2096
|
+
export async function reclaimDeadDaemonOwner(input: {
|
|
2097
|
+
settings: Settings;
|
|
2098
|
+
endpointDir?: string;
|
|
2099
|
+
fs?: TelegramDaemonFs;
|
|
2100
|
+
now?: () => number;
|
|
2101
|
+
pidAlive?: (pid: number) => boolean;
|
|
2102
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
2103
|
+
}): Promise<DeadOwnerRecoveryResult> {
|
|
2104
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
2105
|
+
const now = input.now ?? Date.now;
|
|
2106
|
+
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
2107
|
+
const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
|
|
2108
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
2109
|
+
const state = await readDaemonState(input.settings, fsImpl);
|
|
2110
|
+
if (!state || !hasSafeDaemonStateShape(state) || pidAlive(state.pid))
|
|
2111
|
+
return { recovered: false, reason: "not-confirmed-dead" };
|
|
2112
|
+
const lock = await readOwnershipLock(fsImpl, paths.lock);
|
|
2113
|
+
if (
|
|
2114
|
+
!(await ownershipLockIsReclaimable({ fs: fsImpl, path: paths.lock, lock, now: now(), pidAlive, pidIncarnation }))
|
|
2115
|
+
)
|
|
2116
|
+
return { recovered: false, reason: "unsafe-lock" };
|
|
2117
|
+
const transition = await acquireDaemonTransitionLock({
|
|
2118
|
+
fs: fsImpl,
|
|
2119
|
+
path: paths.steal,
|
|
2120
|
+
pid: process.pid,
|
|
2121
|
+
pidAlive: defaultPidAlive,
|
|
2122
|
+
pidIncarnation: defaultPidIncarnation,
|
|
2123
|
+
});
|
|
2124
|
+
const readEndpointFile = fsImpl.readEndpointFile;
|
|
2125
|
+
if (!transition || !readEndpointFile || !fsImpl.exactUnlink)
|
|
2126
|
+
return { recovered: false, reason: "transition-contended" };
|
|
2127
|
+
try {
|
|
2128
|
+
const current = await readDaemonState(input.settings, fsImpl);
|
|
2129
|
+
if (
|
|
2130
|
+
!current ||
|
|
2131
|
+
!hasSafeDaemonStateShape(current) ||
|
|
2132
|
+
current.ownerId !== state.ownerId ||
|
|
2133
|
+
current.acquisitionId !== state.acquisitionId ||
|
|
2134
|
+
current.pid !== state.pid ||
|
|
2135
|
+
current.incarnation !== state.incarnation ||
|
|
2136
|
+
current.generation !== state.generation ||
|
|
2137
|
+
current.ownershipPhase !== state.ownershipPhase ||
|
|
2138
|
+
current.tokenFingerprint !== state.tokenFingerprint ||
|
|
2139
|
+
current.chatId !== state.chatId ||
|
|
2140
|
+
pidAlive(current.pid)
|
|
2141
|
+
)
|
|
2142
|
+
return { recovered: false, reason: "owner-superseded" };
|
|
2143
|
+
const currentLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
2144
|
+
if (
|
|
2145
|
+
!ownershipLockMatches(lock, currentLock) ||
|
|
2146
|
+
!(await ownershipLockIsReclaimable({
|
|
2147
|
+
fs: fsImpl,
|
|
2148
|
+
path: paths.lock,
|
|
2149
|
+
lock: currentLock,
|
|
2150
|
+
now: now(),
|
|
2151
|
+
pidAlive,
|
|
2152
|
+
pidIncarnation,
|
|
2153
|
+
})) ||
|
|
2154
|
+
!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
|
|
2155
|
+
)
|
|
2156
|
+
return { recovered: false, reason: "unsafe-lock" };
|
|
2157
|
+
const endpoints: Array<{ file: string; identity: NotificationEndpointFileIdentity }> = [];
|
|
2158
|
+
if (input.endpointDir) {
|
|
2159
|
+
let names: string[];
|
|
2160
|
+
try {
|
|
2161
|
+
names = await fsImpl.readdir(input.endpointDir);
|
|
2162
|
+
} catch (error) {
|
|
2163
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") names = [];
|
|
2164
|
+
else return { recovered: false, reason: "endpoint-directory-unreadable" };
|
|
2165
|
+
}
|
|
2166
|
+
for (const name of names) {
|
|
2167
|
+
if (!name.endsWith(".json")) continue;
|
|
2168
|
+
const file = path.join(input.endpointDir, name);
|
|
2169
|
+
const endpoint = await classifyNotificationEndpoint(
|
|
2170
|
+
{ readEndpointFile: fsImpl.readEndpointFile! },
|
|
2171
|
+
file,
|
|
2172
|
+
pidAlive,
|
|
2173
|
+
);
|
|
2174
|
+
if (endpoint.kind === "non-endpoint") continue;
|
|
2175
|
+
if (endpoint.kind !== "endpoint" || endpoint.liveness !== "dead")
|
|
2176
|
+
return { recovered: false, reason: "unsafe-endpoint" };
|
|
2177
|
+
endpoints.push({ file, identity: endpoint.identity });
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
|
|
2181
|
+
return { recovered: false, reason: "transition-contended" };
|
|
2182
|
+
for (const endpoint of endpoints)
|
|
2183
|
+
if (!(await fsImpl.exactUnlink(endpoint.file, endpoint.identity)).ok)
|
|
2184
|
+
return { recovered: false, reason: "endpoint-changed" };
|
|
2185
|
+
if (currentLock.kind === "missing") return { recovered: true, reason: "cleared" };
|
|
2186
|
+
const exactLock = await fsImpl.readEndpointFile!(paths.lock).catch(() => undefined);
|
|
2187
|
+
const exactCurrentLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
2188
|
+
if (
|
|
2189
|
+
!exactLock ||
|
|
2190
|
+
!ownershipLockMatches(currentLock, exactCurrentLock) ||
|
|
2191
|
+
!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
|
|
2192
|
+
)
|
|
2193
|
+
return { recovered: false, reason: "lock-changed" };
|
|
2194
|
+
return (await fsImpl.exactUnlink(paths.lock, exactLock.identity)).ok
|
|
2195
|
+
? { recovered: true, reason: "cleared" }
|
|
2196
|
+
: { recovered: false, reason: "lock-changed" };
|
|
2197
|
+
} finally {
|
|
2198
|
+
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2013
2202
|
/**
|
|
2014
2203
|
* Ensure a configured daemon owns this session root, preserving ownership safety
|
|
2015
2204
|
* while exposing whether a #2028 generation handoff was required.
|
|
@@ -2022,6 +2211,24 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
2022
2211
|
if (!isTelegramConfigured(cfg)) return "disabled";
|
|
2023
2212
|
const root = notificationRootForCwd(input.cwd);
|
|
2024
2213
|
const fp = tokenFingerprint(cfg.botToken);
|
|
2214
|
+
// Windows can retain dead launcher metadata without an ownership lock; reclaim
|
|
2215
|
+
// its dead discovery records before the replacement can publish a new owner.
|
|
2216
|
+
if ((deps.platform ?? process.platform) === "win32" && !deps.fs) {
|
|
2217
|
+
const preflight = await reclaimDeadDaemonOwner({
|
|
2218
|
+
settings: input.settings,
|
|
2219
|
+
endpointDir: path.join(root, "sdk"),
|
|
2220
|
+
fs: deps.fs,
|
|
2221
|
+
now: deps.now,
|
|
2222
|
+
pidAlive: deps.pidAlive,
|
|
2223
|
+
pidIncarnation: deps.pidIncarnation,
|
|
2224
|
+
});
|
|
2225
|
+
if (!preflight.recovered && preflight.reason !== "not-confirmed-dead") {
|
|
2226
|
+
logger.warn(
|
|
2227
|
+
`notifications: startup recovery unsafe (${preflight.reason}); run \`gjc notify recovery\` for diagnostics`,
|
|
2228
|
+
);
|
|
2229
|
+
return "blocked_identity";
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2025
2232
|
let spawned = await withTelegramSetupLease(
|
|
2026
2233
|
cfg.botToken,
|
|
2027
2234
|
async () =>
|
|
@@ -2063,8 +2270,31 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
2063
2270
|
);
|
|
2064
2271
|
}
|
|
2065
2272
|
}
|
|
2273
|
+
let recoveryReason: Extract<DeadOwnerRecoveryResult, { recovered: false }>["reason"] | undefined;
|
|
2274
|
+
if (spawned.result === "blocked" && !spawned.warnings[0]?.includes("provisional")) {
|
|
2275
|
+
const recovery = await reclaimDeadDaemonOwner({
|
|
2276
|
+
settings: input.settings,
|
|
2277
|
+
endpointDir: path.join(root, "sdk"),
|
|
2278
|
+
fs: deps.fs,
|
|
2279
|
+
now: deps.now,
|
|
2280
|
+
pidAlive: deps.pidAlive,
|
|
2281
|
+
pidIncarnation: deps.pidIncarnation,
|
|
2282
|
+
});
|
|
2283
|
+
if (recovery.recovered) {
|
|
2284
|
+
spawned = await withTelegramSetupLease(
|
|
2285
|
+
cfg.botToken,
|
|
2286
|
+
async () =>
|
|
2287
|
+
await spawnTelegramDaemonOwner(
|
|
2288
|
+
{ settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
|
|
2289
|
+
deps,
|
|
2290
|
+
),
|
|
2291
|
+
);
|
|
2292
|
+
} else recoveryReason = recovery.reason;
|
|
2293
|
+
}
|
|
2066
2294
|
if (spawned.result === "blocked") {
|
|
2067
|
-
logger.warn(
|
|
2295
|
+
logger.warn(
|
|
2296
|
+
`notifications: failed to ensure Telegram daemon: ${recoveryReason ? `stale recovery ${recoveryReason}; run \`gjc notify recovery\`` : spawned.warnings.join("; ")}`,
|
|
2297
|
+
);
|
|
2068
2298
|
return "blocked_identity";
|
|
2069
2299
|
}
|
|
2070
2300
|
if (spawned.result === "attached" && spawned.reloadRequired) {
|
|
@@ -2411,7 +2641,10 @@ export class TelegramUpdatePoller {
|
|
|
2411
2641
|
/** Mutable dispatch state shared by session frames and inbound Telegram updates. */
|
|
2412
2642
|
export class TelegramEventDispatchState {
|
|
2413
2643
|
readonly busy = new Set<string>();
|
|
2414
|
-
readonly inboundReactions = new Map<
|
|
2644
|
+
readonly inboundReactions = new Map<
|
|
2645
|
+
number,
|
|
2646
|
+
{ messageId: number; socketLease?: { session: SessionSocket; token: number; logicalSessionId: string } }
|
|
2647
|
+
>();
|
|
2415
2648
|
readonly seenUpdateIds = new Set<number>();
|
|
2416
2649
|
}
|
|
2417
2650
|
|
|
@@ -2500,11 +2733,16 @@ interface SessionSocket {
|
|
|
2500
2733
|
sessionId: string;
|
|
2501
2734
|
/** Current logical session carried by valid threaded frames on this transport. */
|
|
2502
2735
|
logicalSessionId: string;
|
|
2736
|
+
/** Endpoint metadata proved this logical id belongs to this discovery record. */
|
|
2737
|
+
logicalSessionIdTrusted: boolean;
|
|
2503
2738
|
token: string;
|
|
2504
2739
|
endpointKey: string;
|
|
2505
2740
|
endpointDigest: string;
|
|
2506
2741
|
hostGeneration: number;
|
|
2507
2742
|
ws: WebSocket;
|
|
2743
|
+
/** Timestamp (via opts.now) at which this socket began connecting. */
|
|
2744
|
+
connectingSince: number;
|
|
2745
|
+
|
|
2508
2746
|
pending: Map<string, { sessionId: string; actionId: string }>;
|
|
2509
2747
|
/** True once the server advertised the `client_ping_pong` capability. */
|
|
2510
2748
|
capable: boolean;
|
|
@@ -2520,11 +2758,20 @@ interface SessionSocket {
|
|
|
2520
2758
|
/** Queues live frames until startup replay is applied. */
|
|
2521
2759
|
replayPending: boolean;
|
|
2522
2760
|
replayQueue: Record<string, unknown>[];
|
|
2761
|
+
/** Trusted recovery may not route until its durable endpoint lease is committed. */
|
|
2762
|
+
recoveryLease?: {
|
|
2763
|
+
state: "pending" | "authorized" | "rejected";
|
|
2764
|
+
logicalSessionId: string;
|
|
2765
|
+
binding: TopicEndpointBinding;
|
|
2766
|
+
token: number;
|
|
2767
|
+
};
|
|
2523
2768
|
}
|
|
2524
2769
|
|
|
2525
2770
|
interface ModelChoiceRoute {
|
|
2526
2771
|
/** Exact transport socket that rendered this choice. */
|
|
2527
2772
|
session: SessionSocket;
|
|
2773
|
+
/** Immutable owner lease captured when this choice was rendered. */
|
|
2774
|
+
socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
2528
2775
|
/** Logical session current when this choice was rendered. */
|
|
2529
2776
|
sessionId: string;
|
|
2530
2777
|
selector: string;
|
|
@@ -2555,7 +2802,8 @@ interface ToolActivityOwner {
|
|
|
2555
2802
|
interface PendingThreadedFrame {
|
|
2556
2803
|
send: ThreadedSend;
|
|
2557
2804
|
msg: Record<string, unknown>;
|
|
2558
|
-
|
|
2805
|
+
logicalSessionId: string;
|
|
2806
|
+
socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
2559
2807
|
toolActivity?: ToolActivityOwner;
|
|
2560
2808
|
}
|
|
2561
2809
|
|
|
@@ -2580,6 +2828,8 @@ interface SelectedAckQueueItem {
|
|
|
2580
2828
|
requestId: string;
|
|
2581
2829
|
commitKey: string;
|
|
2582
2830
|
session: SessionSocket;
|
|
2831
|
+
/** Immutable owner lease captured when this acknowledgement was admitted. */
|
|
2832
|
+
socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
2583
2833
|
state: "queued" | "dispatching" | "sending";
|
|
2584
2834
|
controller?: AbortController;
|
|
2585
2835
|
followers: Array<{ pendingKey: string; requestId: string; commitKey: string }>;
|
|
@@ -2588,6 +2838,8 @@ interface SelectedAckQueueItem {
|
|
|
2588
2838
|
interface TelegramQueuePayload {
|
|
2589
2839
|
send: ThreadedSend;
|
|
2590
2840
|
topicLease?: TopicAuthorityLease;
|
|
2841
|
+
/** Immutable owner lease captured when the work was admitted. */
|
|
2842
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
2591
2843
|
selectedAck?: SelectedAckQueueItem;
|
|
2592
2844
|
btwDelivery?: BtwQueuedDelivery;
|
|
2593
2845
|
toolActivity?: ToolActivityOwner;
|
|
@@ -2598,6 +2850,10 @@ interface PendingBtwTurn {
|
|
|
2598
2850
|
transportSessionId: string;
|
|
2599
2851
|
/** Logical session id supplied to the session endpoint. */
|
|
2600
2852
|
logicalSessionId: string;
|
|
2853
|
+
/** Immutable committed owner lease captured when the inbound turn was admitted. */
|
|
2854
|
+
socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
2855
|
+
/** Lease token to which this request was most recently dispatched. */
|
|
2856
|
+
dispatchedSocketLeaseToken?: number;
|
|
2601
2857
|
endpointDigest: string;
|
|
2602
2858
|
generation: number;
|
|
2603
2859
|
question: string;
|
|
@@ -2700,12 +2956,22 @@ export class TelegramNotificationDaemon {
|
|
|
2700
2956
|
private readonly pollConflictBackoff = new OperatorBackoffPolicy({ initialMs: 500, maxMs: 5_000 });
|
|
2701
2957
|
private readonly loopBackoff = new OperatorBackoffPolicy({ initialMs: 250, maxMs: 4_000 });
|
|
2702
2958
|
private running = false;
|
|
2959
|
+
/** Once set, a concurrent startup await can never restore a running daemon. */
|
|
2960
|
+
private stopRequested = false;
|
|
2961
|
+
|
|
2703
2962
|
private readonly fsImpl: TelegramDaemonFs;
|
|
2704
2963
|
private readonly botApi: BotApi;
|
|
2705
2964
|
private readonly effects = new TelegramEffectSupervisor();
|
|
2706
2965
|
private readonly topics = new TopicRegistry();
|
|
2707
2966
|
/** Serializes registry snapshots so an older atomic write cannot overwrite newer rename state. */
|
|
2967
|
+
/** Legacy sockets have no durable token, so explicit teardown is their revocation fence. */
|
|
2968
|
+
private readonly droppedSessions = new WeakSet<SessionSocket>();
|
|
2708
2969
|
private topicsPersistQueue: Promise<void> = Promise.resolve();
|
|
2970
|
+
/** Serializes recovery compare/write/publish claims so competing endpoint migrations cannot durably diverge. */
|
|
2971
|
+
private recoveryBindingClaimQueue: Promise<void> = Promise.resolve();
|
|
2972
|
+
/** Durable compensation fences retry under supervision until persistence succeeds. */
|
|
2973
|
+
private readonly compensationFenceRetries = new Map<string, Promise<void>>();
|
|
2974
|
+
|
|
2709
2975
|
/** Daemon edit attempts that can race an accepted user service message. */
|
|
2710
2976
|
private readonly daemonRenameAttempts = new Map<string, number>();
|
|
2711
2977
|
private readonly selectedAckPending = new Map<string, SelectedAckQueueItem>();
|
|
@@ -2727,10 +2993,17 @@ export class TelegramNotificationDaemon {
|
|
|
2727
2993
|
private readonly draftStream = new DraftStreamState();
|
|
2728
2994
|
/** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
|
|
2729
2995
|
private readonly topicOwnerByIdentity = new Map<string, string>();
|
|
2996
|
+
/** Ephemeral legacy topic owners retained across a same-socket config rekey. */
|
|
2997
|
+
private readonly legacyTopicOwners = new Map<string, SessionSocket>();
|
|
2998
|
+
/** Preserved initiator topics must not route through a rekeyed transport. */
|
|
2999
|
+
private readonly preservedInitiatorTopics = new Set<string>();
|
|
2730
3000
|
/** Non-identity frames held until identity creates the correct thread. */
|
|
2731
3001
|
private readonly pendingThreadedFrames = new Map<string, PendingThreadedFrame[]>();
|
|
2732
|
-
/**
|
|
2733
|
-
private readonly closedEndpointKeys = new Map<string,
|
|
3002
|
+
/** Durable endpoint leases for sessions that already sent an authorized session_closed. */
|
|
3003
|
+
private readonly closedEndpointKeys = new Map<string, TopicEndpointBinding>();
|
|
3004
|
+
/** Exactly one authorized transport may route each recovered logical session. */
|
|
3005
|
+
private readonly logicalSessionOwners = new Map<string, SessionSocket>();
|
|
3006
|
+
private nextSocketLeaseToken = 1;
|
|
2734
3007
|
/** True once the daemon has nudged the user to enable Threaded Mode. */
|
|
2735
3008
|
private threadedFallbackNoticeSent = false;
|
|
2736
3009
|
/** Sessions whose identity header was already sent flat (Threaded Mode off). */
|
|
@@ -2744,7 +3017,10 @@ export class TelegramNotificationDaemon {
|
|
|
2744
3017
|
return this.dispatchState.busy;
|
|
2745
3018
|
}
|
|
2746
3019
|
/** Inbound update id → originating Telegram message, for delivery reactions. */
|
|
2747
|
-
private get inboundReactions(): Map<
|
|
3020
|
+
private get inboundReactions(): Map<
|
|
3021
|
+
number,
|
|
3022
|
+
{ messageId: number; socketLease?: { session: SessionSocket; token: number; logicalSessionId: string } }
|
|
3023
|
+
> {
|
|
2748
3024
|
return this.dispatchState.inboundReactions;
|
|
2749
3025
|
}
|
|
2750
3026
|
/**
|
|
@@ -2780,7 +3056,7 @@ export class TelegramNotificationDaemon {
|
|
|
2780
3056
|
this.selectedAckPending.delete(item.pendingKey);
|
|
2781
3057
|
for (const follower of item.followers) this.selectedAckPending.delete(follower.pendingKey);
|
|
2782
3058
|
this.cacheSelectedAck(item.cacheKey, outcome);
|
|
2783
|
-
if (item.session.ws.readyState === WebSocket.OPEN) {
|
|
3059
|
+
if (this.#leaseTokenAllows(item.socketLease) && item.session.ws.readyState === WebSocket.OPEN) {
|
|
2784
3060
|
for (const result of [{ requestId: item.requestId, commitKey: item.commitKey }, ...item.followers]) {
|
|
2785
3061
|
item.session.ws.send(
|
|
2786
3062
|
JSON.stringify({
|
|
@@ -2800,6 +3076,8 @@ export class TelegramNotificationDaemon {
|
|
|
2800
3076
|
* ~25s getUpdates timeout. Safe to call from a signal handler.
|
|
2801
3077
|
*/
|
|
2802
3078
|
requestStop(_reason?: "reload" | "stop" | "signal"): void {
|
|
3079
|
+
this.stopRequested = true;
|
|
3080
|
+
|
|
2803
3081
|
const toolShutdown = this.beginToolActivityShutdown();
|
|
2804
3082
|
void toolShutdown
|
|
2805
3083
|
.finally(() => {
|
|
@@ -3121,7 +3399,7 @@ export class TelegramNotificationDaemon {
|
|
|
3121
3399
|
|
|
3122
3400
|
const frame = this.buildLifecycleFrame(parsed, updateId ?? Date.now());
|
|
3123
3401
|
const response = await this.submitLifecycleFrame(frame);
|
|
3124
|
-
await reply(this.formatLifecycleResponse(response));
|
|
3402
|
+
await reply(this.formatLifecycleResponse(response, verb));
|
|
3125
3403
|
return true;
|
|
3126
3404
|
}
|
|
3127
3405
|
|
|
@@ -3137,8 +3415,8 @@ export class TelegramNotificationDaemon {
|
|
|
3137
3415
|
}
|
|
3138
3416
|
|
|
3139
3417
|
/** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
|
|
3140
|
-
private formatLifecycleResponse(r: SessionLifecycleResponse): string {
|
|
3141
|
-
return formatLifecycleOutcome(r);
|
|
3418
|
+
private formatLifecycleResponse(r: SessionLifecycleResponse, verb: LifecycleCommandVerb): string {
|
|
3419
|
+
return formatLifecycleOutcome(r, verb);
|
|
3142
3420
|
}
|
|
3143
3421
|
|
|
3144
3422
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
@@ -3220,10 +3498,14 @@ export class TelegramNotificationDaemon {
|
|
|
3220
3498
|
finishImmediately({ status: "failed", reason: "route_missing" });
|
|
3221
3499
|
return;
|
|
3222
3500
|
}
|
|
3223
|
-
const
|
|
3501
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
3502
|
+
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
3503
|
+
if (!socketLease) return;
|
|
3504
|
+
const topicLease = this.topicAuthorityLeaseFromRegistry(logicalSessionId);
|
|
3224
3505
|
if (
|
|
3225
|
-
this.topics.get(
|
|
3226
|
-
(
|
|
3506
|
+
this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
|
|
3507
|
+
this.topics.get(logicalSessionId)?.bindingMalformed ||
|
|
3508
|
+
(mode === "recovery" && (!topicLease || msg.sessionId !== logicalSessionId))
|
|
3227
3509
|
) {
|
|
3228
3510
|
finishImmediately({ status: "failed", reason: "route_missing" });
|
|
3229
3511
|
return;
|
|
@@ -3249,13 +3531,14 @@ export class TelegramNotificationDaemon {
|
|
|
3249
3531
|
requestId,
|
|
3250
3532
|
commitKey,
|
|
3251
3533
|
session,
|
|
3534
|
+
socketLease,
|
|
3252
3535
|
state: "queued",
|
|
3253
3536
|
followers: [],
|
|
3254
3537
|
};
|
|
3255
3538
|
this.selectedAckPending.set(pendingKey, item);
|
|
3256
3539
|
this.submitPool({
|
|
3257
|
-
sessionId: session.sessionId,
|
|
3258
3540
|
lane: "ask",
|
|
3541
|
+
sessionId: logicalSessionId,
|
|
3259
3542
|
itemId: item.itemId,
|
|
3260
3543
|
deadlineAt,
|
|
3261
3544
|
payload: {
|
|
@@ -3332,34 +3615,117 @@ export class TelegramNotificationDaemon {
|
|
|
3332
3615
|
name: "activity",
|
|
3333
3616
|
matches: msg => msg.type === "activity",
|
|
3334
3617
|
handle: async (session, msg) => {
|
|
3618
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
3335
3619
|
if (msg.state === "busy") {
|
|
3336
|
-
this.busy.add(
|
|
3337
|
-
await this.sendTyping(session
|
|
3620
|
+
this.busy.add(logicalSessionId);
|
|
3621
|
+
await this.sendTyping(logicalSessionId, this.#socketLease(session, logicalSessionId));
|
|
3338
3622
|
} else {
|
|
3339
|
-
this.busy.delete(
|
|
3623
|
+
this.busy.delete(logicalSessionId);
|
|
3340
3624
|
}
|
|
3341
3625
|
},
|
|
3342
3626
|
})
|
|
3343
3627
|
.add({
|
|
3344
3628
|
name: "inbound_ack",
|
|
3345
3629
|
matches: msg => msg.type === "inbound_ack" && typeof msg.updateId === "number",
|
|
3346
|
-
handle: async (
|
|
3630
|
+
handle: async (session, msg) => {
|
|
3347
3631
|
const target = this.inboundReactions.get(msg.updateId as number);
|
|
3348
3632
|
if (target && msg.state === "consumed") {
|
|
3349
3633
|
this.inboundReactions.delete(msg.updateId as number);
|
|
3350
|
-
await this.setReaction(
|
|
3634
|
+
await this.setReaction(
|
|
3635
|
+
target.messageId,
|
|
3636
|
+
CONSUMED_REACTION,
|
|
3637
|
+
target.socketLease ?? this.#socketLease(session),
|
|
3638
|
+
);
|
|
3351
3639
|
}
|
|
3352
3640
|
},
|
|
3353
3641
|
})
|
|
3354
3642
|
.add({
|
|
3355
3643
|
name: "session_closed",
|
|
3356
3644
|
matches: msg => msg.type === "session_closed",
|
|
3357
|
-
handle: async session => {
|
|
3358
|
-
this
|
|
3359
|
-
|
|
3645
|
+
handle: async (session, msg) => {
|
|
3646
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
3647
|
+
if (
|
|
3648
|
+
(this.sessions.has(session.sessionId) && this.sessions.get(session.sessionId) !== session) ||
|
|
3649
|
+
typeof msg.sessionId !== "string" ||
|
|
3650
|
+
msg.sessionId !== logicalSessionId ||
|
|
3651
|
+
(session.logicalSessionIdTrusted && (!this.#leaseAllows(session) || session.hostGeneration < 1))
|
|
3652
|
+
)
|
|
3653
|
+
return;
|
|
3654
|
+
const socketLease = session.logicalSessionIdTrusted
|
|
3655
|
+
? this.#socketLease(session, logicalSessionId)
|
|
3656
|
+
: undefined;
|
|
3657
|
+
if (session.logicalSessionIdTrusted && !socketLease) return;
|
|
3658
|
+
this.busy.delete(logicalSessionId);
|
|
3360
3659
|
await this.#terminalizeBtwTurnsForSession(session, true);
|
|
3361
|
-
|
|
3362
|
-
this
|
|
3660
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
3661
|
+
await this.#withRecoveryBindingClaim(async () => {
|
|
3662
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
3663
|
+
|
|
3664
|
+
const closedBinding = this.#endpointBinding(session);
|
|
3665
|
+
const closeTopicAuthority = this.topics.captureDeleteAuthority(logicalSessionId);
|
|
3666
|
+
const previousClosedBinding = this.closedEndpointKeys.get(session.sessionId);
|
|
3667
|
+
await this.#persistTopicMutation(
|
|
3668
|
+
() => {
|
|
3669
|
+
this.closedEndpointKeys.set(session.sessionId, closedBinding);
|
|
3670
|
+
this.topics.beginDelete(logicalSessionId);
|
|
3671
|
+
},
|
|
3672
|
+
() => {
|
|
3673
|
+
this.topics.restoreDeleteAuthority(closeTopicAuthority);
|
|
3674
|
+
if (previousClosedBinding === undefined) this.closedEndpointKeys.delete(session.sessionId);
|
|
3675
|
+
else if (this.closedEndpointKeys.get(session.sessionId) === closedBinding)
|
|
3676
|
+
this.closedEndpointKeys.set(session.sessionId, previousClosedBinding);
|
|
3677
|
+
},
|
|
3678
|
+
);
|
|
3679
|
+
if (
|
|
3680
|
+
socketLease &&
|
|
3681
|
+
(this.sessions.get(session.sessionId) !== session ||
|
|
3682
|
+
session.recoveryLease?.token !== socketLease.token ||
|
|
3683
|
+
session.recoveryLease.state !== "authorized" ||
|
|
3684
|
+
this.logicalSessionOwners.get(logicalSessionId) !== session)
|
|
3685
|
+
) {
|
|
3686
|
+
// A replacement won after the close fence committed. Restore the exact
|
|
3687
|
+
// pre-close authority and remove the predecessor tombstone together.
|
|
3688
|
+
const restoreCloseAuthority = (): Promise<boolean> =>
|
|
3689
|
+
this.#persistTopicMutation(
|
|
3690
|
+
() => {
|
|
3691
|
+
const restored = this.topics.restoreDeleteAuthority(closeTopicAuthority);
|
|
3692
|
+
if (!restored) throw new Error("close authority changed before compensation");
|
|
3693
|
+
this.closedEndpointKeys.delete(session.sessionId);
|
|
3694
|
+
return true;
|
|
3695
|
+
},
|
|
3696
|
+
() => {
|
|
3697
|
+
this.topics.restoreDeleteFence(closeTopicAuthority);
|
|
3698
|
+
this.closedEndpointKeys.set(session.sessionId, closedBinding);
|
|
3699
|
+
},
|
|
3700
|
+
);
|
|
3701
|
+
try {
|
|
3702
|
+
await restoreCloseAuthority();
|
|
3703
|
+
} catch {
|
|
3704
|
+
await restoreCloseAuthority();
|
|
3705
|
+
}
|
|
3706
|
+
return;
|
|
3707
|
+
}
|
|
3708
|
+
const deleteOutcome = await this.deleteTopic(logicalSessionId, socketLease, true);
|
|
3709
|
+
if (
|
|
3710
|
+
deleteOutcome === "pre_dispatch_cancelled" &&
|
|
3711
|
+
socketLease &&
|
|
3712
|
+
!this.#deleteLeaseAllows(socketLease)
|
|
3713
|
+
) {
|
|
3714
|
+
await this.#persistTopicMutation(
|
|
3715
|
+
() => {
|
|
3716
|
+
if (!this.topics.restoreDeleteAuthority(closeTopicAuthority))
|
|
3717
|
+
throw new Error("close authority changed before compensation");
|
|
3718
|
+
this.closedEndpointKeys.delete(session.sessionId);
|
|
3719
|
+
},
|
|
3720
|
+
() => {
|
|
3721
|
+
this.topics.restoreDeleteFence(closeTopicAuthority);
|
|
3722
|
+
this.closedEndpointKeys.set(session.sessionId, closedBinding);
|
|
3723
|
+
},
|
|
3724
|
+
);
|
|
3725
|
+
return;
|
|
3726
|
+
}
|
|
3727
|
+
this.dropSession(session, "session_closed");
|
|
3728
|
+
});
|
|
3363
3729
|
},
|
|
3364
3730
|
});
|
|
3365
3731
|
}
|
|
@@ -3440,7 +3806,27 @@ export class TelegramNotificationDaemon {
|
|
|
3440
3806
|
return true;
|
|
3441
3807
|
}
|
|
3442
3808
|
|
|
3809
|
+
private async releaseSeenUpdateId(updateId: number): Promise<void> {
|
|
3810
|
+
if (!this.dispatchState.seenUpdateIds.has(updateId)) return;
|
|
3811
|
+
const candidate = new Set(this.dispatchState.seenUpdateIds);
|
|
3812
|
+
candidate.delete(updateId);
|
|
3813
|
+
try {
|
|
3814
|
+
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
3815
|
+
await ensureDir(this.fsImpl, paths.dir);
|
|
3816
|
+
await writeJsonAtomic(this.fsImpl, paths.seenUpdates, {
|
|
3817
|
+
version: 1,
|
|
3818
|
+
updateIds: [...candidate],
|
|
3819
|
+
});
|
|
3820
|
+
} catch {
|
|
3821
|
+
logger.warn("notifications: Telegram update state release failed");
|
|
3822
|
+
return;
|
|
3823
|
+
}
|
|
3824
|
+
this.dispatchState.seenUpdateIds.clear();
|
|
3825
|
+
for (const seenId of candidate) this.dispatchState.seenUpdateIds.add(seenId);
|
|
3826
|
+
}
|
|
3827
|
+
|
|
3443
3828
|
async scanRoots(): Promise<void> {
|
|
3829
|
+
await this.reconcilePendingTopicDeletes();
|
|
3444
3830
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
3445
3831
|
const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
|
|
3446
3832
|
const endpointSessionIds = new Set<string>();
|
|
@@ -3463,6 +3849,11 @@ export class TelegramNotificationDaemon {
|
|
|
3463
3849
|
// A hard-killed owner can leave both its endpoint file and socket map
|
|
3464
3850
|
// entry behind; skipping the read in that case permanently preserves
|
|
3465
3851
|
// the stale Telegram topic.
|
|
3852
|
+
const owner = this.logicalSessionOwners.get(sessionId);
|
|
3853
|
+
if (owner && this.#leaseAllows(owner, sessionId)) {
|
|
3854
|
+
if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
|
|
3855
|
+
continue;
|
|
3856
|
+
}
|
|
3466
3857
|
const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
|
|
3467
3858
|
if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) {
|
|
3468
3859
|
const connected = this.sessions.get(sessionId);
|
|
@@ -3471,22 +3862,36 @@ export class TelegramNotificationDaemon {
|
|
|
3471
3862
|
await this.observeOrphanedTopic(sessionId);
|
|
3472
3863
|
continue;
|
|
3473
3864
|
}
|
|
3865
|
+
|
|
3474
3866
|
if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
|
|
3475
3867
|
const endpointKey = endpointGenerationKey(endpoint.url, endpoint.token);
|
|
3476
3868
|
const connected = this.sessions.get(sessionId);
|
|
3477
3869
|
if (connected) {
|
|
3478
|
-
if (
|
|
3870
|
+
if (
|
|
3871
|
+
connected.endpointKey !== endpointKey ||
|
|
3872
|
+
(connected.ws.readyState === WebSocket.CONNECTING &&
|
|
3873
|
+
this.runtime.now() - connected.connectingSince >= CONNECTING_RECONNECT_MS)
|
|
3874
|
+
)
|
|
3479
3875
|
this.connectSession(sessionId, endpoint.url, endpoint.token);
|
|
3480
3876
|
continue;
|
|
3481
3877
|
}
|
|
3482
|
-
|
|
3483
|
-
|
|
3878
|
+
const closed = this.closedEndpointKeys.get(sessionId);
|
|
3879
|
+
if (closed?.endpointKey === endpointKey) continue;
|
|
3880
|
+
if (closed) {
|
|
3881
|
+
this.closedEndpointKeys.delete(sessionId);
|
|
3882
|
+
await this.persistTopics();
|
|
3883
|
+
}
|
|
3484
3884
|
this.connectSession(sessionId, endpoint.url, endpoint.token);
|
|
3485
3885
|
} catch {}
|
|
3486
3886
|
}
|
|
3487
3887
|
}
|
|
3488
3888
|
if (allRootsReadable) {
|
|
3489
3889
|
for (const sessionId of this.topics.sessionIds()) {
|
|
3890
|
+
const owner = this.logicalSessionOwners.get(sessionId);
|
|
3891
|
+
if (owner && this.#leaseAllows(owner)) {
|
|
3892
|
+
if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
|
|
3893
|
+
continue;
|
|
3894
|
+
}
|
|
3490
3895
|
if (!this.sessions.has(sessionId) && !endpointSessionIds.has(sessionId)) {
|
|
3491
3896
|
this.#terminalizeBtwTurnsForTransportSession(sessionId);
|
|
3492
3897
|
await this.observeOrphanedTopic(sessionId);
|
|
@@ -3500,7 +3905,6 @@ export class TelegramNotificationDaemon {
|
|
|
3500
3905
|
const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
|
|
3501
3906
|
const endpointKey = endpointGenerationKey(url, token);
|
|
3502
3907
|
const endpointDigest = endpointAuthorityDigest(url, token);
|
|
3503
|
-
this.closedEndpointKeys.delete(sessionId);
|
|
3504
3908
|
const existing = this.sessions.get(sessionId);
|
|
3505
3909
|
if (existing) {
|
|
3506
3910
|
this.dropSession(
|
|
@@ -3515,12 +3919,13 @@ export class TelegramNotificationDaemon {
|
|
|
3515
3919
|
const session: SessionSocket = {
|
|
3516
3920
|
sessionId,
|
|
3517
3921
|
logicalSessionId: sessionId,
|
|
3518
|
-
|
|
3922
|
+
logicalSessionIdTrusted: false,
|
|
3519
3923
|
token,
|
|
3520
3924
|
endpointKey,
|
|
3521
3925
|
endpointDigest,
|
|
3522
3926
|
hostGeneration: 0,
|
|
3523
3927
|
ws,
|
|
3928
|
+
connectingSince: this.runtime.now(),
|
|
3524
3929
|
pending: new Map(),
|
|
3525
3930
|
capable: false,
|
|
3526
3931
|
ephemeralCapable: false,
|
|
@@ -3532,13 +3937,20 @@ export class TelegramNotificationDaemon {
|
|
|
3532
3937
|
replayQueue: [],
|
|
3533
3938
|
};
|
|
3534
3939
|
this.sessions.set(sessionId, session);
|
|
3940
|
+
if (this.topics.get(sessionId)) this.preservedInitiatorTopics.add(sessionId);
|
|
3941
|
+
|
|
3535
3942
|
// Bidirectional capability advertisement: announce client_ping_pong once the
|
|
3536
3943
|
// socket is open. Sent on "open" only — a real WHATWG WebSocket cannot send
|
|
3537
3944
|
// while CONNECTING — and liveness starts only after a capable ServerHello.
|
|
3538
3945
|
ws.addEventListener("open", () => {
|
|
3946
|
+
if (this.sessions.get(sessionId) !== session) return;
|
|
3539
3947
|
session.replayPending = true;
|
|
3540
3948
|
session.replayQueue = [];
|
|
3541
|
-
|
|
3949
|
+
// Cursors are endpoint-authority scoped. Reusing a prior host's cursor can
|
|
3950
|
+
// skip its identity event when a fresh host restarts at generation 1.
|
|
3951
|
+
const persistedTopic = this.topics.get(sessionId);
|
|
3952
|
+
const replayCursor =
|
|
3953
|
+
persistedTopic?.endpointDigest === session.endpointDigest ? this.topics.replayCursor(sessionId) : undefined;
|
|
3542
3954
|
if (session.ws.readyState === WebSocket.OPEN) {
|
|
3543
3955
|
try {
|
|
3544
3956
|
session.ws.send(
|
|
@@ -3566,12 +3978,20 @@ export class TelegramNotificationDaemon {
|
|
|
3566
3978
|
);
|
|
3567
3979
|
} catch {}
|
|
3568
3980
|
}
|
|
3569
|
-
//
|
|
3570
|
-
//
|
|
3571
|
-
//
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3981
|
+
// Preserve the transport-session contract: ordinary endpoints own a topic
|
|
3982
|
+
// as soon as they connect. A later authenticated logical rekey is the only
|
|
3983
|
+
// path that may recover a different logical session's durable topic.
|
|
3984
|
+
void (async () => {
|
|
3985
|
+
if (this.#logicalSessionId(session) !== sessionId) return;
|
|
3986
|
+
const topicId = await this.ensureTopic(sessionId, this.topicNameFor(sessionId, {}), session);
|
|
3987
|
+
const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
|
|
3988
|
+
if (topicId && topicLease?.topicId === topicId)
|
|
3989
|
+
await this.flushPendingThreadedFrames(sessionId, topicLease);
|
|
3990
|
+
})().catch(err =>
|
|
3991
|
+
logger.warn(
|
|
3992
|
+
`notifications: Telegram initial topic creation failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
|
|
3993
|
+
),
|
|
3994
|
+
);
|
|
3575
3995
|
});
|
|
3576
3996
|
ws.addEventListener("message", ev => {
|
|
3577
3997
|
// Identity guard: a delayed frame from a superseded socket must not act
|
|
@@ -3746,7 +4166,12 @@ export class TelegramNotificationDaemon {
|
|
|
3746
4166
|
}
|
|
3747
4167
|
|
|
3748
4168
|
private dropSession(session: SessionSocket, reason: string): void {
|
|
4169
|
+
// A dropped socket must lose authority before any teardown-triggered asynchronous
|
|
4170
|
+
// work can observe it. Its immutable lease token remains captured by queued work,
|
|
4171
|
+
// but is no longer usable.
|
|
4172
|
+
if (session.recoveryLease) session.recoveryLease = { ...session.recoveryLease, state: "rejected" };
|
|
3749
4173
|
const isCurrentSession = this.sessions.get(session.sessionId) === session;
|
|
4174
|
+
if (isCurrentSession) this.droppedSessions.add(session);
|
|
3750
4175
|
if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
|
|
3751
4176
|
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
3752
4177
|
if (session.pingTimer) {
|
|
@@ -3764,6 +4189,12 @@ export class TelegramNotificationDaemon {
|
|
|
3764
4189
|
if (isCurrentSession) {
|
|
3765
4190
|
this.#clearModelChoiceAliasesForSocket(session);
|
|
3766
4191
|
this.sessions.delete(session.sessionId);
|
|
4192
|
+
for (const [topicSessionId, owner] of this.legacyTopicOwners) {
|
|
4193
|
+
if (owner === session) this.legacyTopicOwners.delete(topicSessionId);
|
|
4194
|
+
}
|
|
4195
|
+
for (const [logicalSessionId, owner] of this.logicalSessionOwners) {
|
|
4196
|
+
if (owner === session) this.logicalSessionOwners.delete(logicalSessionId);
|
|
4197
|
+
}
|
|
3767
4198
|
}
|
|
3768
4199
|
|
|
3769
4200
|
for (const item of new Set(this.selectedAckPending.values())) {
|
|
@@ -3841,6 +4272,7 @@ export class TelegramNotificationDaemon {
|
|
|
3841
4272
|
void this.#terminalizeBtwTurn(requestId, pending).catch(() => undefined);
|
|
3842
4273
|
}
|
|
3843
4274
|
}
|
|
4275
|
+
|
|
3844
4276
|
#resumeBtwTurnsForSession(session: SessionSocket): void {
|
|
3845
4277
|
if (!session.ephemeralCapable || session.hostGeneration < 1 || session.ws.readyState !== WebSocket.OPEN) return;
|
|
3846
4278
|
const logicalSessionId = this.#logicalSessionId(session);
|
|
@@ -3851,6 +4283,7 @@ export class TelegramNotificationDaemon {
|
|
|
3851
4283
|
continue;
|
|
3852
4284
|
}
|
|
3853
4285
|
if (
|
|
4286
|
+
!this.#leaseTokenAllows(pending.socketLease) ||
|
|
3854
4287
|
pending.transportSessionId !== session.sessionId ||
|
|
3855
4288
|
pending.logicalSessionId !== logicalSessionId ||
|
|
3856
4289
|
pending.endpointDigest !== session.endpointDigest ||
|
|
@@ -3861,6 +4294,9 @@ export class TelegramNotificationDaemon {
|
|
|
3861
4294
|
}
|
|
3862
4295
|
}
|
|
3863
4296
|
#sendPendingBtwTurn(session: SessionSocket, requestId: string, pending: PendingBtwTurn): boolean {
|
|
4297
|
+
if (!this.#leaseTokenAllows(pending.socketLease) || this.sessions.get(session.sessionId) !== session)
|
|
4298
|
+
return false;
|
|
4299
|
+
if (pending.dispatchedSocketLeaseToken === pending.socketLease.token) return true;
|
|
3864
4300
|
try {
|
|
3865
4301
|
session.ws.send(
|
|
3866
4302
|
JSON.stringify({
|
|
@@ -3874,6 +4310,7 @@ export class TelegramNotificationDaemon {
|
|
|
3874
4310
|
messageId: pending.messageId,
|
|
3875
4311
|
}),
|
|
3876
4312
|
);
|
|
4313
|
+
pending.dispatchedSocketLeaseToken = pending.socketLease.token;
|
|
3877
4314
|
return true;
|
|
3878
4315
|
} catch {
|
|
3879
4316
|
return false;
|
|
@@ -3931,8 +4368,14 @@ export class TelegramNotificationDaemon {
|
|
|
3931
4368
|
parseMode?: typeof TELEGRAM_PARSE_MODE;
|
|
3932
4369
|
allowWhileStopping?: boolean;
|
|
3933
4370
|
signal?: AbortSignal;
|
|
4371
|
+
isAuthoritative?: () => boolean;
|
|
3934
4372
|
}): Promise<unknown> {
|
|
3935
|
-
if (
|
|
4373
|
+
if (
|
|
4374
|
+
(!input.allowWhileStopping && this.#stoppingBtw) ||
|
|
4375
|
+
this.#btwDeliveryAbort.signal.aborted ||
|
|
4376
|
+
(input.isAuthoritative && !input.isAuthoritative())
|
|
4377
|
+
)
|
|
4378
|
+
return undefined;
|
|
3936
4379
|
const signals = [this.#btwDeliveryAbort.signal, AbortSignal.timeout(30_000)];
|
|
3937
4380
|
if (input.signal) signals.unshift(input.signal);
|
|
3938
4381
|
return this.botApi.call(
|
|
@@ -4065,12 +4508,149 @@ export class TelegramNotificationDaemon {
|
|
|
4065
4508
|
return session.logicalSessionId ?? session.sessionId;
|
|
4066
4509
|
}
|
|
4067
4510
|
|
|
4511
|
+
#leaseAllows(session: SessionSocket, logicalSessionId = this.#logicalSessionId(session)): boolean {
|
|
4512
|
+
if (this.droppedSessions.has(session)) return false;
|
|
4513
|
+
const closedBinding = this.closedEndpointKeys.get(session.sessionId);
|
|
4514
|
+
if (
|
|
4515
|
+
closedBinding &&
|
|
4516
|
+
typeof session.endpointKey === "string" &&
|
|
4517
|
+
typeof session.endpointDigest === "string" &&
|
|
4518
|
+
closedBinding.endpointKey === session.endpointKey &&
|
|
4519
|
+
closedBinding.endpointDigest === session.endpointDigest &&
|
|
4520
|
+
closedBinding.endpointGeneration === session.hostGeneration
|
|
4521
|
+
)
|
|
4522
|
+
return false;
|
|
4523
|
+
if (!session.logicalSessionIdTrusted) return true;
|
|
4524
|
+
// Trusted leases require both the current transport socket and exact logical owner.
|
|
4525
|
+
if (this.sessions.get(session.sessionId) !== session) return false;
|
|
4526
|
+
const lease = session.recoveryLease;
|
|
4527
|
+
if (lease?.state !== "authorized" || lease.logicalSessionId !== logicalSessionId) return false;
|
|
4528
|
+
if (this.logicalSessionOwners.get(logicalSessionId) !== session) return false;
|
|
4529
|
+
const record = this.topics.get(logicalSessionId);
|
|
4530
|
+
return (
|
|
4531
|
+
lease.binding.endpointKey === session.endpointKey &&
|
|
4532
|
+
lease.binding.endpointDigest === session.endpointDigest &&
|
|
4533
|
+
lease.binding.endpointGeneration === session.hostGeneration &&
|
|
4534
|
+
(!record ||
|
|
4535
|
+
(record.authorityState !== "delete_pending" &&
|
|
4536
|
+
!record.bindingMalformed &&
|
|
4537
|
+
record.chatId === lease.binding.chatId &&
|
|
4538
|
+
record.endpointKey === lease.binding.endpointKey &&
|
|
4539
|
+
record.endpointDigest === lease.binding.endpointDigest &&
|
|
4540
|
+
record.endpointGeneration === lease.binding.endpointGeneration))
|
|
4541
|
+
);
|
|
4542
|
+
}
|
|
4543
|
+
|
|
4544
|
+
#leaseTokenAllows(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
|
|
4545
|
+
return socketLease.token === 0
|
|
4546
|
+
? !socketLease.session.logicalSessionIdTrusted && !this.droppedSessions.has(socketLease.session)
|
|
4547
|
+
: this.sessions.get(socketLease.session.sessionId) === socketLease.session &&
|
|
4548
|
+
socketLease.session.recoveryLease?.token === socketLease.token &&
|
|
4549
|
+
this.#leaseAllows(socketLease.session, socketLease.logicalSessionId);
|
|
4550
|
+
}
|
|
4551
|
+
|
|
4552
|
+
#deleteLeaseAllows(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
|
|
4553
|
+
if (socketLease.token === 0)
|
|
4554
|
+
return (
|
|
4555
|
+
this.sessions.get(socketLease.session.sessionId) === socketLease.session &&
|
|
4556
|
+
!this.droppedSessions.has(socketLease.session)
|
|
4557
|
+
);
|
|
4558
|
+
return (
|
|
4559
|
+
this.sessions.get(socketLease.session.sessionId) === socketLease.session &&
|
|
4560
|
+
socketLease.session.recoveryLease?.token === socketLease.token &&
|
|
4561
|
+
socketLease.session.recoveryLease.state === "authorized" &&
|
|
4562
|
+
socketLease.session.recoveryLease.logicalSessionId === socketLease.logicalSessionId &&
|
|
4563
|
+
this.logicalSessionOwners.get(socketLease.logicalSessionId) === socketLease.session
|
|
4564
|
+
);
|
|
4565
|
+
}
|
|
4566
|
+
|
|
4567
|
+
/**
|
|
4568
|
+
* An eager transport-topic create may complete after authenticated replay
|
|
4569
|
+
* authorizes that exact transport as its own logical session. Preserve that
|
|
4570
|
+
* handoff, but never revive a replaced socket or a rekeyed transport.
|
|
4571
|
+
*/
|
|
4572
|
+
#isEagerCreationHandoff(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
|
|
4573
|
+
const { session, logicalSessionId } = socketLease;
|
|
4574
|
+
const lease = session.recoveryLease;
|
|
4575
|
+
return (
|
|
4576
|
+
socketLease.token === 0 &&
|
|
4577
|
+
logicalSessionId === session.sessionId &&
|
|
4578
|
+
session.logicalSessionIdTrusted &&
|
|
4579
|
+
this.sessions.get(session.sessionId) === session &&
|
|
4580
|
+
lease?.state === "authorized" &&
|
|
4581
|
+
lease.logicalSessionId === logicalSessionId &&
|
|
4582
|
+
this.logicalSessionOwners.get(logicalSessionId) === session &&
|
|
4583
|
+
lease.binding.chatId === String(this.opts.chatId) &&
|
|
4584
|
+
lease.binding.endpointKey === session.endpointKey &&
|
|
4585
|
+
lease.binding.endpointDigest === session.endpointDigest &&
|
|
4586
|
+
lease.binding.endpointGeneration === session.hostGeneration
|
|
4587
|
+
);
|
|
4588
|
+
}
|
|
4589
|
+
|
|
4590
|
+
#creationLeaseAllows(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
|
|
4591
|
+
return this.#leaseTokenAllows(socketLease) || this.#isEagerCreationHandoff(socketLease);
|
|
4592
|
+
}
|
|
4593
|
+
|
|
4594
|
+
async #awaitCreationLeaseAuthority(socketLease: {
|
|
4595
|
+
session: SessionSocket;
|
|
4596
|
+
token: number;
|
|
4597
|
+
logicalSessionId: string;
|
|
4598
|
+
}): Promise<boolean> {
|
|
4599
|
+
if (this.#creationLeaseAllows(socketLease)) return true;
|
|
4600
|
+
if (
|
|
4601
|
+
socketLease.token === 0 &&
|
|
4602
|
+
socketLease.session.logicalSessionIdTrusted &&
|
|
4603
|
+
socketLease.session.recoveryLease?.state === "pending" &&
|
|
4604
|
+
this.sessions.get(socketLease.session.sessionId) === socketLease.session
|
|
4605
|
+
)
|
|
4606
|
+
await this.recoveryBindingClaimQueue;
|
|
4607
|
+
return this.#creationLeaseAllows(socketLease);
|
|
4608
|
+
}
|
|
4609
|
+
#socketLease(
|
|
4610
|
+
session: SessionSocket,
|
|
4611
|
+
logicalSessionId = this.#logicalSessionId(session),
|
|
4612
|
+
): { session: SessionSocket; token: number; logicalSessionId: string } | undefined {
|
|
4613
|
+
const lease = session.recoveryLease;
|
|
4614
|
+
return lease && this.#leaseAllows(session, logicalSessionId)
|
|
4615
|
+
? { session, token: lease.token, logicalSessionId }
|
|
4616
|
+
: session.logicalSessionIdTrusted
|
|
4617
|
+
? undefined
|
|
4618
|
+
: { session, token: 0, logicalSessionId };
|
|
4619
|
+
}
|
|
4620
|
+
|
|
4621
|
+
#authorizeLease(session: SessionSocket, logicalSessionId: string, binding: TopicEndpointBinding): void {
|
|
4622
|
+
const previousSessionId = this.#logicalSessionId(session);
|
|
4623
|
+
if (previousSessionId !== logicalSessionId && this.logicalSessionOwners.get(previousSessionId) === session)
|
|
4624
|
+
this.logicalSessionOwners.delete(previousSessionId);
|
|
4625
|
+
const previousOwner = this.logicalSessionOwners.get(logicalSessionId);
|
|
4626
|
+
if (previousOwner && previousOwner !== session && previousOwner.recoveryLease)
|
|
4627
|
+
previousOwner.recoveryLease = { ...previousOwner.recoveryLease, state: "rejected" };
|
|
4628
|
+
this.logicalSessionOwners.set(logicalSessionId, session);
|
|
4629
|
+
this.preservedInitiatorTopics.delete(logicalSessionId);
|
|
4630
|
+
session.logicalSessionId = logicalSessionId;
|
|
4631
|
+
session.recoveryLease = { state: "authorized", logicalSessionId, binding, token: this.nextSocketLeaseToken++ };
|
|
4632
|
+
}
|
|
4633
|
+
|
|
4068
4634
|
#clearModelChoiceAliases(sessionId: string): void {
|
|
4069
4635
|
for (const [alias, route] of this.#modelChoiceAliases) {
|
|
4070
4636
|
if (route.sessionId === sessionId) this.#modelChoiceAliases.delete(alias);
|
|
4071
4637
|
}
|
|
4072
4638
|
}
|
|
4073
4639
|
|
|
4640
|
+
async #revokeAskAuthority(sessionId: string): Promise<void> {
|
|
4641
|
+
for (const [alias, route] of this.aliasTable.entries()) {
|
|
4642
|
+
if (route.sessionId === sessionId) this.aliasTable.delete(alias);
|
|
4643
|
+
}
|
|
4644
|
+
for (const session of this.sessions.values()) {
|
|
4645
|
+
if (session.sessionId === sessionId || this.#logicalSessionId(session) === sessionId) {
|
|
4646
|
+
for (const [actionId, pending] of session.pending) {
|
|
4647
|
+
if (pending.sessionId === sessionId) session.pending.delete(actionId);
|
|
4648
|
+
}
|
|
4649
|
+
}
|
|
4650
|
+
}
|
|
4651
|
+
await this.persistAliases();
|
|
4652
|
+
}
|
|
4653
|
+
|
|
4074
4654
|
#clearModelChoiceAliasesForSocket(session: SessionSocket): void {
|
|
4075
4655
|
for (const [alias, route] of this.#modelChoiceAliases) {
|
|
4076
4656
|
if (route.session === session) this.#modelChoiceAliases.delete(alias);
|
|
@@ -4114,8 +4694,8 @@ export class TelegramNotificationDaemon {
|
|
|
4114
4694
|
"reasoning_summary",
|
|
4115
4695
|
]);
|
|
4116
4696
|
|
|
4117
|
-
/** Rekey
|
|
4118
|
-
#updateLogicalSessionForThreadedFrame(session: SessionSocket, msg: Record<string, unknown>): void {
|
|
4697
|
+
/** Rekey only after authenticated replay, except legacy config updates which are transport-local. */
|
|
4698
|
+
async #updateLogicalSessionForThreadedFrame(session: SessionSocket, msg: Record<string, unknown>): Promise<void> {
|
|
4119
4699
|
if (
|
|
4120
4700
|
typeof msg.type !== "string" ||
|
|
4121
4701
|
!TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type) ||
|
|
@@ -4125,10 +4705,187 @@ export class TelegramNotificationDaemon {
|
|
|
4125
4705
|
(msg.type !== "config_update" && !renderThreadedFrame(msg))
|
|
4126
4706
|
)
|
|
4127
4707
|
return;
|
|
4128
|
-
|
|
4129
|
-
this.#
|
|
4130
|
-
|
|
4131
|
-
|
|
4708
|
+
if (!session.logicalSessionIdTrusted) return;
|
|
4709
|
+
await this.#recoverTopicBinding(session, msg.sessionId, msg.type === "config_update");
|
|
4710
|
+
}
|
|
4711
|
+
|
|
4712
|
+
#endpointBinding(session: SessionSocket): TopicEndpointBinding {
|
|
4713
|
+
return {
|
|
4714
|
+
chatId: String(this.opts.chatId),
|
|
4715
|
+
endpointKey: session.endpointKey,
|
|
4716
|
+
endpointDigest: session.endpointDigest,
|
|
4717
|
+
endpointGeneration: session.hostGeneration,
|
|
4718
|
+
};
|
|
4719
|
+
}
|
|
4720
|
+
|
|
4721
|
+
#endpointAuthority(binding: TopicEndpointBinding, excludedSession?: SessionSocket) {
|
|
4722
|
+
const tombstoned = [...this.closedEndpointKeys.values()].some(
|
|
4723
|
+
closed =>
|
|
4724
|
+
closed.chatId === binding.chatId &&
|
|
4725
|
+
closed.endpointKey === binding.endpointKey &&
|
|
4726
|
+
closed.endpointDigest === binding.endpointDigest,
|
|
4727
|
+
);
|
|
4728
|
+
if (tombstoned) return { state: "ambiguous" as const };
|
|
4729
|
+
const authority = this.topics.endpointAuthority(binding, excludedSession);
|
|
4730
|
+
const competingLiveClaim = [...this.sessions.values()].some(
|
|
4731
|
+
session =>
|
|
4732
|
+
session !== excludedSession &&
|
|
4733
|
+
session.endpointKey === binding.endpointKey &&
|
|
4734
|
+
session.endpointDigest === binding.endpointDigest,
|
|
4735
|
+
);
|
|
4736
|
+
const competingRecoveryClaim = [...this.sessions.values()].some(
|
|
4737
|
+
session =>
|
|
4738
|
+
session !== excludedSession &&
|
|
4739
|
+
session.recoveryLease?.state === "pending" &&
|
|
4740
|
+
session.recoveryLease.binding.chatId === binding.chatId &&
|
|
4741
|
+
session.recoveryLease.binding.endpointKey === binding.endpointKey &&
|
|
4742
|
+
session.recoveryLease.binding.endpointDigest === binding.endpointDigest,
|
|
4743
|
+
);
|
|
4744
|
+
return competingLiveClaim || competingRecoveryClaim ? { state: "ambiguous" as const } : authority;
|
|
4745
|
+
}
|
|
4746
|
+
|
|
4747
|
+
#ownsLiveOpenEndpoint(session: SessionSocket, binding: TopicEndpointBinding): boolean {
|
|
4748
|
+
return (
|
|
4749
|
+
this.sessions.get(session.sessionId) === session &&
|
|
4750
|
+
session.ws.readyState === WebSocket.OPEN &&
|
|
4751
|
+
binding.endpointKey === session.endpointKey &&
|
|
4752
|
+
binding.endpointDigest === session.endpointDigest &&
|
|
4753
|
+
binding.endpointGeneration === session.hostGeneration
|
|
4754
|
+
);
|
|
4755
|
+
}
|
|
4756
|
+
|
|
4757
|
+
#activeEndpointKeysFor(logicalSessionId: string, claimant: SessionSocket): Set<string> {
|
|
4758
|
+
const keys = new Set<string>();
|
|
4759
|
+
for (const session of this.sessions.values()) {
|
|
4760
|
+
if (
|
|
4761
|
+
session !== claimant &&
|
|
4762
|
+
this.#logicalSessionId(session) === logicalSessionId &&
|
|
4763
|
+
this.#leaseAllows(session, logicalSessionId)
|
|
4764
|
+
)
|
|
4765
|
+
keys.add(session.endpointKey);
|
|
4766
|
+
}
|
|
4767
|
+
return keys;
|
|
4768
|
+
}
|
|
4769
|
+
|
|
4770
|
+
#withRecoveryBindingClaim<T>(claim: () => Promise<T>): Promise<T> {
|
|
4771
|
+
const pending = this.recoveryBindingClaimQueue.then(claim, claim);
|
|
4772
|
+
this.recoveryBindingClaimQueue = pending.then(
|
|
4773
|
+
() => undefined,
|
|
4774
|
+
() => undefined,
|
|
4775
|
+
);
|
|
4776
|
+
return pending;
|
|
4777
|
+
}
|
|
4778
|
+
|
|
4779
|
+
async #recoverTopicBinding(
|
|
4780
|
+
session: SessionSocket,
|
|
4781
|
+
candidateSessionId = this.#logicalSessionId(session),
|
|
4782
|
+
preserveTransportTopic = false,
|
|
4783
|
+
allowEndpointRotation = false,
|
|
4784
|
+
identitylessAdmission: "bootstrap" | "resume" | undefined = undefined,
|
|
4785
|
+
): Promise<boolean> {
|
|
4786
|
+
if (!session.logicalSessionIdTrusted) return false;
|
|
4787
|
+
const binding = this.#endpointBinding(session);
|
|
4788
|
+
const pendingToken = this.nextSocketLeaseToken++;
|
|
4789
|
+
session.recoveryLease = { state: "pending", logicalSessionId: candidateSessionId, binding, token: pendingToken };
|
|
4790
|
+
const claim = await this.#withRecoveryBindingClaim(async () => {
|
|
4791
|
+
const existing = this.topics.get(candidateSessionId);
|
|
4792
|
+
const hadDurableTopic = existing !== undefined;
|
|
4793
|
+
const previousBinding = existing
|
|
4794
|
+
? {
|
|
4795
|
+
chatId: existing.chatId,
|
|
4796
|
+
endpointKey: existing.endpointKey,
|
|
4797
|
+
endpointDigest: existing.endpointDigest,
|
|
4798
|
+
endpointGeneration: existing.endpointGeneration,
|
|
4799
|
+
endpointIncarnation: existing.endpointIncarnation,
|
|
4800
|
+
}
|
|
4801
|
+
: undefined;
|
|
4802
|
+
const outcome = await this.#persistTopicMutation(
|
|
4803
|
+
() =>
|
|
4804
|
+
this.topics.get(candidateSessionId)
|
|
4805
|
+
? this.topics.bindEndpoint(
|
|
4806
|
+
candidateSessionId,
|
|
4807
|
+
binding,
|
|
4808
|
+
this.#activeEndpointKeysFor(candidateSessionId, session),
|
|
4809
|
+
allowEndpointRotation,
|
|
4810
|
+
)
|
|
4811
|
+
: "unchanged",
|
|
4812
|
+
() => {
|
|
4813
|
+
if (previousBinding) this.topics.restoreEndpointBinding(candidateSessionId, binding, previousBinding);
|
|
4814
|
+
},
|
|
4815
|
+
).catch(() => "rejected" as const);
|
|
4816
|
+
if (outcome === "rejected") {
|
|
4817
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
4818
|
+
session.recoveryLease = {
|
|
4819
|
+
state: "rejected",
|
|
4820
|
+
logicalSessionId: candidateSessionId,
|
|
4821
|
+
binding,
|
|
4822
|
+
token: pendingToken,
|
|
4823
|
+
};
|
|
4824
|
+
return undefined;
|
|
4825
|
+
}
|
|
4826
|
+
const endpointAuthority = this.#endpointAuthority(binding, session);
|
|
4827
|
+
const identitylessAdmissionAllows =
|
|
4828
|
+
identitylessAdmission === undefined ||
|
|
4829
|
+
(identitylessAdmission === "bootstrap"
|
|
4830
|
+
? (endpointAuthority.state === "none" && !this.topics.get(candidateSessionId)) ||
|
|
4831
|
+
(endpointAuthority.state === "unique" &&
|
|
4832
|
+
endpointAuthority.sessionId === candidateSessionId &&
|
|
4833
|
+
this.topics.matchesEndpoint(candidateSessionId, binding))
|
|
4834
|
+
: endpointAuthority.state === "unique" &&
|
|
4835
|
+
endpointAuthority.sessionId === candidateSessionId &&
|
|
4836
|
+
this.topics.matchesEndpoint(candidateSessionId, binding));
|
|
4837
|
+
if (
|
|
4838
|
+
session.recoveryLease?.token !== pendingToken ||
|
|
4839
|
+
session.recoveryLease.state !== "pending" ||
|
|
4840
|
+
!this.#ownsLiveOpenEndpoint(session, binding) ||
|
|
4841
|
+
!identitylessAdmissionAllows
|
|
4842
|
+
) {
|
|
4843
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
4844
|
+
session.recoveryLease = {
|
|
4845
|
+
state: "rejected",
|
|
4846
|
+
logicalSessionId: candidateSessionId,
|
|
4847
|
+
binding,
|
|
4848
|
+
token: pendingToken,
|
|
4849
|
+
};
|
|
4850
|
+
return undefined;
|
|
4851
|
+
}
|
|
4852
|
+
const previousSessionId = this.#logicalSessionId(session);
|
|
4853
|
+
if (previousSessionId !== candidateSessionId) await this.#terminalizeBtwTurnsForSession(session, true);
|
|
4854
|
+
if (!this.#ownsLiveOpenEndpoint(session, binding)) {
|
|
4855
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
4856
|
+
session.recoveryLease = {
|
|
4857
|
+
state: "rejected",
|
|
4858
|
+
logicalSessionId: candidateSessionId,
|
|
4859
|
+
binding,
|
|
4860
|
+
token: pendingToken,
|
|
4861
|
+
};
|
|
4862
|
+
return undefined;
|
|
4863
|
+
}
|
|
4864
|
+
this.#authorizeLease(session, candidateSessionId, binding);
|
|
4865
|
+
return { previousSessionId, hadDurableTopic };
|
|
4866
|
+
});
|
|
4867
|
+
if (!claim) return false;
|
|
4868
|
+
const { previousSessionId } = claim;
|
|
4869
|
+
if (preserveTransportTopic && previousSessionId !== candidateSessionId) {
|
|
4870
|
+
this.legacyTopicOwners.set(previousSessionId, session);
|
|
4871
|
+
this.preservedInitiatorTopics.add(previousSessionId);
|
|
4872
|
+
}
|
|
4873
|
+
if (candidateSessionId === session.sessionId && !this.topics.get(candidateSessionId))
|
|
4874
|
+
void (async () => {
|
|
4875
|
+
const topicId = await this.ensureTopic(
|
|
4876
|
+
candidateSessionId,
|
|
4877
|
+
this.topicNameFor(candidateSessionId, {}),
|
|
4878
|
+
session,
|
|
4879
|
+
);
|
|
4880
|
+
const topicLease = this.topicAuthorityLeaseFromRegistry(candidateSessionId);
|
|
4881
|
+
if (topicId && topicLease?.topicId === topicId)
|
|
4882
|
+
await this.flushPendingThreadedFrames(candidateSessionId, topicLease);
|
|
4883
|
+
})().catch(err =>
|
|
4884
|
+
logger.warn(
|
|
4885
|
+
`notifications: Telegram recovered topic creation failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
|
|
4886
|
+
),
|
|
4887
|
+
);
|
|
4888
|
+
return true;
|
|
4132
4889
|
}
|
|
4133
4890
|
|
|
4134
4891
|
private topicNameFor(sessionId: string, msg: { title?: unknown; repo?: unknown; branch?: unknown }): string {
|
|
@@ -4210,13 +4967,6 @@ export class TelegramNotificationDaemon {
|
|
|
4210
4967
|
return undefined;
|
|
4211
4968
|
}
|
|
4212
4969
|
|
|
4213
|
-
private sessionCanClaimIdentity(session: SessionSocket, msg: { repo?: unknown; branch?: unknown }): boolean {
|
|
4214
|
-
const current = this.sessions.get(session.sessionId);
|
|
4215
|
-
if (current) return current === session;
|
|
4216
|
-
const ownerId = this.topicOwnerForIdentity(msg);
|
|
4217
|
-
return !ownerId || ownerId === session.sessionId;
|
|
4218
|
-
}
|
|
4219
|
-
|
|
4220
4970
|
private toolActivityOwner(session: SessionSocket, msg: Record<string, unknown>): ToolActivityOwner | undefined {
|
|
4221
4971
|
if (msg.type !== "tool_activity") return undefined;
|
|
4222
4972
|
const toolCallId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
|
|
@@ -4224,7 +4974,7 @@ export class TelegramNotificationDaemon {
|
|
|
4224
4974
|
const phase = typeof msg.phase === "string" ? msg.phase : undefined;
|
|
4225
4975
|
if (!toolCallId || !toolName || !phase) return undefined;
|
|
4226
4976
|
return {
|
|
4227
|
-
sessionId: session
|
|
4977
|
+
sessionId: this.#logicalSessionId(session),
|
|
4228
4978
|
toolCallId,
|
|
4229
4979
|
toolName,
|
|
4230
4980
|
endpointDigest: session.endpointDigest,
|
|
@@ -4235,7 +4985,8 @@ export class TelegramNotificationDaemon {
|
|
|
4235
4985
|
|
|
4236
4986
|
private toolActivityAuthorityIsCurrent(toolActivity: ToolActivityOwner): boolean {
|
|
4237
4987
|
if (this.revokedToolEndpoints.has(toolActivity.endpointDigest)) return false;
|
|
4238
|
-
const session =
|
|
4988
|
+
const session =
|
|
4989
|
+
this.logicalSessionOwners.get(toolActivity.sessionId) ?? this.sessions.get(toolActivity.sessionId);
|
|
4239
4990
|
if (toolActivity.endpointDigest === undefined) return session === undefined;
|
|
4240
4991
|
return session === toolActivity.session && session.endpointDigest === toolActivity.endpointDigest;
|
|
4241
4992
|
}
|
|
@@ -4244,12 +4995,18 @@ export class TelegramNotificationDaemon {
|
|
|
4244
4995
|
send: ThreadedSend,
|
|
4245
4996
|
topicLease: TopicAuthorityLease,
|
|
4246
4997
|
toolActivity?: ToolActivityOwner,
|
|
4998
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
4247
4999
|
): Promise<void> {
|
|
4248
5000
|
this.submitPool({
|
|
4249
5001
|
sessionId,
|
|
4250
5002
|
lane: send.lane,
|
|
4251
5003
|
coalesceKey: send.coalesceKey,
|
|
4252
|
-
payload: {
|
|
5004
|
+
payload: {
|
|
5005
|
+
send,
|
|
5006
|
+
topicLease,
|
|
5007
|
+
...(socketLease ? { socketLease } : {}),
|
|
5008
|
+
...(toolActivity ? { toolActivity } : {}),
|
|
5009
|
+
},
|
|
4253
5010
|
});
|
|
4254
5011
|
await this.flushPool();
|
|
4255
5012
|
}
|
|
@@ -4265,14 +5022,15 @@ export class TelegramNotificationDaemon {
|
|
|
4265
5022
|
|
|
4266
5023
|
private topicAuthorityLeaseFromRegistry(sessionId: string): TopicAuthorityLease | undefined {
|
|
4267
5024
|
const topic = this.topics.get(sessionId);
|
|
4268
|
-
if (!topic ||
|
|
5025
|
+
if (!topic || !this.topics.isActiveUnambiguous(sessionId) || topic.bindingMalformed) return undefined;
|
|
4269
5026
|
return { sessionId, topicId: topic.topicId, authorityEpoch: topic.authorityEpoch ?? 0 };
|
|
4270
5027
|
}
|
|
4271
5028
|
|
|
4272
5029
|
private topicLeaseIsCurrent(lease: TopicAuthorityLease): boolean {
|
|
4273
5030
|
const topic = this.topics.get(lease.sessionId);
|
|
4274
5031
|
return (
|
|
4275
|
-
|
|
5032
|
+
this.topics.isActiveUnambiguous(lease.sessionId) &&
|
|
5033
|
+
!topic?.bindingMalformed &&
|
|
4276
5034
|
topic?.topicId === lease.topicId &&
|
|
4277
5035
|
(topic.authorityEpoch ?? 0) === lease.authorityEpoch
|
|
4278
5036
|
);
|
|
@@ -4317,26 +5075,29 @@ export class TelegramNotificationDaemon {
|
|
|
4317
5075
|
msg: Record<string, unknown>,
|
|
4318
5076
|
toolActivity?: ToolActivityOwner,
|
|
4319
5077
|
): void {
|
|
4320
|
-
const
|
|
5078
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
5079
|
+
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
5080
|
+
if (!socketLease) return;
|
|
5081
|
+
const frames = this.pendingThreadedFrames.get(logicalSessionId) ?? [];
|
|
4321
5082
|
frames.push({
|
|
4322
5083
|
send,
|
|
4323
5084
|
msg,
|
|
4324
|
-
|
|
5085
|
+
logicalSessionId,
|
|
5086
|
+
socketLease,
|
|
4325
5087
|
...(toolActivity ? { toolActivity } : {}),
|
|
4326
5088
|
});
|
|
4327
5089
|
if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
|
|
4328
|
-
this.pendingThreadedFrames.set(
|
|
5090
|
+
this.pendingThreadedFrames.set(logicalSessionId, frames);
|
|
4329
5091
|
}
|
|
4330
5092
|
|
|
4331
5093
|
private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
|
|
4332
5094
|
const frames = this.pendingThreadedFrames.get(sessionId);
|
|
4333
5095
|
if (!frames || frames.length === 0) return;
|
|
4334
5096
|
this.pendingThreadedFrames.delete(sessionId);
|
|
4335
|
-
const session = this.sessions.get(sessionId);
|
|
4336
5097
|
for (const frame of frames) {
|
|
4337
|
-
if (
|
|
5098
|
+
if (frame.logicalSessionId !== sessionId || !this.#leaseTokenAllows(frame.socketLease)) continue;
|
|
4338
5099
|
if (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled === false) continue;
|
|
4339
|
-
await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity);
|
|
5100
|
+
await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, frame.socketLease);
|
|
4340
5101
|
}
|
|
4341
5102
|
}
|
|
4342
5103
|
|
|
@@ -4346,36 +5107,163 @@ export class TelegramNotificationDaemon {
|
|
|
4346
5107
|
* `undefined`; callers then flat-deliver to a private paired chat (with a
|
|
4347
5108
|
* one-time nudge) or drop fail-closed for a non-private chat.
|
|
4348
5109
|
*/
|
|
4349
|
-
private async ensureTopic(
|
|
5110
|
+
private async ensureTopic(
|
|
5111
|
+
sessionId: string,
|
|
5112
|
+
name: string,
|
|
5113
|
+
session?: SessionSocket,
|
|
5114
|
+
creationLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
5115
|
+
): Promise<string | undefined> {
|
|
4350
5116
|
if (!(await this.pairedChatIsPrivate())) return undefined;
|
|
5117
|
+
if (session && sessionId === session.sessionId && this.#logicalSessionId(session) !== sessionId) return undefined;
|
|
5118
|
+
const capturedCreationLease = creationLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
|
|
5119
|
+
if (session?.logicalSessionIdTrusted && !capturedCreationLease) return undefined;
|
|
4351
5120
|
const existing = this.topics.get(sessionId);
|
|
4352
|
-
if (existing?.authorityState === "delete_pending") return undefined;
|
|
5121
|
+
if (existing?.authorityState === "delete_pending" || existing?.bindingMalformed) return undefined;
|
|
4353
5122
|
if (existing) return existing.topicId;
|
|
5123
|
+
if (
|
|
5124
|
+
session &&
|
|
5125
|
+
sessionId !== session.sessionId &&
|
|
5126
|
+
(!session.logicalSessionIdTrusted || sessionId !== this.#logicalSessionId(session))
|
|
5127
|
+
)
|
|
5128
|
+
return undefined;
|
|
5129
|
+
if (capturedCreationLease && !this.#leaseTokenAllows(capturedCreationLease)) return undefined;
|
|
5130
|
+
const creationBinding = session ? this.#endpointBinding(session) : undefined;
|
|
5131
|
+
const creationLeaseEpoch = this.topics.authorityEpoch(sessionId);
|
|
5132
|
+
let acceptedTopicId: string | undefined;
|
|
5133
|
+
let acceptedTopicCompensated = false;
|
|
5134
|
+
let acceptedTopicDeleteAttempted = false;
|
|
4354
5135
|
try {
|
|
4355
5136
|
const rec = await this.topics.getOrCreateTopic(
|
|
4356
5137
|
sessionId,
|
|
4357
5138
|
async () => {
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
});
|
|
5139
|
+
if (capturedCreationLease && !this.#leaseTokenAllows(capturedCreationLease))
|
|
5140
|
+
throw new Error("topic authority was revoked during creation");
|
|
5141
|
+
const res = await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name });
|
|
4362
5142
|
if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
|
|
4363
5143
|
const tid = (res as { result?: { message_thread_id?: unknown } }).result?.message_thread_id;
|
|
4364
5144
|
if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0)
|
|
4365
5145
|
throw new Error("createForumTopic: invalid message_thread_id");
|
|
4366
|
-
|
|
5146
|
+
acceptedTopicId = String(tid);
|
|
5147
|
+
if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
|
|
5148
|
+
if (
|
|
5149
|
+
!this.topics.fenceAcceptedCreateForLease(
|
|
5150
|
+
sessionId,
|
|
5151
|
+
acceptedTopicId,
|
|
5152
|
+
creationLeaseEpoch,
|
|
5153
|
+
this.opts.now,
|
|
5154
|
+
name,
|
|
5155
|
+
creationBinding,
|
|
5156
|
+
)
|
|
5157
|
+
)
|
|
5158
|
+
throw new Error("topic authority was revoked during creation");
|
|
5159
|
+
try {
|
|
5160
|
+
await this.persistTopics();
|
|
5161
|
+
} finally {
|
|
5162
|
+
acceptedTopicDeleteAttempted = true;
|
|
5163
|
+
const deletion = await this.botApi.call("deleteForumTopic", {
|
|
5164
|
+
chat_id: this.opts.chatId,
|
|
5165
|
+
message_thread_id: tid,
|
|
5166
|
+
});
|
|
5167
|
+
acceptedTopicCompensated = topicDeleteSettled(deletion);
|
|
5168
|
+
|
|
5169
|
+
if (topicDeleteSettled(deletion)) {
|
|
5170
|
+
this.topics.settleDelete(sessionId, acceptedTopicId);
|
|
5171
|
+
await this.persistTopics();
|
|
5172
|
+
} else {
|
|
5173
|
+
this.#superviseCompensationFence(sessionId);
|
|
5174
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
5175
|
+
}
|
|
5176
|
+
}
|
|
5177
|
+
throw new Error("topic authority was revoked during creation");
|
|
5178
|
+
}
|
|
5179
|
+
return acceptedTopicId;
|
|
4367
5180
|
},
|
|
4368
5181
|
this.opts.now,
|
|
4369
|
-
// The create winner records the name it actually used; callers that
|
|
4370
|
-
// merely JOIN an in-flight create must not overwrite it locally, or a
|
|
4371
|
-
// later identity rename would be wrongly skipped (topic stuck at the
|
|
4372
|
-
// provisional name on Telegram).
|
|
4373
5182
|
name,
|
|
5183
|
+
creationBinding,
|
|
5184
|
+
() => this.persistTopics(),
|
|
5185
|
+
session,
|
|
4374
5186
|
);
|
|
4375
|
-
|
|
5187
|
+
// getOrCreateTopic deduplicates callers, so an accepted create can be
|
|
5188
|
+
// observed by a successor after its initiating socket was revoked. Check
|
|
5189
|
+
// the immutable lease again before exposing that record to frame delivery.
|
|
5190
|
+
if (capturedCreationLease && this.#isEagerCreationHandoff(capturedCreationLease)) {
|
|
5191
|
+
const binding = this.#endpointBinding(capturedCreationLease.session);
|
|
5192
|
+
if (
|
|
5193
|
+
this.topics.bindEndpoint(
|
|
5194
|
+
sessionId,
|
|
5195
|
+
binding,
|
|
5196
|
+
this.#activeEndpointKeysFor(sessionId, capturedCreationLease.session),
|
|
5197
|
+
true,
|
|
5198
|
+
) !== "unchanged"
|
|
5199
|
+
)
|
|
5200
|
+
await this.persistTopics();
|
|
5201
|
+
}
|
|
5202
|
+
if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
|
|
5203
|
+
if (
|
|
5204
|
+
this.topics.fenceAcceptedCreateForLease(
|
|
5205
|
+
sessionId,
|
|
5206
|
+
rec.topicId,
|
|
5207
|
+
creationLeaseEpoch,
|
|
5208
|
+
this.opts.now,
|
|
5209
|
+
name,
|
|
5210
|
+
creationBinding,
|
|
5211
|
+
)
|
|
5212
|
+
)
|
|
5213
|
+
await this.deleteTopic(sessionId, undefined, true);
|
|
5214
|
+
return undefined;
|
|
5215
|
+
}
|
|
4376
5216
|
return rec.topicId;
|
|
4377
5217
|
} catch (err) {
|
|
4378
5218
|
if (err instanceof ThreadedModeCapabilityRefusal) return undefined;
|
|
5219
|
+
if (
|
|
5220
|
+
acceptedTopicId &&
|
|
5221
|
+
!acceptedTopicCompensated &&
|
|
5222
|
+
!acceptedTopicDeleteAttempted &&
|
|
5223
|
+
this.topics.get(sessionId)?.authorityState !== "delete_pending"
|
|
5224
|
+
) {
|
|
5225
|
+
// A failed initial commit must never make compensation conditional on
|
|
5226
|
+
// successfully publishing its fence.
|
|
5227
|
+
if (
|
|
5228
|
+
this.topics.fenceAcceptedCreateForLease(
|
|
5229
|
+
sessionId,
|
|
5230
|
+
acceptedTopicId,
|
|
5231
|
+
creationLeaseEpoch,
|
|
5232
|
+
this.opts.now,
|
|
5233
|
+
name,
|
|
5234
|
+
creationBinding,
|
|
5235
|
+
)
|
|
5236
|
+
) {
|
|
5237
|
+
try {
|
|
5238
|
+
await this.#persistTopicsWithRetry();
|
|
5239
|
+
} catch {
|
|
5240
|
+
this.#superviseCompensationFence(sessionId);
|
|
5241
|
+
}
|
|
5242
|
+
|
|
5243
|
+
try {
|
|
5244
|
+
const deletion = await this.botApi.call("deleteForumTopic", {
|
|
5245
|
+
chat_id: this.opts.chatId,
|
|
5246
|
+
message_thread_id: Number(acceptedTopicId),
|
|
5247
|
+
});
|
|
5248
|
+
if (topicDeleteSettled(deletion)) {
|
|
5249
|
+
this.topics.settleDelete(sessionId, acceptedTopicId);
|
|
5250
|
+
await this.persistTopics();
|
|
5251
|
+
} else {
|
|
5252
|
+
this.#superviseCompensationFence(sessionId);
|
|
5253
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
5254
|
+
}
|
|
5255
|
+
} catch {
|
|
5256
|
+
this.#superviseCompensationFence(sessionId);
|
|
5257
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
5258
|
+
}
|
|
5259
|
+
}
|
|
5260
|
+
}
|
|
5261
|
+
if (acceptedTopicId && !acceptedTopicCompensated && acceptedTopicDeleteAttempted) {
|
|
5262
|
+
this.#superviseCompensationFence(sessionId);
|
|
5263
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
5264
|
+
}
|
|
5265
|
+
if (session && err instanceof Error && err.message === "topic authority was revoked during creation")
|
|
5266
|
+
return undefined;
|
|
4379
5267
|
logger.warn(
|
|
4380
5268
|
`notifications: Telegram topic creation failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
|
|
4381
5269
|
);
|
|
@@ -4389,24 +5277,39 @@ export class TelegramNotificationDaemon {
|
|
|
4389
5277
|
}
|
|
4390
5278
|
|
|
4391
5279
|
private async observeOrphanedTopic(sessionId: string): Promise<void> {
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
5280
|
+
await this.#withRecoveryBindingClaim(async () => {
|
|
5281
|
+
const owner = this.logicalSessionOwners.get(sessionId);
|
|
5282
|
+
if (owner && this.#leaseAllows(owner, sessionId)) {
|
|
5283
|
+
if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
|
|
5284
|
+
return;
|
|
5285
|
+
}
|
|
5286
|
+
if (this.topics.markOrphaned(sessionId, this.runtime.now())) await this.persistTopics();
|
|
5287
|
+
if (!this.topicPastOrphanGrace(sessionId)) return;
|
|
5288
|
+
const currentOwner = this.logicalSessionOwners.get(sessionId);
|
|
5289
|
+
if (currentOwner && this.#leaseAllows(currentOwner, sessionId)) return;
|
|
5290
|
+
await this.deleteTopic(sessionId);
|
|
5291
|
+
});
|
|
4395
5292
|
}
|
|
4396
5293
|
|
|
4397
5294
|
/** Best-effort delete of a session topic once its local notification endpoint shuts down. */
|
|
4398
|
-
private async deleteTopic(
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
5295
|
+
private async deleteTopic(
|
|
5296
|
+
sessionId: string,
|
|
5297
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
5298
|
+
deleteFenceAlreadyPublished = false,
|
|
5299
|
+
): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> {
|
|
5300
|
+
const deleteSnapshot = this.topics.captureDeleteAuthority(sessionId);
|
|
5301
|
+
let record = deleteFenceAlreadyPublished ? this.topics.get(sessionId) : this.topics.beginDelete(sessionId);
|
|
5302
|
+
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
4402
5303
|
await this.persistTopics();
|
|
5304
|
+
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
5305
|
+
await this.#revokeAskAuthority(sessionId);
|
|
5306
|
+
this.deleteMessageRoutes(sessionId);
|
|
5307
|
+
this.#clearModelChoiceAliases(sessionId);
|
|
4403
5308
|
if (!record) {
|
|
4404
5309
|
await this.topics.awaitInflight(sessionId);
|
|
4405
5310
|
record = this.topics.get(sessionId);
|
|
4406
|
-
// A remotely accepted revoked create installs a delete_pending record while
|
|
4407
|
-
// its caller rejects; durably retain it before compensating remotely.
|
|
4408
5311
|
await this.persistTopics();
|
|
4409
|
-
if (!record) return;
|
|
5312
|
+
if (!record) return "settled";
|
|
4410
5313
|
}
|
|
4411
5314
|
const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
|
|
4412
5315
|
for (const item of removed) {
|
|
@@ -4416,11 +5319,12 @@ export class TelegramNotificationDaemon {
|
|
|
4416
5319
|
}
|
|
4417
5320
|
try {
|
|
4418
5321
|
await this.flushPool();
|
|
5322
|
+
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
4419
5323
|
const res = (await this.botApi.call("deleteForumTopic", {
|
|
4420
5324
|
chat_id: this.opts.chatId,
|
|
4421
5325
|
message_thread_id: Number(record.topicId),
|
|
4422
5326
|
})) as { ok?: boolean };
|
|
4423
|
-
if (!topicDeleteSettled(res)) return;
|
|
5327
|
+
if (!topicDeleteSettled(res)) return "post_dispatch_pending";
|
|
4424
5328
|
this.topics.settleDelete(sessionId, record.topicId);
|
|
4425
5329
|
for (const k of [...this.liveMessages.keys()])
|
|
4426
5330
|
if (k.startsWith(`${sessionId}:`)) {
|
|
@@ -4431,32 +5335,119 @@ export class TelegramNotificationDaemon {
|
|
|
4431
5335
|
if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
|
|
4432
5336
|
});
|
|
4433
5337
|
this.pendingThreadedFrames.delete(sessionId);
|
|
5338
|
+
try {
|
|
5339
|
+
await this.persistTopics();
|
|
5340
|
+
return "settled";
|
|
5341
|
+
} catch {
|
|
5342
|
+
this.topics.restoreDeleteFence(deleteSnapshot);
|
|
5343
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
5344
|
+
return "post_dispatch_pending";
|
|
5345
|
+
}
|
|
5346
|
+
} catch {
|
|
5347
|
+
// Once Telegram dispatch starts, retain the persisted deletion fence: the
|
|
5348
|
+
// remote result is ambiguous and may not restore stale routing authority.
|
|
5349
|
+
return "post_dispatch_pending";
|
|
5350
|
+
}
|
|
5351
|
+
}
|
|
5352
|
+
|
|
5353
|
+
/** Serialize a mutation and its durable snapshot so rollback precedes later writers. */
|
|
5354
|
+
#persistTopicMutation<T>(mutation: () => T, rollback: () => void): Promise<T> {
|
|
5355
|
+
const pending = this.topicsPersistQueue.then(async () => {
|
|
5356
|
+
const result = mutation();
|
|
5357
|
+
try {
|
|
5358
|
+
const snapshot = this.#topicStateForPersistence();
|
|
5359
|
+
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
5360
|
+
await ensureDir(this.fsImpl, paths.dir);
|
|
5361
|
+
await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), snapshot);
|
|
5362
|
+
return result;
|
|
5363
|
+
} catch (error) {
|
|
5364
|
+
rollback();
|
|
5365
|
+
throw error;
|
|
5366
|
+
}
|
|
5367
|
+
});
|
|
5368
|
+
this.topicsPersistQueue = pending.then(
|
|
5369
|
+
() => undefined,
|
|
5370
|
+
() => undefined,
|
|
5371
|
+
);
|
|
5372
|
+
return pending;
|
|
5373
|
+
}
|
|
5374
|
+
|
|
5375
|
+
async #persistTopicsWithRetry(): Promise<void> {
|
|
5376
|
+
try {
|
|
4434
5377
|
await this.persistTopics();
|
|
4435
5378
|
} catch {
|
|
4436
|
-
|
|
5379
|
+
await this.persistTopics();
|
|
4437
5380
|
}
|
|
4438
5381
|
}
|
|
4439
5382
|
|
|
5383
|
+
#superviseCompensationFence(sessionId: string): void {
|
|
5384
|
+
if (this.compensationFenceRetries.has(sessionId)) return;
|
|
5385
|
+
const retry = this.effects.track(
|
|
5386
|
+
(async () => {
|
|
5387
|
+
for (;;) {
|
|
5388
|
+
try {
|
|
5389
|
+
await this.persistTopics();
|
|
5390
|
+
return;
|
|
5391
|
+
} catch {
|
|
5392
|
+
await this.runtime.sleep(250);
|
|
5393
|
+
}
|
|
5394
|
+
}
|
|
5395
|
+
})(),
|
|
5396
|
+
);
|
|
5397
|
+
this.compensationFenceRetries.set(sessionId, retry);
|
|
5398
|
+
void retry.finally(() => this.compensationFenceRetries.delete(sessionId));
|
|
5399
|
+
}
|
|
5400
|
+
|
|
4440
5401
|
private persistTopics(): Promise<void> {
|
|
4441
5402
|
const pending = this.topicsPersistQueue.then(async () => {
|
|
5403
|
+
// Resolve implicit snapshots inside the serialization queue. Callers that
|
|
5404
|
+
// mutate the registry before waiting cannot overwrite a newer authority
|
|
5405
|
+
// binding with an invocation-time snapshot.
|
|
5406
|
+
const snapshot = this.#topicStateForPersistence();
|
|
4442
5407
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
4443
5408
|
await ensureDir(this.fsImpl, paths.dir);
|
|
4444
|
-
await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"),
|
|
5409
|
+
await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), snapshot);
|
|
4445
5410
|
});
|
|
4446
5411
|
this.topicsPersistQueue = pending.catch(() => undefined);
|
|
4447
5412
|
return pending;
|
|
4448
5413
|
}
|
|
4449
5414
|
|
|
5415
|
+
#topicStateForPersistence(): TopicRegistryState {
|
|
5416
|
+
return { ...this.topics.serialize(), closedEndpoints: Object.fromEntries(this.closedEndpointKeys) };
|
|
5417
|
+
}
|
|
5418
|
+
|
|
4450
5419
|
async loadTopics(): Promise<void> {
|
|
4451
5420
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
4452
5421
|
const raw = await readJson<TopicRegistryState>(this.fsImpl, path.join(paths.dir, "telegram-topics.json"));
|
|
4453
5422
|
// Restore the full serialized registry (topicId + identitySent + name) so a
|
|
4454
5423
|
// fresh daemon after reload does not resend identity headers or lose renames.
|
|
4455
|
-
if (raw && typeof raw === "object")
|
|
5424
|
+
if (raw && typeof raw === "object") {
|
|
5425
|
+
this.topics.load(raw);
|
|
5426
|
+
for (const [sessionId, binding] of Object.entries(raw.closedEndpoints ?? {})) {
|
|
5427
|
+
if (
|
|
5428
|
+
binding &&
|
|
5429
|
+
typeof binding.chatId === "string" &&
|
|
5430
|
+
typeof binding.endpointKey === "string" &&
|
|
5431
|
+
typeof binding.endpointDigest === "string" &&
|
|
5432
|
+
(binding.endpointGeneration === undefined ||
|
|
5433
|
+
(Number.isSafeInteger(binding.endpointGeneration) && binding.endpointGeneration >= 0))
|
|
5434
|
+
)
|
|
5435
|
+
this.closedEndpointKeys.set(sessionId, binding);
|
|
5436
|
+
}
|
|
5437
|
+
}
|
|
4456
5438
|
}
|
|
4457
5439
|
|
|
4458
|
-
/**
|
|
4459
|
-
private async
|
|
5440
|
+
/** Retry crash-interrupted topic deletes; only a definite Telegram result clears the durable fence. */
|
|
5441
|
+
private async reconcilePendingTopicDeletes(): Promise<void> {
|
|
5442
|
+
for (const sessionId of this.topics.deletePendingSessionIds()) await this.deleteTopic(sessionId);
|
|
5443
|
+
}
|
|
5444
|
+
|
|
5445
|
+
/** Download one Telegram file with the Bot API's 20 MiB ceiling and one end-to-end deadline. */
|
|
5446
|
+
private async downloadTelegramFile(
|
|
5447
|
+
filePath: string,
|
|
5448
|
+
maxBytes = TELEGRAM_ATTACHMENT_MAX_BYTES,
|
|
5449
|
+
deadlineController?: AbortController,
|
|
5450
|
+
): Promise<TelegramFileDownload> {
|
|
4460
5451
|
const apiBase = this.opts.apiBase ?? "https://api.telegram.org";
|
|
4461
5452
|
const fetchImpl = this.opts.fetchImpl ?? fetch;
|
|
4462
5453
|
// `filePath` is remote metadata from getFile; reject suspicious segments
|
|
@@ -4464,17 +5455,68 @@ export class TelegramNotificationDaemon {
|
|
|
4464
5455
|
// composing the download URL.
|
|
4465
5456
|
if (filePath.includes("..") || filePath.startsWith("/") || filePath.includes("\\")) {
|
|
4466
5457
|
logger.warn("notifications: rejecting suspicious Telegram file_path");
|
|
4467
|
-
return
|
|
5458
|
+
return { failure: "download_failed" };
|
|
4468
5459
|
}
|
|
4469
5460
|
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
|
4470
5461
|
const url = `${apiBase}/file/bot${this.opts.botToken}/${encodedPath}`;
|
|
5462
|
+
const controller = deadlineController ?? new AbortController();
|
|
5463
|
+
let cancelActiveReader: (() => Promise<void>) | undefined;
|
|
5464
|
+
const setTimeoutImpl = this.opts.setTimeoutImpl ?? setTimeout;
|
|
5465
|
+
const clearTimeoutImpl = this.opts.clearTimeoutImpl ?? clearTimeout;
|
|
5466
|
+
const timeout = deadlineController
|
|
5467
|
+
? undefined
|
|
5468
|
+
: setTimeoutImpl(
|
|
5469
|
+
() => controller.abort(new Error("Telegram attachment download timed out")),
|
|
5470
|
+
TELEGRAM_ATTACHMENT_DOWNLOAD_TIMEOUT_MS,
|
|
5471
|
+
);
|
|
5472
|
+
const cancelReader = () => void cancelActiveReader?.();
|
|
5473
|
+
controller.signal.addEventListener("abort", cancelReader, { once: true });
|
|
4471
5474
|
try {
|
|
4472
|
-
const res = await fetchImpl(url);
|
|
4473
|
-
if (!res.ok)
|
|
4474
|
-
|
|
5475
|
+
const res = await fetchImpl(url, { signal: controller.signal });
|
|
5476
|
+
if (!res.ok) {
|
|
5477
|
+
await res.body?.cancel().catch(() => undefined);
|
|
5478
|
+
controller.abort();
|
|
5479
|
+
return { failure: "download_failed" };
|
|
5480
|
+
}
|
|
5481
|
+
const declaredLength = res.headers.get("content-length");
|
|
5482
|
+
if (declaredLength !== null) {
|
|
5483
|
+
const declaredBytes = Number(declaredLength);
|
|
5484
|
+
if (!Number.isSafeInteger(declaredBytes) || declaredBytes < 0 || declaredBytes > maxBytes) {
|
|
5485
|
+
await res.body?.cancel().catch(() => undefined);
|
|
5486
|
+
controller.abort();
|
|
5487
|
+
return { failure: "too_large" };
|
|
5488
|
+
}
|
|
5489
|
+
}
|
|
5490
|
+
if (!res.body) {
|
|
5491
|
+
controller.abort();
|
|
5492
|
+
return { failure: "download_failed" };
|
|
5493
|
+
}
|
|
5494
|
+
const reader = res.body.getReader();
|
|
5495
|
+
cancelActiveReader = () => reader.cancel().catch(() => undefined);
|
|
5496
|
+
controller.signal.throwIfAborted();
|
|
5497
|
+
const chunks: Buffer[] = [];
|
|
5498
|
+
let total = 0;
|
|
5499
|
+
while (true) {
|
|
5500
|
+
const { done, value } = await reader.read();
|
|
5501
|
+
if (done) break;
|
|
5502
|
+
total += value.byteLength;
|
|
5503
|
+
if (total > maxBytes) {
|
|
5504
|
+
await reader.cancel().catch(() => undefined);
|
|
5505
|
+
controller.abort();
|
|
5506
|
+
return { failure: "too_large" };
|
|
5507
|
+
}
|
|
5508
|
+
chunks.push(Buffer.from(value));
|
|
5509
|
+
}
|
|
5510
|
+
controller.signal.throwIfAborted();
|
|
5511
|
+
return { bytes: Buffer.concat(chunks, total) };
|
|
4475
5512
|
} catch (e) {
|
|
5513
|
+
await cancelActiveReader?.();
|
|
5514
|
+
controller.abort();
|
|
4476
5515
|
logger.warn(`notifications: file download failed: ${sanitizeDiagnostic(String(e))}`);
|
|
4477
|
-
return
|
|
5516
|
+
return { failure: "download_failed" };
|
|
5517
|
+
} finally {
|
|
5518
|
+
controller.signal.removeEventListener("abort", cancelReader);
|
|
5519
|
+
if (timeout !== undefined) clearTimeoutImpl(timeout);
|
|
4478
5520
|
}
|
|
4479
5521
|
}
|
|
4480
5522
|
|
|
@@ -4484,6 +5526,8 @@ export class TelegramNotificationDaemon {
|
|
|
4484
5526
|
* removed when the daemon stops (see {@link cleanupAllAttachmentDirs}).
|
|
4485
5527
|
*/
|
|
4486
5528
|
private readonly attachmentDirs = new Map<string, string>();
|
|
5529
|
+
readonly #attachmentUsage = new Map<string, { count: number; bytes: number }>();
|
|
5530
|
+
readonly #attachmentChains = new Map<string, Promise<void>>();
|
|
4487
5531
|
|
|
4488
5532
|
/** Lazily create a private, unguessable 0700 temp dir for `sessionId`. */
|
|
4489
5533
|
private async ensureAttachmentDir(sessionId: string): Promise<string> {
|
|
@@ -4501,6 +5545,8 @@ export class TelegramNotificationDaemon {
|
|
|
4501
5545
|
private async cleanupAllAttachmentDirs(): Promise<void> {
|
|
4502
5546
|
const dirs = [...this.attachmentDirs.values()];
|
|
4503
5547
|
this.attachmentDirs.clear();
|
|
5548
|
+
this.#attachmentUsage.clear();
|
|
5549
|
+
this.#attachmentChains.clear();
|
|
4504
5550
|
await Promise.all(dirs.map(dir => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => undefined)));
|
|
4505
5551
|
}
|
|
4506
5552
|
|
|
@@ -4516,22 +5562,83 @@ export class TelegramNotificationDaemon {
|
|
|
4516
5562
|
private async resolveInboundAttachment(
|
|
4517
5563
|
att: InboundAttachment,
|
|
4518
5564
|
sessionId: string,
|
|
5565
|
+
): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
|
|
5566
|
+
const previous = this.#attachmentChains.get(sessionId) ?? Promise.resolve();
|
|
5567
|
+
const task = previous.then(() => this.resolveInboundAttachmentSerial(att, sessionId));
|
|
5568
|
+
const chain = task.then(
|
|
5569
|
+
() => undefined,
|
|
5570
|
+
() => undefined,
|
|
5571
|
+
);
|
|
5572
|
+
this.#attachmentChains.set(sessionId, chain);
|
|
5573
|
+
return task.finally(() => {
|
|
5574
|
+
if (this.#attachmentChains.get(sessionId) === chain) this.#attachmentChains.delete(sessionId);
|
|
5575
|
+
});
|
|
5576
|
+
}
|
|
5577
|
+
|
|
5578
|
+
private async resolveInboundAttachmentSerial(
|
|
5579
|
+
att: InboundAttachment,
|
|
5580
|
+
sessionId: string,
|
|
4519
5581
|
): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
|
|
4520
5582
|
const images: { data: string; mime?: string }[] = [];
|
|
4521
5583
|
const fileNotes: string[] = [];
|
|
4522
5584
|
const label = att.fileName ?? att.kind;
|
|
5585
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
4523
5586
|
try {
|
|
4524
|
-
const
|
|
4525
|
-
|
|
5587
|
+
const usage = this.#attachmentUsage.get(sessionId) ?? { count: 0, bytes: 0 };
|
|
5588
|
+
if (
|
|
5589
|
+
usage.count >= TELEGRAM_SESSION_ATTACHMENT_MAX_COUNT ||
|
|
5590
|
+
usage.bytes >= TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES
|
|
5591
|
+
) {
|
|
5592
|
+
fileNotes.push(`[attachment rejected: ${label}; session attachment limit reached]`);
|
|
5593
|
+
return { images, fileNotes };
|
|
5594
|
+
}
|
|
5595
|
+
const controller = new AbortController();
|
|
5596
|
+
timeout = (this.opts.setTimeoutImpl ?? setTimeout)(
|
|
5597
|
+
() => controller.abort(new Error("Telegram attachment download timed out")),
|
|
5598
|
+
TELEGRAM_ATTACHMENT_DOWNLOAD_TIMEOUT_MS,
|
|
5599
|
+
);
|
|
5600
|
+
const got = (await this.botApi.call("getFile", { file_id: att.fileId }, { signal: controller.signal })) as {
|
|
5601
|
+
result?: { file_path?: unknown; file_size?: unknown };
|
|
4526
5602
|
};
|
|
4527
5603
|
const filePath = typeof got?.result?.file_path === "string" ? got.result.file_path : undefined;
|
|
4528
5604
|
if (!filePath) {
|
|
4529
5605
|
fileNotes.push(`[attachment unavailable: ${label}]`);
|
|
4530
5606
|
return { images, fileNotes };
|
|
4531
5607
|
}
|
|
4532
|
-
const
|
|
4533
|
-
|
|
4534
|
-
|
|
5608
|
+
const declaredBytes = got.result?.file_size;
|
|
5609
|
+
const remainingBytes = TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES - usage.bytes;
|
|
5610
|
+
if (
|
|
5611
|
+
(typeof declaredBytes === "number" &&
|
|
5612
|
+
(!Number.isSafeInteger(declaredBytes) ||
|
|
5613
|
+
declaredBytes < 0 ||
|
|
5614
|
+
declaredBytes > TELEGRAM_ATTACHMENT_MAX_BYTES ||
|
|
5615
|
+
declaredBytes > remainingBytes)) ||
|
|
5616
|
+
remainingBytes <= 0
|
|
5617
|
+
) {
|
|
5618
|
+
fileNotes.push(`[attachment rejected: ${label}; size limit exceeded]`);
|
|
5619
|
+
return { images, fileNotes };
|
|
5620
|
+
}
|
|
5621
|
+
const downloaded = await this.downloadTelegramFile(
|
|
5622
|
+
filePath,
|
|
5623
|
+
Math.min(TELEGRAM_ATTACHMENT_MAX_BYTES, remainingBytes),
|
|
5624
|
+
controller,
|
|
5625
|
+
);
|
|
5626
|
+
if ("failure" in downloaded) {
|
|
5627
|
+
fileNotes.push(
|
|
5628
|
+
downloaded.failure === "too_large"
|
|
5629
|
+
? `[attachment rejected: ${label}; size limit exceeded]`
|
|
5630
|
+
: `[attachment download failed: ${label}]`,
|
|
5631
|
+
);
|
|
5632
|
+
return { images, fileNotes };
|
|
5633
|
+
}
|
|
5634
|
+
const bytes = downloaded.bytes;
|
|
5635
|
+
const accountedBytes = Math.max(bytes.byteLength, typeof declaredBytes === "number" ? declaredBytes : 0);
|
|
5636
|
+
const current = this.#attachmentUsage.get(sessionId) ?? { count: 0, bytes: 0 };
|
|
5637
|
+
if (
|
|
5638
|
+
current.count >= TELEGRAM_SESSION_ATTACHMENT_MAX_COUNT ||
|
|
5639
|
+
current.bytes + accountedBytes > TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES
|
|
5640
|
+
) {
|
|
5641
|
+
fileNotes.push(`[attachment rejected: ${label}; session attachment limit reached]`);
|
|
4535
5642
|
return { images, fileNotes };
|
|
4536
5643
|
}
|
|
4537
5644
|
const isImage = att.kind === "photo" || (typeof att.mime === "string" && att.mime.startsWith("image/"));
|
|
@@ -4548,12 +5655,20 @@ export class TelegramNotificationDaemon {
|
|
|
4548
5655
|
// Unguessable, non-colliding name inside the private 0700 dir; the
|
|
4549
5656
|
// exclusive 0600 create (`wx`) refuses to follow a pre-existing file/symlink.
|
|
4550
5657
|
const dest = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safeBase}`);
|
|
4551
|
-
|
|
5658
|
+
try {
|
|
5659
|
+
await fs.promises.writeFile(dest, bytes, { flag: "wx", mode: 0o600 });
|
|
5660
|
+
} catch (e) {
|
|
5661
|
+
await fs.promises.unlink(dest).catch(() => undefined);
|
|
5662
|
+
throw e;
|
|
5663
|
+
}
|
|
4552
5664
|
fileNotes.push(`[user attached a file, saved to ${dest}${att.mime ? ` (${att.mime})` : ""}]`);
|
|
4553
5665
|
}
|
|
5666
|
+
this.#attachmentUsage.set(sessionId, { count: current.count + 1, bytes: current.bytes + accountedBytes });
|
|
4554
5667
|
} catch (e) {
|
|
4555
5668
|
logger.warn(`notifications: inbound attachment failed: ${String(e)}`);
|
|
4556
5669
|
fileNotes.push(`[attachment error: ${label}]`);
|
|
5670
|
+
} finally {
|
|
5671
|
+
if (timeout !== undefined) (this.opts.clearTimeoutImpl ?? clearTimeout)(timeout);
|
|
4557
5672
|
}
|
|
4558
5673
|
return { images, fileNotes };
|
|
4559
5674
|
}
|
|
@@ -4659,7 +5774,9 @@ export class TelegramNotificationDaemon {
|
|
|
4659
5774
|
const controller = new AbortController();
|
|
4660
5775
|
selectedAck.controller = controller;
|
|
4661
5776
|
const routeAvailable =
|
|
4662
|
-
(
|
|
5777
|
+
this.#leaseTokenAllows(selectedAck.socketLease) &&
|
|
5778
|
+
(await this.pairedChatIsPrivate()) &&
|
|
5779
|
+
(!topicLease || this.topicLeaseIsCurrent(topicLease));
|
|
4663
5780
|
if (this.selectedAckPending.get(selectedAck.pendingKey) !== selectedAck) {
|
|
4664
5781
|
this.pool.settle(item.itemId!, "removed");
|
|
4665
5782
|
continue;
|
|
@@ -4681,6 +5798,14 @@ export class TelegramNotificationDaemon {
|
|
|
4681
5798
|
Math.min(8_000, remaining),
|
|
4682
5799
|
);
|
|
4683
5800
|
try {
|
|
5801
|
+
if (
|
|
5802
|
+
!this.#leaseTokenAllows(selectedAck.socketLease) ||
|
|
5803
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
5804
|
+
) {
|
|
5805
|
+
this.finishSelectedAck(selectedAck, { status: "failed", reason: "route_missing" });
|
|
5806
|
+
this.pool.settle(item.itemId!, "rejected");
|
|
5807
|
+
continue;
|
|
5808
|
+
}
|
|
4684
5809
|
const response = (await this.botApi.call(
|
|
4685
5810
|
"sendMessage",
|
|
4686
5811
|
{
|
|
@@ -4705,8 +5830,11 @@ export class TelegramNotificationDaemon {
|
|
|
4705
5830
|
}
|
|
4706
5831
|
continue;
|
|
4707
5832
|
}
|
|
4708
|
-
const { send, topicLease } = item.payload;
|
|
4709
|
-
if (
|
|
5833
|
+
const { send, topicLease, socketLease } = item.payload;
|
|
5834
|
+
if (
|
|
5835
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
5836
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
5837
|
+
) {
|
|
4710
5838
|
this.pool.settle(item.itemId!, "rejected");
|
|
4711
5839
|
continue;
|
|
4712
5840
|
}
|
|
@@ -4715,7 +5843,10 @@ export class TelegramNotificationDaemon {
|
|
|
4715
5843
|
this.pool.settle(item.itemId!, "rejected");
|
|
4716
5844
|
continue;
|
|
4717
5845
|
}
|
|
4718
|
-
if (
|
|
5846
|
+
if (
|
|
5847
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
5848
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
5849
|
+
) {
|
|
4719
5850
|
this.pool.settle(item.itemId!, "rejected");
|
|
4720
5851
|
continue;
|
|
4721
5852
|
}
|
|
@@ -4760,7 +5891,10 @@ export class TelegramNotificationDaemon {
|
|
|
4760
5891
|
}
|
|
4761
5892
|
}
|
|
4762
5893
|
}
|
|
4763
|
-
if (
|
|
5894
|
+
if (
|
|
5895
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
5896
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
5897
|
+
) {
|
|
4764
5898
|
disposition = "rejected";
|
|
4765
5899
|
continue;
|
|
4766
5900
|
}
|
|
@@ -4800,7 +5934,11 @@ export class TelegramNotificationDaemon {
|
|
|
4800
5934
|
// non-editable, HTML-only pool items (rich markers stripped) — same
|
|
4801
5935
|
// per-token discipline as the non-rich split path.
|
|
4802
5936
|
const chunks = splitTelegramHtml(send.text!);
|
|
4803
|
-
if (
|
|
5937
|
+
if (
|
|
5938
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
5939
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
5940
|
+
)
|
|
5941
|
+
return;
|
|
4804
5942
|
await this.botApi.call("sendMessage", {
|
|
4805
5943
|
chat_id: this.opts.chatId,
|
|
4806
5944
|
...threadField,
|
|
@@ -4825,6 +5963,7 @@ export class TelegramNotificationDaemon {
|
|
|
4825
5963
|
richClass: undefined,
|
|
4826
5964
|
},
|
|
4827
5965
|
topicLease,
|
|
5966
|
+
socketLease,
|
|
4828
5967
|
},
|
|
4829
5968
|
});
|
|
4830
5969
|
}
|
|
@@ -4859,6 +5998,11 @@ export class TelegramNotificationDaemon {
|
|
|
4859
5998
|
// transport error) resends so the first chunk is never lost.
|
|
4860
5999
|
let edited = false;
|
|
4861
6000
|
try {
|
|
6001
|
+
if (
|
|
6002
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
6003
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6004
|
+
)
|
|
6005
|
+
return;
|
|
4862
6006
|
const res = (await this.botApi.call("editMessageText", {
|
|
4863
6007
|
chat_id: this.opts.chatId,
|
|
4864
6008
|
message_id: existingId,
|
|
@@ -4871,7 +6015,10 @@ export class TelegramNotificationDaemon {
|
|
|
4871
6015
|
}
|
|
4872
6016
|
if (edited) {
|
|
4873
6017
|
firstMessageId = existingId;
|
|
4874
|
-
} else if (
|
|
6018
|
+
} else if (
|
|
6019
|
+
(!socketLease || this.#leaseTokenAllows(socketLease)) &&
|
|
6020
|
+
(!topicLease || this.topicLeaseIsCurrent(topicLease))
|
|
6021
|
+
) {
|
|
4875
6022
|
const res = (await this.botApi.call("sendMessage", {
|
|
4876
6023
|
chat_id: this.opts.chatId,
|
|
4877
6024
|
...threadField,
|
|
@@ -4883,6 +6030,11 @@ export class TelegramNotificationDaemon {
|
|
|
4883
6030
|
} else {
|
|
4884
6031
|
// No streamed message to edit: a single granted slot maps to a
|
|
4885
6032
|
// single Telegram send.
|
|
6033
|
+
if (
|
|
6034
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
6035
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6036
|
+
)
|
|
6037
|
+
return;
|
|
4886
6038
|
const res = (await this.botApi.call("sendMessage", {
|
|
4887
6039
|
chat_id: this.opts.chatId,
|
|
4888
6040
|
...threadField,
|
|
@@ -4916,6 +6068,7 @@ export class TelegramNotificationDaemon {
|
|
|
4916
6068
|
richClass: undefined,
|
|
4917
6069
|
},
|
|
4918
6070
|
topicLease,
|
|
6071
|
+
socketLease,
|
|
4919
6072
|
},
|
|
4920
6073
|
});
|
|
4921
6074
|
}
|
|
@@ -4993,17 +6146,21 @@ export class TelegramNotificationDaemon {
|
|
|
4993
6146
|
sessionId: string,
|
|
4994
6147
|
send: ThreadedSend,
|
|
4995
6148
|
toolActivity?: ToolActivityOwner,
|
|
6149
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
4996
6150
|
): Promise<void> {
|
|
4997
|
-
if (!(await this.pairedChatIsPrivate())) return;
|
|
4998
|
-
|
|
6151
|
+
if ((socketLease && !this.#leaseTokenAllows(socketLease)) || !(await this.pairedChatIsPrivate())) return;
|
|
6152
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
6153
|
+
await this.notifyThreadedFallback(socketLease);
|
|
6154
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
4999
6155
|
if (send.identity && this.flatIdentitySent.has(sessionId)) return;
|
|
5000
6156
|
this.submitPool({
|
|
5001
6157
|
sessionId,
|
|
5002
6158
|
lane: send.lane,
|
|
5003
6159
|
coalesceKey: send.coalesceKey,
|
|
5004
|
-
payload: { send, ...(toolActivity ? { toolActivity } : {}) },
|
|
6160
|
+
payload: { send, ...(socketLease ? { socketLease } : {}), ...(toolActivity ? { toolActivity } : {}) },
|
|
5005
6161
|
});
|
|
5006
6162
|
await this.flushPool();
|
|
6163
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
5007
6164
|
if (send.identity) this.flatIdentitySent.add(sessionId);
|
|
5008
6165
|
}
|
|
5009
6166
|
|
|
@@ -5046,10 +6203,21 @@ export class TelegramNotificationDaemon {
|
|
|
5046
6203
|
}
|
|
5047
6204
|
|
|
5048
6205
|
/** Tell the user once (per daemon run) how to enable Threaded Mode. */
|
|
5049
|
-
private async notifyThreadedFallback(
|
|
5050
|
-
|
|
6206
|
+
private async notifyThreadedFallback(socketLease?: {
|
|
6207
|
+
session: SessionSocket;
|
|
6208
|
+
token: number;
|
|
6209
|
+
logicalSessionId: string;
|
|
6210
|
+
}): Promise<void> {
|
|
6211
|
+
if (
|
|
6212
|
+
this.threadedFallbackNoticeSent ||
|
|
6213
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
6214
|
+
!(await this.pairedChatIsPrivate())
|
|
6215
|
+
)
|
|
6216
|
+
return;
|
|
6217
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
5051
6218
|
this.threadedFallbackNoticeSent = true;
|
|
5052
6219
|
try {
|
|
6220
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
5053
6221
|
await this.botApi.call("sendMessage", {
|
|
5054
6222
|
chat_id: this.opts.chatId,
|
|
5055
6223
|
text: "Flat Telegram private chat supports outbound notifications and inline ask buttons only. Enable Threaded Mode in @BotFather > Bot Settings > Threads Settings for free-text replies and session commands.",
|
|
@@ -5124,14 +6292,20 @@ export class TelegramNotificationDaemon {
|
|
|
5124
6292
|
}
|
|
5125
6293
|
|
|
5126
6294
|
/** Send a single `typing` chat action into a busy session's topic (best-effort). */
|
|
5127
|
-
private async sendTyping(
|
|
6295
|
+
private async sendTyping(
|
|
6296
|
+
sessionId: string,
|
|
6297
|
+
capturedLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
6298
|
+
): Promise<void> {
|
|
6299
|
+
const session = this.logicalSessionOwners.get(sessionId) ?? this.sessions.get(sessionId);
|
|
6300
|
+
const socketLease = capturedLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
|
|
6301
|
+
if (!socketLease) return;
|
|
5128
6302
|
const topicLease = await this.topicAuthorityLease(sessionId);
|
|
5129
|
-
if (!topicLease || !this.topicLeaseIsCurrent(topicLease)) return;
|
|
5130
|
-
const topicId = topicLease.topicId;
|
|
6303
|
+
if (!topicLease || !this.topicLeaseIsCurrent(topicLease) || !this.#leaseTokenAllows(socketLease)) return;
|
|
5131
6304
|
try {
|
|
6305
|
+
if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return;
|
|
5132
6306
|
await this.botApi.call("sendChatAction", {
|
|
5133
6307
|
chat_id: this.opts.chatId,
|
|
5134
|
-
message_thread_id: Number(topicId),
|
|
6308
|
+
message_thread_id: Number(topicLease.topicId),
|
|
5135
6309
|
action: "typing",
|
|
5136
6310
|
});
|
|
5137
6311
|
} catch {
|
|
@@ -5140,9 +6314,14 @@ export class TelegramNotificationDaemon {
|
|
|
5140
6314
|
}
|
|
5141
6315
|
|
|
5142
6316
|
/** Set a native reaction on an inbound thread message (best-effort). */
|
|
5143
|
-
private async setReaction(
|
|
5144
|
-
|
|
6317
|
+
private async setReaction(
|
|
6318
|
+
messageId: number,
|
|
6319
|
+
emoji: string,
|
|
6320
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
6321
|
+
): Promise<void> {
|
|
6322
|
+
if ((socketLease && !this.#leaseTokenAllows(socketLease)) || !(await this.pairedChatIsPrivate())) return;
|
|
5145
6323
|
try {
|
|
6324
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
5146
6325
|
await this.botApi.call("setMessageReaction", {
|
|
5147
6326
|
chat_id: this.opts.chatId,
|
|
5148
6327
|
message_id: messageId,
|
|
@@ -5166,7 +6345,10 @@ export class TelegramNotificationDaemon {
|
|
|
5166
6345
|
|
|
5167
6346
|
/** Render successful `/model` lists as session-bound, one-shot inline choices. */
|
|
5168
6347
|
async #renderModelChoices(session: SessionSocket, msg: Record<string, unknown>): Promise<boolean> {
|
|
5169
|
-
const logicalSessionId =
|
|
6348
|
+
const logicalSessionId =
|
|
6349
|
+
!session.logicalSessionIdTrusted && typeof msg.sessionId === "string" && msg.sessionId.trim()
|
|
6350
|
+
? msg.sessionId
|
|
6351
|
+
: this.#logicalSessionId(session);
|
|
5170
6352
|
if (
|
|
5171
6353
|
msg.type !== "control_command_result" ||
|
|
5172
6354
|
msg.status !== "ok" ||
|
|
@@ -5175,6 +6357,8 @@ export class TelegramNotificationDaemon {
|
|
|
5175
6357
|
this.sessions.get(session.sessionId) !== session
|
|
5176
6358
|
)
|
|
5177
6359
|
return false;
|
|
6360
|
+
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
6361
|
+
if (!socketLease) return false;
|
|
5178
6362
|
|
|
5179
6363
|
const choices: RenderedModelChoice[] = [];
|
|
5180
6364
|
for (const choice of msg.modelChoices) {
|
|
@@ -5189,21 +6373,27 @@ export class TelegramNotificationDaemon {
|
|
|
5189
6373
|
const rendered = renderThreadedFrame({ ...msg, type: "control_command_result" });
|
|
5190
6374
|
if (!rendered?.text) return false;
|
|
5191
6375
|
const topicId =
|
|
5192
|
-
(await this.existingTopicForPrivateChat(
|
|
5193
|
-
(await this.ensureTopic(
|
|
5194
|
-
|
|
6376
|
+
(await this.existingTopicForPrivateChat(logicalSessionId)) ??
|
|
6377
|
+
(await this.ensureTopic(
|
|
6378
|
+
logicalSessionId,
|
|
6379
|
+
this.topicNameFor(logicalSessionId, msg),
|
|
6380
|
+
session.logicalSessionIdTrusted ? session : undefined,
|
|
6381
|
+
socketLease,
|
|
6382
|
+
));
|
|
6383
|
+
const topicLease = await this.topicAuthorityLease(logicalSessionId);
|
|
5195
6384
|
if (!topicId || !topicLease || topicLease.topicId !== topicId) return false;
|
|
6385
|
+
if (!session.logicalSessionIdTrusted) this.legacyTopicOwners.set(logicalSessionId, session);
|
|
5196
6386
|
|
|
5197
6387
|
// Each logical session owns only its most recently rendered menu.
|
|
5198
6388
|
this.#clearModelChoiceAliases(logicalSessionId);
|
|
5199
6389
|
const aliases = choices.map(choice =>
|
|
5200
|
-
this.#putModelChoiceAlias({ session, sessionId: logicalSessionId, selector: choice.selector }),
|
|
6390
|
+
this.#putModelChoiceAlias({ session, socketLease, sessionId: logicalSessionId, selector: choice.selector }),
|
|
5201
6391
|
);
|
|
5202
6392
|
const inline_keyboard = buildButtonGrid(
|
|
5203
6393
|
choices.map(choice => choice.label),
|
|
5204
6394
|
index => aliases[index]!,
|
|
5205
6395
|
);
|
|
5206
|
-
if (!this.topicLeaseIsCurrent(topicLease)) return false;
|
|
6396
|
+
if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return false;
|
|
5207
6397
|
try {
|
|
5208
6398
|
const response = await this.botApi.call("sendMessage", {
|
|
5209
6399
|
chat_id: this.opts.chatId,
|
|
@@ -5226,6 +6416,18 @@ export class TelegramNotificationDaemon {
|
|
|
5226
6416
|
}
|
|
5227
6417
|
|
|
5228
6418
|
async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
|
|
6419
|
+
if (msg?.type === "hello") {
|
|
6420
|
+
const capabilities = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
6421
|
+
if (capabilities.includes("ephemeral_turn_v1")) {
|
|
6422
|
+
session.ephemeralCapable = true;
|
|
6423
|
+
this.#resumeBtwTurnsForSession(session);
|
|
6424
|
+
}
|
|
6425
|
+
if (capabilities.includes(CLIENT_PING_PONG_CAPABILITY)) {
|
|
6426
|
+
session.capable = true;
|
|
6427
|
+
this.startLiveness(session);
|
|
6428
|
+
}
|
|
6429
|
+
return;
|
|
6430
|
+
}
|
|
5229
6431
|
if (session.replayPending) {
|
|
5230
6432
|
if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled === false || this.toolActivityStopping))
|
|
5231
6433
|
return;
|
|
@@ -5236,34 +6438,100 @@ export class TelegramNotificationDaemon {
|
|
|
5236
6438
|
session.replayQueue.push(frame);
|
|
5237
6439
|
return;
|
|
5238
6440
|
}
|
|
5239
|
-
session.replayPending = false;
|
|
5240
6441
|
const replayValid =
|
|
6442
|
+
msg.ok === true &&
|
|
5241
6443
|
Number.isSafeInteger(msg.generation) &&
|
|
5242
6444
|
msg.generation >= 1 &&
|
|
5243
6445
|
Number.isSafeInteger(msg.lastSeq) &&
|
|
5244
6446
|
msg.lastSeq >= 0 &&
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
}
|
|
6447
|
+
msg.gap === undefined &&
|
|
6448
|
+
Array.isArray(msg.events) &&
|
|
6449
|
+
msg.events.every((event: unknown) => event !== null && typeof event === "object" && !Array.isArray(event));
|
|
5249
6450
|
const replayed: Record<string, unknown>[] = replayValid
|
|
5250
|
-
? (msg.events as
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
payload && typeof payload === "object" && !Array.isArray(payload)
|
|
5256
|
-
? (payload as Record<string, unknown>)
|
|
5257
|
-
: envelope,
|
|
5258
|
-
];
|
|
6451
|
+
? (msg.events as Record<string, unknown>[]).map(event => {
|
|
6452
|
+
const payload = event.payload;
|
|
6453
|
+
return payload && typeof payload === "object" && !Array.isArray(payload)
|
|
6454
|
+
? (payload as Record<string, unknown>)
|
|
6455
|
+
: event;
|
|
5259
6456
|
})
|
|
5260
6457
|
: [];
|
|
6458
|
+
const identityFrames = replayed.filter(frame => frame.type === "identity_header");
|
|
6459
|
+
const identities = identityFrames.flatMap(frame =>
|
|
6460
|
+
typeof frame.sessionId === "string" && frame.sessionId.trim() ? [frame.sessionId] : [],
|
|
6461
|
+
);
|
|
6462
|
+
const malformedIdentity = identityFrames.some(
|
|
6463
|
+
frame => typeof frame.sessionId !== "string" || !frame.sessionId.trim(),
|
|
6464
|
+
);
|
|
6465
|
+
const identityConflict = new Set(identities).size > 1;
|
|
6466
|
+
if (!replayValid || malformedIdentity || identityConflict) {
|
|
6467
|
+
// A replay result is the admission proof. Never clear its barrier or drain
|
|
6468
|
+
// queued frames after malformed/conflicting proof; the socket cannot fall
|
|
6469
|
+
// back to transport-local config rekeying.
|
|
6470
|
+
this.dropSession(session, "invalid_replay");
|
|
6471
|
+
return;
|
|
6472
|
+
}
|
|
6473
|
+
session.hostGeneration = msg.generation;
|
|
6474
|
+
session.logicalSessionIdTrusted = true;
|
|
6475
|
+
const identityIndex = replayed.findLastIndex(frame => frame.type === "identity_header");
|
|
6476
|
+
const latestIdentity = identityIndex < 0 ? undefined : replayed[identityIndex];
|
|
6477
|
+
const replayIdentitySessionId = latestIdentity?.sessionId as string | undefined;
|
|
6478
|
+
const endpointBinding = this.#endpointBinding(session);
|
|
6479
|
+
// An open transport owns an eager topic-create claim. An identity-less replay
|
|
6480
|
+
// that races that claim must await its own public creation before classifying
|
|
6481
|
+
// endpoint authority; otherwise the registry correctly sees an in-flight
|
|
6482
|
+
// claim as ambiguous and rejects a valid bootstrap.
|
|
6483
|
+
if (!this.topics.get(session.sessionId) && this.#ownsLiveOpenEndpoint(session, endpointBinding)) {
|
|
6484
|
+
try {
|
|
6485
|
+
await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, {}), session);
|
|
6486
|
+
} catch (error) {
|
|
6487
|
+
logger.warn(
|
|
6488
|
+
`notifications: Telegram replay topic creation failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
|
|
6489
|
+
);
|
|
6490
|
+
this.dropSession(session, "replay_topic_creation_failed");
|
|
6491
|
+
return;
|
|
6492
|
+
}
|
|
6493
|
+
}
|
|
6494
|
+
// Identity-less replay may resume only the exact transport owner. A
|
|
6495
|
+
// rekeyed A→B transport remains denied unless replay proves B.
|
|
6496
|
+
const endpointAuthority = this.#endpointAuthority(endpointBinding, session);
|
|
6497
|
+
const ownsLiveOpenEndpoint = this.#ownsLiveOpenEndpoint(session, endpointBinding);
|
|
6498
|
+
const canResumeTransport =
|
|
6499
|
+
endpointAuthority.state === "unique" &&
|
|
6500
|
+
endpointAuthority.sessionId === session.sessionId &&
|
|
6501
|
+
ownsLiveOpenEndpoint &&
|
|
6502
|
+
this.topics.matchesEndpoint(session.sessionId, endpointBinding);
|
|
6503
|
+
const canBootstrapTransport =
|
|
6504
|
+
endpointAuthority.state === "none" &&
|
|
6505
|
+
ownsLiveOpenEndpoint &&
|
|
6506
|
+
!this.topics.get(session.sessionId) &&
|
|
6507
|
+
!this.preservedInitiatorTopics.has(session.sessionId);
|
|
6508
|
+
const replayCandidateSessionId =
|
|
6509
|
+
replayIdentitySessionId ?? (canResumeTransport || canBootstrapTransport ? session.sessionId : undefined);
|
|
6510
|
+
if (!replayCandidateSessionId) return;
|
|
6511
|
+
const recovered = await this.#recoverTopicBinding(
|
|
6512
|
+
session,
|
|
6513
|
+
replayCandidateSessionId ?? session.sessionId,
|
|
6514
|
+
true,
|
|
6515
|
+
true,
|
|
6516
|
+
replayIdentitySessionId ? undefined : canBootstrapTransport ? "bootstrap" : "resume",
|
|
6517
|
+
);
|
|
6518
|
+
if (!recovered) {
|
|
6519
|
+
if (session.hostGeneration === msg.generation && session.recoveryLease?.state !== "pending")
|
|
6520
|
+
this.dropSession(session, "recovery_rejected");
|
|
6521
|
+
return;
|
|
6522
|
+
}
|
|
6523
|
+
if (
|
|
6524
|
+
!this.#ownsLiveOpenEndpoint(session, endpointBinding) ||
|
|
6525
|
+
!this.#leaseAllows(session, this.#logicalSessionId(session))
|
|
6526
|
+
) {
|
|
6527
|
+
this.dropSession(session, "recovery_rejected");
|
|
6528
|
+
return;
|
|
6529
|
+
}
|
|
6530
|
+
session.replayPending = false;
|
|
5261
6531
|
// Replay restores durable attachment state only. Live notification effects
|
|
5262
6532
|
// (turn streams, context updates, lifecycle messages) may already have been
|
|
5263
6533
|
// delivered before a reconnect and must never be rendered a second time.
|
|
5264
|
-
const identityIndex = replayed.findLastIndex(frame => frame.type === "identity_header");
|
|
5265
6534
|
const currentGeneration = identityIndex < 0 ? replayed : replayed.slice(identityIndex);
|
|
5266
|
-
const latestIdentity = identityIndex < 0 ? undefined : replayed[identityIndex];
|
|
5267
6535
|
const latestActions = new Map<string, Record<string, unknown>>();
|
|
5268
6536
|
for (const frame of currentGeneration) {
|
|
5269
6537
|
if ((frame.type === "action_needed" || frame.type === "action_resolved") && typeof frame.id === "string")
|
|
@@ -5274,7 +6542,15 @@ export class TelegramNotificationDaemon {
|
|
|
5274
6542
|
for (const frame of replayState) {
|
|
5275
6543
|
const fingerprint = JSON.stringify(frame);
|
|
5276
6544
|
replayCounts.set(fingerprint, (replayCounts.get(fingerprint) ?? 0) + 1);
|
|
5277
|
-
|
|
6545
|
+
try {
|
|
6546
|
+
await this.handleSessionMessage(session, frame);
|
|
6547
|
+
} catch (error) {
|
|
6548
|
+
logger.warn(
|
|
6549
|
+
`notifications: Telegram replay admission failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
|
|
6550
|
+
);
|
|
6551
|
+
this.dropSession(session, "replay_admission_failed");
|
|
6552
|
+
return;
|
|
6553
|
+
}
|
|
5278
6554
|
}
|
|
5279
6555
|
const queued = session.replayQueue.splice(0);
|
|
5280
6556
|
for (const frame of queued) {
|
|
@@ -5292,16 +6568,19 @@ export class TelegramNotificationDaemon {
|
|
|
5292
6568
|
}
|
|
5293
6569
|
await this.handleSessionMessage(session, frame);
|
|
5294
6570
|
}
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
6571
|
+
this.#terminalizeBtwTurnsForGenerationChange(session);
|
|
6572
|
+
this.#resumeBtwTurnsForSession(session);
|
|
6573
|
+
const recoveredSessionId = this.#logicalSessionId(session);
|
|
6574
|
+
if (
|
|
6575
|
+
this.#leaseAllows(session, recoveredSessionId) &&
|
|
6576
|
+
this.topics.markReplayCursor(recoveredSessionId, msg.generation, msg.lastSeq)
|
|
6577
|
+
)
|
|
6578
|
+
await this.persistTopics();
|
|
5301
6579
|
return;
|
|
5302
6580
|
}
|
|
5303
6581
|
if (msg?.type === "event_replay_result") return;
|
|
5304
|
-
if (msg && typeof msg === "object") this.#updateLogicalSessionForThreadedFrame(session, msg);
|
|
6582
|
+
if (msg && typeof msg === "object") await this.#updateLogicalSessionForThreadedFrame(session, msg);
|
|
6583
|
+
if (session.logicalSessionIdTrusted && !this.#leaseAllows(session)) return;
|
|
5305
6584
|
if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
|
|
5306
6585
|
if (await this.#renderModelChoices(session, msg as Record<string, unknown>)) return;
|
|
5307
6586
|
|
|
@@ -5335,7 +6614,7 @@ export class TelegramNotificationDaemon {
|
|
|
5335
6614
|
msg.threadId !== pending.threadId ||
|
|
5336
6615
|
msg.updateId !== pending.updateId ||
|
|
5337
6616
|
msg.messageId !== pending.messageId ||
|
|
5338
|
-
this.topics.sessionForTopic(pending.threadId) !== pending.
|
|
6617
|
+
this.topics.sessionForTopic(pending.threadId) !== pending.logicalSessionId
|
|
5339
6618
|
)
|
|
5340
6619
|
return;
|
|
5341
6620
|
if (
|
|
@@ -5349,6 +6628,21 @@ export class TelegramNotificationDaemon {
|
|
|
5349
6628
|
return;
|
|
5350
6629
|
if (msg.status === "ok" && typeof msg.text !== "string") return;
|
|
5351
6630
|
if (this.#stoppingBtw || this.#btwTerminalDeliveries.has(requestId)) return;
|
|
6631
|
+
const isAuthoritative = (): boolean =>
|
|
6632
|
+
!this.#stoppingBtw &&
|
|
6633
|
+
this.#leaseTokenAllows(pending.socketLease) &&
|
|
6634
|
+
this.sessions.get(session.sessionId) === session &&
|
|
6635
|
+
session.ws.readyState === WebSocket.OPEN &&
|
|
6636
|
+
pending.endpointDigest === session.endpointDigest &&
|
|
6637
|
+
pending.generation === session.hostGeneration &&
|
|
6638
|
+
pending.logicalSessionId === this.#logicalSessionId(session) &&
|
|
6639
|
+
pending.transportSessionId === session.sessionId &&
|
|
6640
|
+
requestId === msg.requestId &&
|
|
6641
|
+
pending.logicalSessionId === msg.sessionId &&
|
|
6642
|
+
pending.messageId === msg.messageId &&
|
|
6643
|
+
pending.threadId === msg.threadId &&
|
|
6644
|
+
pending.updateId === msg.updateId &&
|
|
6645
|
+
this.topics.sessionForTopic(pending.threadId) === pending.logicalSessionId;
|
|
5352
6646
|
const finished = Promise.withResolvers<void>();
|
|
5353
6647
|
const terminalDelivery: PendingBtwDelivery = {
|
|
5354
6648
|
pending,
|
|
@@ -5377,6 +6671,7 @@ export class TelegramNotificationDaemon {
|
|
|
5377
6671
|
messageId: pending.messageId,
|
|
5378
6672
|
text,
|
|
5379
6673
|
signal: terminalDelivery.controller.signal,
|
|
6674
|
+
isAuthoritative,
|
|
5380
6675
|
});
|
|
5381
6676
|
deliveryOutcome =
|
|
5382
6677
|
response && typeof response === "object" && (response as { ok?: unknown }).ok === true
|
|
@@ -5391,20 +6686,6 @@ export class TelegramNotificationDaemon {
|
|
|
5391
6686
|
return;
|
|
5392
6687
|
}
|
|
5393
6688
|
const markdown = msg.text;
|
|
5394
|
-
const isAuthoritative = (): boolean =>
|
|
5395
|
-
!this.#stoppingBtw &&
|
|
5396
|
-
this.sessions.get(session.sessionId) === session &&
|
|
5397
|
-
session.ws.readyState === WebSocket.OPEN &&
|
|
5398
|
-
pending.endpointDigest === session.endpointDigest &&
|
|
5399
|
-
pending.generation === session.hostGeneration &&
|
|
5400
|
-
pending.logicalSessionId === this.#logicalSessionId(session) &&
|
|
5401
|
-
pending.transportSessionId === session.sessionId &&
|
|
5402
|
-
requestId === msg.requestId &&
|
|
5403
|
-
pending.logicalSessionId === msg.sessionId &&
|
|
5404
|
-
pending.messageId === msg.messageId &&
|
|
5405
|
-
pending.threadId === msg.threadId &&
|
|
5406
|
-
pending.updateId === msg.updateId &&
|
|
5407
|
-
this.topics.sessionForTopic(pending.threadId) === pending.transportSessionId;
|
|
5408
6689
|
const signal = AbortSignal.any([
|
|
5409
6690
|
this.#btwDeliveryAbort.signal,
|
|
5410
6691
|
terminalDelivery.controller.signal,
|
|
@@ -5531,13 +6812,13 @@ export class TelegramNotificationDaemon {
|
|
|
5531
6812
|
toolAdmissionEpoch === this.toolActivityPolicyEpoch);
|
|
5532
6813
|
const abandonStaleToolStart = (): void => {
|
|
5533
6814
|
if (toolActivity?.phase !== "started") return;
|
|
5534
|
-
const key = `${
|
|
6815
|
+
const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
5535
6816
|
const owner = this.toolActivityOwners.get(key);
|
|
5536
6817
|
if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
|
|
5537
6818
|
this.toolActivityOwners.delete(key);
|
|
5538
6819
|
};
|
|
5539
6820
|
if (toolActivity) {
|
|
5540
|
-
const liveKey = `${
|
|
6821
|
+
const liveKey = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
5541
6822
|
const currentOwner = this.toolActivityOwners.get(liveKey);
|
|
5542
6823
|
if (toolActivity.phase === "started") {
|
|
5543
6824
|
if (!toolStartIsCurrent()) return;
|
|
@@ -5562,102 +6843,106 @@ export class TelegramNotificationDaemon {
|
|
|
5562
6843
|
}
|
|
5563
6844
|
const send = renderThreadedFrame(msg);
|
|
5564
6845
|
if (!send) return;
|
|
5565
|
-
const
|
|
6846
|
+
const transportLogicalSessionId = this.#logicalSessionId(session);
|
|
6847
|
+
// Preserve legacy identity routing for direct/non-replay session callers.
|
|
6848
|
+
// Authenticated transports never infer topic ownership from display identity:
|
|
6849
|
+
// a resume must bind only its proven logical session.
|
|
6850
|
+
const logicalSessionId =
|
|
6851
|
+
send.identity && !session.logicalSessionIdTrusted && this.sessions.get(session.sessionId) !== session
|
|
6852
|
+
? (this.topicOwnerForIdentity(msg) ?? transportLogicalSessionId)
|
|
6853
|
+
: transportLogicalSessionId;
|
|
6854
|
+
if (!this.#leaseAllows(session, logicalSessionId)) return;
|
|
6855
|
+
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
6856
|
+
if (!socketLease) return;
|
|
6857
|
+
const existingTopic = await this.existingTopicForPrivateChat(logicalSessionId);
|
|
5566
6858
|
if (!toolStartIsCurrent()) {
|
|
5567
6859
|
abandonStaleToolStart();
|
|
5568
6860
|
return;
|
|
5569
6861
|
}
|
|
5570
|
-
if (
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
6862
|
+
if (
|
|
6863
|
+
this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
|
|
6864
|
+
this.topics.get(logicalSessionId)?.bindingMalformed
|
|
6865
|
+
)
|
|
6866
|
+
return;
|
|
6867
|
+
if (!send.identity && !existingTopic && !this.flatIdentitySent.has(logicalSessionId)) {
|
|
5576
6868
|
this.rememberPendingThreadedFrame(session, send, threadedFrame, toolActivity);
|
|
5577
6869
|
return;
|
|
5578
6870
|
}
|
|
5579
|
-
if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
|
|
5580
|
-
const ownerId = this.topicOwnerForIdentity(msg);
|
|
5581
|
-
const ownerLease = ownerId ? await this.topicAuthorityLease(ownerId) : undefined;
|
|
5582
|
-
if (ownerId && ownerId !== session.sessionId && ownerLease) {
|
|
5583
|
-
await this.flushPendingThreadedFrames(session.sessionId, ownerLease);
|
|
5584
|
-
return;
|
|
5585
|
-
}
|
|
5586
|
-
}
|
|
5587
6871
|
const topicId =
|
|
5588
|
-
existingTopic ??
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
abandonStaleToolStart();
|
|
5592
|
-
return;
|
|
5593
|
-
}
|
|
6872
|
+
existingTopic ??
|
|
6873
|
+
(await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session));
|
|
6874
|
+
const topicLease = await this.topicAuthorityLease(logicalSessionId);
|
|
5594
6875
|
if (!topicId || !topicLease || topicLease.topicId !== topicId) {
|
|
5595
|
-
if (
|
|
5596
|
-
|
|
6876
|
+
if (
|
|
6877
|
+
this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
|
|
6878
|
+
this.topics.get(logicalSessionId)?.bindingMalformed
|
|
6879
|
+
)
|
|
6880
|
+
return;
|
|
6881
|
+
await this.deliverFlatFallback(logicalSessionId, send, toolActivity, socketLease);
|
|
5597
6882
|
return;
|
|
5598
6883
|
}
|
|
5599
6884
|
if (send.identity) {
|
|
5600
6885
|
const identityKey = this.topicIdentityKey(msg);
|
|
5601
6886
|
if (identityKey) {
|
|
5602
|
-
this.topicOwnerByIdentity.set(identityKey,
|
|
5603
|
-
if (this.topics.markIdentityKey(
|
|
6887
|
+
this.topicOwnerByIdentity.set(identityKey, logicalSessionId);
|
|
6888
|
+
if (this.topics.markIdentityKey(logicalSessionId, identityKey)) await this.persistTopics();
|
|
5604
6889
|
}
|
|
5605
|
-
// Explicit Telegram-side user renames own the topic title. Pending user
|
|
5606
|
-
// reconciliation runs before daemon identity naming, so retries and daemon
|
|
5607
|
-
// restarts cannot silently replace the preserved name.
|
|
5608
6890
|
await this.reconcileUserTopicName(topicLease);
|
|
5609
|
-
const name = this.topicNameFor(
|
|
5610
|
-
if (this.topics.needsRename(
|
|
5611
|
-
this.daemonRenameAttempts.set(
|
|
5612
|
-
session.sessionId,
|
|
5613
|
-
(this.daemonRenameAttempts.get(session.sessionId) ?? 0) + 1,
|
|
5614
|
-
);
|
|
6891
|
+
const name = this.topicNameFor(logicalSessionId, msg);
|
|
6892
|
+
if (this.topics.needsRename(logicalSessionId, name)) {
|
|
5615
6893
|
try {
|
|
5616
|
-
if (!this.topicLeaseIsCurrent(topicLease)) return;
|
|
6894
|
+
if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return;
|
|
6895
|
+
this.daemonRenameAttempts.set(
|
|
6896
|
+
logicalSessionId,
|
|
6897
|
+
(this.daemonRenameAttempts.get(logicalSessionId) ?? 0) + 1,
|
|
6898
|
+
);
|
|
5617
6899
|
const response = await this.botApi.call("editForumTopic", {
|
|
5618
6900
|
chat_id: this.opts.chatId,
|
|
5619
6901
|
message_thread_id: Number(topicId),
|
|
5620
6902
|
name,
|
|
5621
6903
|
});
|
|
5622
|
-
if (topicRenameApplied(response)) this.topics.markNameApplied(
|
|
6904
|
+
if (topicRenameApplied(response)) this.topics.markNameApplied(logicalSessionId, name);
|
|
5623
6905
|
} catch {
|
|
5624
|
-
//
|
|
5625
|
-
// later identity frame retries.
|
|
6906
|
+
// A later identity frame retries a transient daemon rename failure.
|
|
5626
6907
|
} finally {
|
|
5627
|
-
const
|
|
5628
|
-
if (
|
|
5629
|
-
else
|
|
5630
|
-
this.daemonRenameAttempts.delete(session.sessionId);
|
|
5631
|
-
await this.reconcileUserTopicName(topicLease);
|
|
5632
|
-
}
|
|
6908
|
+
const attempts = (this.daemonRenameAttempts.get(logicalSessionId) ?? 1) - 1;
|
|
6909
|
+
if (attempts > 0) this.daemonRenameAttempts.set(logicalSessionId, attempts);
|
|
6910
|
+
else this.daemonRenameAttempts.delete(logicalSessionId);
|
|
5633
6911
|
}
|
|
5634
6912
|
}
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
this.topics.markIdentitySent(session.sessionId);
|
|
6913
|
+
if (this.topics.needsIdentity(logicalSessionId)) {
|
|
6914
|
+
await this.submitThreadedFrame(logicalSessionId, send, topicLease, undefined, socketLease);
|
|
6915
|
+
this.topics.markIdentitySent(logicalSessionId);
|
|
5639
6916
|
}
|
|
5640
|
-
await this.flushPendingThreadedFrames(session.sessionId, topicLease);
|
|
5641
6917
|
await this.persistTopics();
|
|
6918
|
+
await this.flushPendingThreadedFrames(logicalSessionId, topicLease);
|
|
6919
|
+
await this.reconcileUserTopicName(topicLease);
|
|
5642
6920
|
return;
|
|
5643
6921
|
}
|
|
5644
6922
|
if (!toolStartIsCurrent()) {
|
|
5645
6923
|
abandonStaleToolStart();
|
|
5646
6924
|
return;
|
|
5647
6925
|
}
|
|
5648
|
-
await this.submitThreadedFrame(
|
|
6926
|
+
await this.submitThreadedFrame(logicalSessionId, send, topicLease, toolActivity, socketLease);
|
|
5649
6927
|
return;
|
|
5650
6928
|
}
|
|
5651
6929
|
if (msg.type === "action_needed" && msg.id) {
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
6930
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
6931
|
+
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
6932
|
+
if (!socketLease) return;
|
|
6933
|
+
if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: logicalSessionId, actionId: msg.id });
|
|
6934
|
+
if (
|
|
6935
|
+
this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
|
|
6936
|
+
this.topics.get(logicalSessionId)?.bindingMalformed
|
|
6937
|
+
)
|
|
6938
|
+
return;
|
|
6939
|
+
const topicId = await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session);
|
|
6940
|
+
const topicLease = topicId ? this.topicAuthorityLeaseFromRegistry(logicalSessionId) : undefined;
|
|
5656
6941
|
if (topicId && (!topicLease || topicLease.topicId !== topicId)) return;
|
|
5657
6942
|
if (!topicId) {
|
|
5658
6943
|
// Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
|
|
5659
6944
|
if (!(await this.pairedChatIsPrivate())) return;
|
|
5660
|
-
await this.notifyThreadedFallback();
|
|
6945
|
+
await this.notifyThreadedFallback(socketLease);
|
|
5661
6946
|
}
|
|
5662
6947
|
const threadField = topicLease ? { message_thread_id: Number(topicLease.topicId) } : {};
|
|
5663
6948
|
const controls: Array<{
|
|
@@ -5695,13 +6980,13 @@ export class TelegramNotificationDaemon {
|
|
|
5695
6980
|
const options = Array.isArray(msg.options) ? msg.options : [];
|
|
5696
6981
|
const inline_keyboard = [
|
|
5697
6982
|
...buildCompactChoiceGrid(options, (i: number) =>
|
|
5698
|
-
this.aliasTable.put({ sessionId:
|
|
6983
|
+
this.aliasTable.put({ sessionId: logicalSessionId, actionId: msg.id, answer: i }),
|
|
5699
6984
|
),
|
|
5700
6985
|
...controls.map(control => [
|
|
5701
6986
|
{
|
|
5702
6987
|
text: control.label,
|
|
5703
6988
|
callback_data: this.aliasTable.put({
|
|
5704
|
-
sessionId:
|
|
6989
|
+
sessionId: logicalSessionId,
|
|
5705
6990
|
actionId: msg.id,
|
|
5706
6991
|
answer: { controlId: control.id },
|
|
5707
6992
|
}),
|
|
@@ -5714,7 +6999,8 @@ export class TelegramNotificationDaemon {
|
|
|
5714
6999
|
const chunks = splitTelegramHtml(rendered.text);
|
|
5715
7000
|
let result: { result?: { message_id?: number } } = {};
|
|
5716
7001
|
for (let i = 0; i < chunks.length; i++) {
|
|
5717
|
-
if (topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
7002
|
+
if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease)))
|
|
7003
|
+
return undefined;
|
|
5718
7004
|
result = (await this.botApi.call("sendMessage", {
|
|
5719
7005
|
chat_id: this.opts.chatId,
|
|
5720
7006
|
...threadField,
|
|
@@ -5730,7 +7016,7 @@ export class TelegramNotificationDaemon {
|
|
|
5730
7016
|
// Rich (default on): promote to sendRichMessage with a top-level
|
|
5731
7017
|
// reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
|
|
5732
7018
|
|
|
5733
|
-
if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
|
|
7019
|
+
if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease))) return;
|
|
5734
7020
|
const outcome = await deliverRichActionWithFallback(
|
|
5735
7021
|
this.botApi,
|
|
5736
7022
|
{ chat_id: this.opts.chatId, ...threadField },
|
|
@@ -5749,21 +7035,22 @@ export class TelegramNotificationDaemon {
|
|
|
5749
7035
|
);
|
|
5750
7036
|
// Only asks are reply-routable; idle pings register no route.
|
|
5751
7037
|
if (kind === "ask" && outcome.messageId !== undefined)
|
|
5752
|
-
this.messageRoutes.set(String(outcome.messageId), { sessionId:
|
|
7038
|
+
this.messageRoutes.set(String(outcome.messageId), { sessionId: logicalSessionId, actionId: msg.id });
|
|
5753
7039
|
} else {
|
|
5754
7040
|
// Off: byte-identical to the pre-rich HTML path.
|
|
5755
7041
|
const messageId = await sendHtmlChunks();
|
|
5756
7042
|
// Only asks are reply-routable; idle pings register no route (parity
|
|
5757
7043
|
// with the rich branch and correct even in the byte-identical off path).
|
|
5758
7044
|
if (kind === "ask" && messageId !== undefined)
|
|
5759
|
-
this.messageRoutes.set(String(messageId), { sessionId:
|
|
7045
|
+
this.messageRoutes.set(String(messageId), { sessionId: logicalSessionId, actionId: msg.id });
|
|
5760
7046
|
}
|
|
5761
7047
|
await this.persistAliases();
|
|
5762
7048
|
} else if (msg.type === "action_resolved" && msg.id) {
|
|
5763
7049
|
session.pending.delete(msg.id);
|
|
5764
|
-
this.deleteMessageRoutes(session
|
|
7050
|
+
this.deleteMessageRoutes(this.#logicalSessionId(session), msg.id);
|
|
5765
7051
|
for (const [alias, route] of this.aliasTable.entries()) {
|
|
5766
|
-
if (route.sessionId === session
|
|
7052
|
+
if (route.sessionId === this.#logicalSessionId(session) && route.actionId === msg.id)
|
|
7053
|
+
this.aliasTable.delete(alias);
|
|
5767
7054
|
}
|
|
5768
7055
|
await this.persistAliases();
|
|
5769
7056
|
}
|
|
@@ -5810,7 +7097,9 @@ export class TelegramNotificationDaemon {
|
|
|
5810
7097
|
if (
|
|
5811
7098
|
this.sessions.get(session.sessionId) !== session ||
|
|
5812
7099
|
this.#logicalSessionId(session) !== route.sessionId ||
|
|
5813
|
-
session.ws.readyState !== WebSocket.OPEN
|
|
7100
|
+
session.ws.readyState !== WebSocket.OPEN ||
|
|
7101
|
+
!this.#leaseTokenAllows(route.socketLease) ||
|
|
7102
|
+
(this.topics.get(route.sessionId) !== undefined && !this.topicAuthorityLeaseFromRegistry(route.sessionId))
|
|
5814
7103
|
) {
|
|
5815
7104
|
this.#modelChoiceAliases.delete(alias);
|
|
5816
7105
|
await this.#sendModelStaleGuidance(callbackId);
|
|
@@ -5822,6 +7111,13 @@ export class TelegramNotificationDaemon {
|
|
|
5822
7111
|
const updateId = (update as { update_id?: unknown }).update_id;
|
|
5823
7112
|
const safeUpdateId =
|
|
5824
7113
|
typeof updateId === "number" && Number.isSafeInteger(updateId) && updateId >= 0 ? updateId : undefined;
|
|
7114
|
+
if (
|
|
7115
|
+
!this.#leaseTokenAllows(route.socketLease) ||
|
|
7116
|
+
(this.topics.get(route.sessionId) !== undefined && !this.topicAuthorityLeaseFromRegistry(route.sessionId))
|
|
7117
|
+
) {
|
|
7118
|
+
await this.#sendModelStaleGuidance(callbackId);
|
|
7119
|
+
return true;
|
|
7120
|
+
}
|
|
5825
7121
|
try {
|
|
5826
7122
|
session.ws.send(
|
|
5827
7123
|
JSON.stringify({
|
|
@@ -6095,7 +7391,40 @@ export class TelegramNotificationDaemon {
|
|
|
6095
7391
|
if (!raw.callback_query && (!isAskReply || interceptAskControl || reservedBtw !== undefined)) {
|
|
6096
7392
|
const inbound = decideThreadedInbound(update as never, {
|
|
6097
7393
|
pairedChatId: this.opts.chatId,
|
|
6098
|
-
topicToSession: t =>
|
|
7394
|
+
topicToSession: t => {
|
|
7395
|
+
const topicSessionId = this.topics.sessionForTopic(t);
|
|
7396
|
+
if (!topicSessionId) return undefined;
|
|
7397
|
+
const legacyOwner = this.legacyTopicOwners.get(topicSessionId);
|
|
7398
|
+
if (this.preservedInitiatorTopics.has(topicSessionId)) return undefined;
|
|
7399
|
+
const owner =
|
|
7400
|
+
this.logicalSessionOwners.get(topicSessionId) ??
|
|
7401
|
+
legacyOwner ??
|
|
7402
|
+
[...this.sessions.values()].find(
|
|
7403
|
+
session =>
|
|
7404
|
+
(session.sessionId === topicSessionId || this.#logicalSessionId(session) === topicSessionId) &&
|
|
7405
|
+
this.#leaseAllows(session, topicSessionId),
|
|
7406
|
+
);
|
|
7407
|
+
if (owner) {
|
|
7408
|
+
if (owner.replayPending || owner.recoveryLease?.state === "pending") return undefined;
|
|
7409
|
+
return this.#leaseAllows(owner, topicSessionId) ? owner.logicalSessionId : undefined;
|
|
7410
|
+
}
|
|
7411
|
+
const transportOwner = [...this.sessions.values()].find(
|
|
7412
|
+
candidate => candidate.sessionId === topicSessionId && this.#leaseAllows(candidate),
|
|
7413
|
+
);
|
|
7414
|
+
if (transportOwner) {
|
|
7415
|
+
if (transportOwner.replayPending || transportOwner.recoveryLease?.state === "pending")
|
|
7416
|
+
return undefined;
|
|
7417
|
+
return transportOwner.logicalSessionId;
|
|
7418
|
+
}
|
|
7419
|
+
return [...this.sessions.values()].some(
|
|
7420
|
+
session =>
|
|
7421
|
+
session.sessionId === topicSessionId ||
|
|
7422
|
+
this.#logicalSessionId(session) === topicSessionId ||
|
|
7423
|
+
session.recoveryLease?.logicalSessionId === topicSessionId,
|
|
7424
|
+
)
|
|
7425
|
+
? undefined
|
|
7426
|
+
: topicSessionId;
|
|
7427
|
+
},
|
|
6099
7428
|
isDuplicate: id => this.dispatchState.seenUpdateIds.has(id),
|
|
6100
7429
|
});
|
|
6101
7430
|
if (inbound.kind === "duplicate") return;
|
|
@@ -6105,10 +7434,45 @@ export class TelegramNotificationDaemon {
|
|
|
6105
7434
|
? { kind: "none" as const }
|
|
6106
7435
|
: parseTelegramControlCommand(inbound.text, this.botUsername);
|
|
6107
7436
|
if (preliminaryControl.kind === "ignored") return;
|
|
6108
|
-
const session =
|
|
7437
|
+
const session =
|
|
7438
|
+
this.logicalSessionOwners.get(inbound.sessionId) ??
|
|
7439
|
+
this.sessions.get(inbound.sessionId) ??
|
|
7440
|
+
[...this.sessions.values()].find(
|
|
7441
|
+
candidate =>
|
|
7442
|
+
this.#logicalSessionId(candidate) === inbound.sessionId &&
|
|
7443
|
+
this.#leaseAllows(candidate, inbound.sessionId),
|
|
7444
|
+
);
|
|
7445
|
+
if (session && !this.#leaseAllows(session, inbound.sessionId)) return;
|
|
7446
|
+
const topicSessionId = this.topics.sessionForTopic(inbound.threadId);
|
|
7447
|
+
const topicLease = topicSessionId ? this.topicAuthorityLeaseFromRegistry(topicSessionId) : undefined;
|
|
7448
|
+
const topicLeaseAllows = (): boolean => {
|
|
7449
|
+
const current = topicSessionId ? this.topicAuthorityLeaseFromRegistry(topicSessionId) : undefined;
|
|
7450
|
+
return (
|
|
7451
|
+
!!topicLease &&
|
|
7452
|
+
current?.topicId === topicLease.topicId &&
|
|
7453
|
+
current.authorityEpoch === topicLease.authorityEpoch
|
|
7454
|
+
);
|
|
7455
|
+
};
|
|
7456
|
+
const routeLease = session ? this.#socketLease(session, inbound.sessionId) : undefined;
|
|
7457
|
+
const routeAllows = (): boolean =>
|
|
7458
|
+
!!session &&
|
|
7459
|
+
!!routeLease &&
|
|
7460
|
+
session.ws.readyState === WebSocket.OPEN &&
|
|
7461
|
+
this.#leaseTokenAllows(routeLease) &&
|
|
7462
|
+
topicLeaseAllows();
|
|
7463
|
+
const routeLeaseAllows = (): boolean =>
|
|
7464
|
+
topicLeaseAllows() && (!session || (!!routeLease && this.#leaseTokenAllows(routeLease)));
|
|
7465
|
+
const reserveRouteUpdate = async (): Promise<boolean> => {
|
|
7466
|
+
if (!(await this.reserveSeenUpdateId(inbound.updateId))) return false;
|
|
7467
|
+
if (routeLeaseAllows()) return true;
|
|
7468
|
+
await this.releaseSeenUpdateId(inbound.updateId);
|
|
7469
|
+
return false;
|
|
7470
|
+
};
|
|
7471
|
+
|
|
7472
|
+
if (session && !routeLease) return;
|
|
6109
7473
|
if (preliminaryControl.kind === "invalid" && session?.ws.readyState !== WebSocket.OPEN) return;
|
|
6110
7474
|
if (preliminaryControl.kind === "command" && session?.ws.readyState !== WebSocket.OPEN) {
|
|
6111
|
-
if (await
|
|
7475
|
+
if (await reserveRouteUpdate()) {
|
|
6112
7476
|
try {
|
|
6113
7477
|
await this.botApi.call("sendMessage", {
|
|
6114
7478
|
chat_id: this.opts.chatId,
|
|
@@ -6127,36 +7491,41 @@ export class TelegramNotificationDaemon {
|
|
|
6127
7491
|
return;
|
|
6128
7492
|
}
|
|
6129
7493
|
if (reservedBtw?.kind === "question" && (!reservedBtw.question || inbound.attachment)) {
|
|
6130
|
-
if (!(await
|
|
7494
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6131
7495
|
await this.#sendBtwMessage({
|
|
6132
7496
|
threadId: inbound.threadId,
|
|
6133
7497
|
messageId: inbound.messageId,
|
|
6134
7498
|
text: BTW_USAGE_TEXT,
|
|
7499
|
+
isAuthoritative: routeLeaseAllows,
|
|
6135
7500
|
});
|
|
6136
7501
|
return;
|
|
6137
7502
|
}
|
|
6138
7503
|
if (reservedBtw?.kind === "question" && this.opts.btw?.enabled === false) {
|
|
6139
|
-
if (!(await
|
|
7504
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6140
7505
|
await this.#sendBtwMessage({
|
|
6141
7506
|
threadId: inbound.threadId,
|
|
6142
7507
|
messageId: inbound.messageId,
|
|
6143
7508
|
text: "Telegram /btw is disabled in local settings.",
|
|
7509
|
+
isAuthoritative: routeLeaseAllows,
|
|
6144
7510
|
});
|
|
6145
7511
|
return;
|
|
6146
7512
|
}
|
|
6147
7513
|
if (reservedBtw?.kind === "question" && session?.ws.readyState !== WebSocket.OPEN) {
|
|
6148
|
-
if (!(await
|
|
7514
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6149
7515
|
await this.#sendBtwMessage({
|
|
6150
7516
|
threadId: inbound.threadId,
|
|
6151
7517
|
messageId: inbound.messageId,
|
|
6152
7518
|
text: "Restart this GJC session to enable /btw.",
|
|
7519
|
+
isAuthoritative: routeLeaseAllows,
|
|
6153
7520
|
});
|
|
6154
7521
|
return;
|
|
6155
7522
|
}
|
|
6156
|
-
if (session
|
|
7523
|
+
if (!session || !routeLease) return;
|
|
7524
|
+
if (routeAllows()) {
|
|
6157
7525
|
const attachmentResult = inbound.attachment
|
|
6158
7526
|
? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
|
|
6159
7527
|
: undefined;
|
|
7528
|
+
if (!routeAllows()) return;
|
|
6160
7529
|
const images = attachmentResult?.images ?? [];
|
|
6161
7530
|
const fileNotes = attachmentResult?.fileNotes ?? [];
|
|
6162
7531
|
const hasMedia = inbound.attachment !== undefined || images.length > 0 || fileNotes.length > 0;
|
|
@@ -6179,11 +7548,12 @@ export class TelegramNotificationDaemon {
|
|
|
6179
7548
|
if (btw?.kind === "question") {
|
|
6180
7549
|
const btwQuestion = btw.question;
|
|
6181
7550
|
if (!btwQuestion || hasMedia) {
|
|
6182
|
-
if (!(await
|
|
7551
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6183
7552
|
await this.#sendBtwMessage({
|
|
6184
7553
|
threadId: inbound.threadId,
|
|
6185
7554
|
messageId: inbound.messageId,
|
|
6186
7555
|
text: BTW_USAGE_TEXT,
|
|
7556
|
+
isAuthoritative: routeLeaseAllows,
|
|
6187
7557
|
});
|
|
6188
7558
|
return;
|
|
6189
7559
|
}
|
|
@@ -6193,28 +7563,31 @@ export class TelegramNotificationDaemon {
|
|
|
6193
7563
|
threadId: inbound.threadId,
|
|
6194
7564
|
messageId: inbound.messageId,
|
|
6195
7565
|
text: BTW_QUESTION_LIMIT_TEXT,
|
|
7566
|
+
isAuthoritative: routeLeaseAllows,
|
|
6196
7567
|
});
|
|
6197
7568
|
} catch {
|
|
6198
7569
|
logger.warn("notifications: /btw question-limit delivery failed");
|
|
6199
7570
|
}
|
|
6200
|
-
await
|
|
7571
|
+
await reserveRouteUpdate();
|
|
6201
7572
|
return;
|
|
6202
7573
|
}
|
|
6203
7574
|
if (this.opts.btw?.enabled === false) {
|
|
6204
|
-
if (!(await
|
|
7575
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6205
7576
|
await this.#sendBtwMessage({
|
|
6206
7577
|
threadId: inbound.threadId,
|
|
6207
7578
|
messageId: inbound.messageId,
|
|
6208
7579
|
text: "Telegram /btw is disabled in local settings.",
|
|
7580
|
+
isAuthoritative: routeLeaseAllows,
|
|
6209
7581
|
});
|
|
6210
7582
|
return;
|
|
6211
7583
|
}
|
|
6212
7584
|
if (!session.ephemeralCapable || session.hostGeneration < 1) {
|
|
6213
|
-
if (!(await
|
|
7585
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6214
7586
|
await this.#sendBtwMessage({
|
|
6215
7587
|
threadId: inbound.threadId,
|
|
6216
7588
|
messageId: inbound.messageId,
|
|
6217
7589
|
text: "Restart this GJC session to enable /btw.",
|
|
7590
|
+
isAuthoritative: routeLeaseAllows,
|
|
6218
7591
|
});
|
|
6219
7592
|
return;
|
|
6220
7593
|
}
|
|
@@ -6230,21 +7603,23 @@ export class TelegramNotificationDaemon {
|
|
|
6230
7603
|
this.#btwTerminalTombstones.delete(this.#btwTerminalTombstones.keys().next().value!);
|
|
6231
7604
|
}
|
|
6232
7605
|
if (this.#pendingBtwTurns.size >= BTW_MAX_PENDING) {
|
|
6233
|
-
if (!(await
|
|
7606
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6234
7607
|
await this.#sendBtwMessage({
|
|
6235
7608
|
threadId: inbound.threadId,
|
|
6236
7609
|
messageId: inbound.messageId,
|
|
6237
7610
|
text: BTW_CAPACITY_TEXT,
|
|
7611
|
+
isAuthoritative: routeLeaseAllows,
|
|
6238
7612
|
});
|
|
6239
7613
|
return;
|
|
6240
7614
|
}
|
|
6241
7615
|
const transportSessionId = session.sessionId;
|
|
6242
7616
|
const logicalSessionId = this.#logicalSessionId(session);
|
|
6243
7617
|
const requestId = `btw:${crypto.randomUUID()}`;
|
|
6244
|
-
if (!(await
|
|
7618
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6245
7619
|
const pending: PendingBtwTurn = {
|
|
6246
7620
|
transportSessionId,
|
|
6247
7621
|
logicalSessionId,
|
|
7622
|
+
socketLease: routeLease,
|
|
6248
7623
|
endpointDigest: session.endpointDigest,
|
|
6249
7624
|
generation: session.hostGeneration,
|
|
6250
7625
|
question: btwQuestion,
|
|
@@ -6260,6 +7635,7 @@ export class TelegramNotificationDaemon {
|
|
|
6260
7635
|
threadId: inbound.threadId,
|
|
6261
7636
|
messageId: inbound.messageId,
|
|
6262
7637
|
text: "Unable to start /btw because this GJC session disconnected. Reopen the session and try again.",
|
|
7638
|
+
isAuthoritative: routeLeaseAllows,
|
|
6263
7639
|
});
|
|
6264
7640
|
return;
|
|
6265
7641
|
}
|
|
@@ -6267,9 +7643,10 @@ export class TelegramNotificationDaemon {
|
|
|
6267
7643
|
}
|
|
6268
7644
|
const control = hasMedia ? { kind: "none" as const } : preliminaryControl;
|
|
6269
7645
|
if (control.kind !== "none") {
|
|
6270
|
-
await
|
|
7646
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6271
7647
|
const sendControlNotice = async (body: string): Promise<void> => {
|
|
6272
7648
|
try {
|
|
7649
|
+
if (!routeLeaseAllows()) return;
|
|
6273
7650
|
await this.botApi.call("sendMessage", {
|
|
6274
7651
|
chat_id: this.opts.chatId,
|
|
6275
7652
|
message_thread_id: Number(inbound.threadId),
|
|
@@ -6288,6 +7665,7 @@ export class TelegramNotificationDaemon {
|
|
|
6288
7665
|
await sendControlNotice("Session control unavailable: session is disconnected.");
|
|
6289
7666
|
return;
|
|
6290
7667
|
}
|
|
7668
|
+
if (!routeAllows()) return;
|
|
6291
7669
|
session.ws.send(
|
|
6292
7670
|
JSON.stringify({
|
|
6293
7671
|
type: "control_command",
|
|
@@ -6307,6 +7685,7 @@ export class TelegramNotificationDaemon {
|
|
|
6307
7685
|
// Telegram asks always accept custom text (the SDK maps a string answer
|
|
6308
7686
|
// to the ask's custom-input slot), so route the latest pending ask here.
|
|
6309
7687
|
const pendingAsk = cfg || hasMedia ? undefined : [...session.pending.values()].at(-1);
|
|
7688
|
+
if (!routeAllows()) return;
|
|
6310
7689
|
if (pendingAsk) {
|
|
6311
7690
|
session.ws.send(
|
|
6312
7691
|
JSON.stringify({
|
|
@@ -6317,9 +7696,11 @@ export class TelegramNotificationDaemon {
|
|
|
6317
7696
|
}),
|
|
6318
7697
|
);
|
|
6319
7698
|
await this.rememberSeenUpdateId(inbound.updateId);
|
|
6320
|
-
if (inbound.messageId !== undefined)
|
|
7699
|
+
if (inbound.messageId !== undefined)
|
|
7700
|
+
await this.setReaction(inbound.messageId, QUEUED_REACTION, routeLease);
|
|
6321
7701
|
return;
|
|
6322
7702
|
}
|
|
7703
|
+
if (!routeAllows()) return;
|
|
6323
7704
|
session.ws.send(
|
|
6324
7705
|
JSON.stringify(
|
|
6325
7706
|
cfg
|
|
@@ -6340,8 +7721,12 @@ export class TelegramNotificationDaemon {
|
|
|
6340
7721
|
// flipped to consumed when the session acks the turn that picks it
|
|
6341
7722
|
// up. Config commands are not user turns and get no reaction.
|
|
6342
7723
|
if (!cfg && inbound.messageId !== undefined) {
|
|
6343
|
-
|
|
6344
|
-
|
|
7724
|
+
if (!routeAllows()) return;
|
|
7725
|
+
this.inboundReactions.set(inbound.updateId, {
|
|
7726
|
+
messageId: inbound.messageId,
|
|
7727
|
+
socketLease: routeLease,
|
|
7728
|
+
});
|
|
7729
|
+
await this.setReaction(inbound.messageId, QUEUED_REACTION, routeLease);
|
|
6345
7730
|
}
|
|
6346
7731
|
}
|
|
6347
7732
|
return;
|
|
@@ -6355,8 +7740,14 @@ export class TelegramNotificationDaemon {
|
|
|
6355
7740
|
pairedChatId: this.opts.chatId,
|
|
6356
7741
|
});
|
|
6357
7742
|
if (decision.kind === "reply") {
|
|
6358
|
-
const session = this.sessions.get(decision.sessionId);
|
|
6359
|
-
if (
|
|
7743
|
+
const session = this.logicalSessionOwners.get(decision.sessionId) ?? this.sessions.get(decision.sessionId);
|
|
7744
|
+
if (
|
|
7745
|
+
session?.ws.readyState !== WebSocket.OPEN ||
|
|
7746
|
+
!session.pending.has(decision.actionId) ||
|
|
7747
|
+
!this.#leaseAllows(session, decision.sessionId) ||
|
|
7748
|
+
(this.topics.get(decision.sessionId) !== undefined &&
|
|
7749
|
+
!this.topicAuthorityLeaseFromRegistry(decision.sessionId))
|
|
7750
|
+
) {
|
|
6360
7751
|
await this.sendStaleGuidance(callbackId);
|
|
6361
7752
|
return;
|
|
6362
7753
|
}
|
|
@@ -6407,31 +7798,38 @@ export class TelegramNotificationDaemon {
|
|
|
6407
7798
|
}
|
|
6408
7799
|
|
|
6409
7800
|
async run(): Promise<void> {
|
|
6410
|
-
// Runtime callers can bypass TypeScript's option type.
|
|
6411
|
-
//
|
|
6412
|
-
|
|
6413
|
-
|
|
6414
|
-
await this.startLifecycleControl();
|
|
6415
|
-
return;
|
|
6416
|
-
}
|
|
6417
|
-
this.running = await renewDaemonHeartbeat({
|
|
6418
|
-
settings: this.opts.settings,
|
|
6419
|
-
ownerId: this.opts.ownerId,
|
|
6420
|
-
acquisitionId: this.opts.ownerId,
|
|
6421
|
-
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
6422
|
-
chatId: this.opts.chatId,
|
|
6423
|
-
fs: this.fsImpl,
|
|
6424
|
-
now: this.opts.now,
|
|
6425
|
-
pid: this.opts.pid ?? process.pid,
|
|
6426
|
-
pidIncarnation: this.opts.pidIncarnation,
|
|
6427
|
-
});
|
|
6428
|
-
if (!this.running) return;
|
|
6429
|
-
this.runtime.start();
|
|
6430
|
-
this.startOwnershipHeartbeatTimer();
|
|
6431
|
-
this.startFlushTimer();
|
|
6432
|
-
this.startScanTimer();
|
|
6433
|
-
this.startTypingTimer();
|
|
7801
|
+
// Runtime callers can bypass TypeScript's option type. Without a valid bot
|
|
7802
|
+
// token, there is no authenticated daemon identity or lifecycle authority.
|
|
7803
|
+
if (!validBotToken(this.opts.botToken)) return;
|
|
7804
|
+
let ownershipProved = false;
|
|
6434
7805
|
try {
|
|
7806
|
+
const renewed = await renewDaemonHeartbeat({
|
|
7807
|
+
settings: this.opts.settings,
|
|
7808
|
+
ownerId: this.opts.ownerId,
|
|
7809
|
+
acquisitionId: this.opts.ownerId,
|
|
7810
|
+
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
7811
|
+
chatId: this.opts.chatId,
|
|
7812
|
+
fs: this.fsImpl,
|
|
7813
|
+
now: this.opts.now,
|
|
7814
|
+
pid: this.opts.pid ?? process.pid,
|
|
7815
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
7816
|
+
});
|
|
7817
|
+
if (!renewed) return;
|
|
7818
|
+
ownershipProved = true;
|
|
7819
|
+
this.running = !this.stopRequested;
|
|
7820
|
+
if (!this.running) return;
|
|
7821
|
+
// Owner-only: start lifecycle control immediately after ownership proof,
|
|
7822
|
+
// before timers or pre-poll startup work can invalidate this run.
|
|
7823
|
+
// Best-effort; notification delivery remains available on failure.
|
|
7824
|
+
await this.startLifecycleControl();
|
|
7825
|
+
// A stop may arrive while lifecycle startup awaits its control endpoint.
|
|
7826
|
+
// Do not re-enable runtime work after that stop; close the partial server.
|
|
7827
|
+
if (!this.running) return;
|
|
7828
|
+
this.runtime.start();
|
|
7829
|
+
this.startOwnershipHeartbeatTimer();
|
|
7830
|
+
this.startFlushTimer();
|
|
7831
|
+
this.startScanTimer();
|
|
7832
|
+
this.startTypingTimer();
|
|
6435
7833
|
await this.refreshBotIdentity();
|
|
6436
7834
|
await this.registerBotCommands();
|
|
6437
7835
|
await this.loadAliases();
|
|
@@ -6439,9 +7837,6 @@ export class TelegramNotificationDaemon {
|
|
|
6439
7837
|
await this.loadSeenUpdateIds();
|
|
6440
7838
|
await this.replyStore.load();
|
|
6441
7839
|
await this.runScan();
|
|
6442
|
-
// Owner-only: start the session-lifecycle control server now that
|
|
6443
|
-
// ownership is confirmed (singleton-safe). Best-effort; degrades.
|
|
6444
|
-
await this.startLifecycleControl();
|
|
6445
7840
|
let idleSince = this.runtime.now();
|
|
6446
7841
|
while (this.running) {
|
|
6447
7842
|
if (await this.controlStopRequested()) break;
|
|
@@ -6493,61 +7888,64 @@ export class TelegramNotificationDaemon {
|
|
|
6493
7888
|
await this.runtime.sleep(10);
|
|
6494
7889
|
}
|
|
6495
7890
|
} finally {
|
|
6496
|
-
|
|
6497
|
-
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
6507
|
-
|
|
6508
|
-
|
|
6509
|
-
|
|
6510
|
-
|
|
6511
|
-
|
|
6512
|
-
|
|
6513
|
-
|
|
6514
|
-
|
|
6515
|
-
|
|
6516
|
-
|
|
6517
|
-
|
|
6518
|
-
|
|
6519
|
-
|
|
6520
|
-
|
|
6521
|
-
|
|
6522
|
-
|
|
6523
|
-
const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
|
|
6524
|
-
const completed = await Promise.race([
|
|
6525
|
-
shutdown.then(
|
|
6526
|
-
() => true,
|
|
6527
|
-
error => {
|
|
6528
|
-
logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
|
|
6529
|
-
return false;
|
|
6530
|
-
},
|
|
6531
|
-
),
|
|
6532
|
-
deadline.promise,
|
|
6533
|
-
]);
|
|
6534
|
-
clearTimeout(deadlineTimer);
|
|
6535
|
-
const quiesced = completed && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
|
|
6536
|
-
if (!quiesced || !persisted) {
|
|
6537
|
-
logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
|
|
6538
|
-
} else {
|
|
6539
|
-
await releaseDaemonOwnership({
|
|
6540
|
-
settings: this.opts.settings,
|
|
6541
|
-
ownerId: this.opts.ownerId,
|
|
6542
|
-
acquisitionId: this.opts.ownerId,
|
|
6543
|
-
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
6544
|
-
chatId: this.opts.chatId,
|
|
6545
|
-
pid: this.opts.pid ?? process.pid,
|
|
6546
|
-
generation: DAEMON_GENERATION,
|
|
6547
|
-
pidIncarnation: this.opts.pidIncarnation,
|
|
6548
|
-
fs: this.fsImpl,
|
|
6549
|
-
now: this.opts.now,
|
|
7891
|
+
// A contender must not mutate durable owner state while unwinding startup.
|
|
7892
|
+
if (ownershipProved) {
|
|
7893
|
+
let toolShutdownError: unknown;
|
|
7894
|
+
try {
|
|
7895
|
+
await this.beginToolActivityShutdown();
|
|
7896
|
+
} catch (error) {
|
|
7897
|
+
toolShutdownError = error;
|
|
7898
|
+
}
|
|
7899
|
+
this.effects.beginShutdown();
|
|
7900
|
+
this.#deliveryAbort.abort();
|
|
7901
|
+
let persisted = false;
|
|
7902
|
+
const shutdown = this.effects.allowTerminal(async () => {
|
|
7903
|
+
if (toolShutdownError) throw toolShutdownError;
|
|
7904
|
+
await this.#drainBtwTurns();
|
|
7905
|
+
await this.toolTerminalizationChain;
|
|
7906
|
+
this.runtime.stop();
|
|
7907
|
+
this.stopOwnershipHeartbeatTimer();
|
|
7908
|
+
this.stopFlushTimer();
|
|
7909
|
+
this.stopScanTimer();
|
|
7910
|
+
this.stopTypingTimer();
|
|
7911
|
+
this.stopLifecycleControl();
|
|
7912
|
+
await this.cleanupAllAttachmentDirs();
|
|
7913
|
+
await this.persistAliases();
|
|
7914
|
+
await this.persistTopics();
|
|
7915
|
+
await this.persistSeenUpdateIds();
|
|
7916
|
+
await this.opts.control?.clear?.(this.opts.ownerId);
|
|
7917
|
+
persisted = true;
|
|
6550
7918
|
});
|
|
7919
|
+
const deadline = Promise.withResolvers<boolean>();
|
|
7920
|
+
const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
|
|
7921
|
+
const completed = await Promise.race([
|
|
7922
|
+
shutdown.then(
|
|
7923
|
+
() => true,
|
|
7924
|
+
error => {
|
|
7925
|
+
logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
|
|
7926
|
+
return false;
|
|
7927
|
+
},
|
|
7928
|
+
),
|
|
7929
|
+
deadline.promise,
|
|
7930
|
+
]);
|
|
7931
|
+
clearTimeout(deadlineTimer);
|
|
7932
|
+
const quiesced = completed && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
|
|
7933
|
+
if (!quiesced || !persisted) {
|
|
7934
|
+
logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
|
|
7935
|
+
} else {
|
|
7936
|
+
await releaseDaemonOwnership({
|
|
7937
|
+
settings: this.opts.settings,
|
|
7938
|
+
ownerId: this.opts.ownerId,
|
|
7939
|
+
acquisitionId: this.opts.ownerId,
|
|
7940
|
+
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
7941
|
+
chatId: this.opts.chatId,
|
|
7942
|
+
pid: this.opts.pid ?? process.pid,
|
|
7943
|
+
generation: DAEMON_GENERATION,
|
|
7944
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
7945
|
+
fs: this.fsImpl,
|
|
7946
|
+
now: this.opts.now,
|
|
7947
|
+
});
|
|
7948
|
+
}
|
|
6551
7949
|
}
|
|
6552
7950
|
}
|
|
6553
7951
|
}
|