@gajae-code/coding-agent 0.11.6 → 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 +56 -6
- 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 +2 -1
- 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 +8 -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 +25 -4
- 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 +2 -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 +57 -6
- 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 +8 -7
- package/src/internal-urls/local-protocol.ts +32 -6
- package/src/main.ts +86 -27
- 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/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/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 +4 -3
- package/src/sdk/bus/index.ts +4 -5
- 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 +8 -3
- package/src/sdk/bus/telegram-daemon-control.ts +2 -2
- package/src/sdk/bus/telegram-daemon.ts +1663 -375
- 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/protocol/operation-inventory.generated.json +11 -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 +240 -149
- 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 +319 -85
- 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 +1 -1
- package/src/ssh/utils.ts +14 -0
- package/src/task/discovery.ts +8 -2
- 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 {
|
|
@@ -1050,7 +1067,7 @@ function classifyForeignLiveOwner(input: {
|
|
|
1050
1067
|
if (!state || !validDaemonPid(state.pid) || !input.pidAlive(state.pid)) return undefined;
|
|
1051
1068
|
// A stopped tombstone with a canonical acquisition is not a live owner.
|
|
1052
1069
|
if (isStoppedDaemonState(state) || isLegacyParentDaemonState(state)) return undefined;
|
|
1053
|
-
const currentIncarnation = input.pidIncarnation
|
|
1070
|
+
const currentIncarnation = input.pidIncarnation ? input.pidIncarnation(state.pid) : defaultPidIncarnation(state.pid);
|
|
1054
1071
|
if (
|
|
1055
1072
|
!hasSafeDaemonStateShape(state) ||
|
|
1056
1073
|
!isProcessIncarnation(state.incarnation) ||
|
|
@@ -1239,6 +1256,16 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1239
1256
|
return { acquired: false, attached: false, provisional: true };
|
|
1240
1257
|
};
|
|
1241
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 };
|
|
1242
1269
|
const foreignOwner = classifyForeignLiveOwner({
|
|
1243
1270
|
state: existing,
|
|
1244
1271
|
tokenFingerprint: input.tokenFingerprint,
|
|
@@ -1858,8 +1885,8 @@ function defaultPidAlive(pid: number): boolean {
|
|
|
1858
1885
|
try {
|
|
1859
1886
|
process.kill(pid, 0);
|
|
1860
1887
|
return true;
|
|
1861
|
-
} catch {
|
|
1862
|
-
return
|
|
1888
|
+
} catch (error) {
|
|
1889
|
+
return (error as NodeJS.ErrnoException).code !== "ESRCH";
|
|
1863
1890
|
}
|
|
1864
1891
|
}
|
|
1865
1892
|
|
|
@@ -2044,35 +2071,49 @@ export async function spawnTelegramDaemonOwner(
|
|
|
2044
2071
|
}
|
|
2045
2072
|
|
|
2046
2073
|
/**
|
|
2047
|
-
* Owner-bound reclamation of a confirmed-dead daemon owner
|
|
2048
|
-
* daemon step of `gjc notify recovery`.
|
|
2049
|
-
*
|
|
2050
|
-
*
|
|
2051
|
-
*
|
|
2052
|
-
* owner (e.g. after a crash, or a `stop --force` that left the record behind)
|
|
2053
|
-
* instead of requiring manual reconciliation. Returns true when a stale lock was
|
|
2054
|
-
* cleared and a spawn retry is worthwhile.
|
|
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.
|
|
2055
2079
|
*/
|
|
2056
|
-
|
|
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: {
|
|
2057
2097
|
settings: Settings;
|
|
2098
|
+
endpointDir?: string;
|
|
2058
2099
|
fs?: TelegramDaemonFs;
|
|
2059
2100
|
now?: () => number;
|
|
2060
2101
|
pidAlive?: (pid: number) => boolean;
|
|
2061
2102
|
pidIncarnation?: (pid: number) => string | undefined;
|
|
2062
|
-
}): Promise<
|
|
2103
|
+
}): Promise<DeadOwnerRecoveryResult> {
|
|
2063
2104
|
const fsImpl = input.fs ?? nodeFs;
|
|
2064
2105
|
const now = input.now ?? Date.now;
|
|
2065
2106
|
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
2066
2107
|
const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
|
|
2067
2108
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
2068
2109
|
const state = await readDaemonState(input.settings, fsImpl);
|
|
2069
|
-
if (!state || pidAlive(state.pid))
|
|
2110
|
+
if (!state || !hasSafeDaemonStateShape(state) || pidAlive(state.pid))
|
|
2111
|
+
return { recovered: false, reason: "not-confirmed-dead" };
|
|
2070
2112
|
const lock = await readOwnershipLock(fsImpl, paths.lock);
|
|
2071
|
-
if (lock.kind === "missing") return false;
|
|
2072
2113
|
if (
|
|
2073
2114
|
!(await ownershipLockIsReclaimable({ fs: fsImpl, path: paths.lock, lock, now: now(), pidAlive, pidIncarnation }))
|
|
2074
2115
|
)
|
|
2075
|
-
return false;
|
|
2116
|
+
return { recovered: false, reason: "unsafe-lock" };
|
|
2076
2117
|
const transition = await acquireDaemonTransitionLock({
|
|
2077
2118
|
fs: fsImpl,
|
|
2078
2119
|
path: paths.steal,
|
|
@@ -2080,18 +2121,79 @@ async function reclaimDeadDaemonOwner(input: {
|
|
|
2080
2121
|
pidAlive: defaultPidAlive,
|
|
2081
2122
|
pidIncarnation: defaultPidIncarnation,
|
|
2082
2123
|
});
|
|
2083
|
-
|
|
2124
|
+
const readEndpointFile = fsImpl.readEndpointFile;
|
|
2125
|
+
if (!transition || !readEndpointFile || !fsImpl.exactUnlink)
|
|
2126
|
+
return { recovered: false, reason: "transition-contended" };
|
|
2084
2127
|
try {
|
|
2085
2128
|
const current = await readDaemonState(input.settings, fsImpl);
|
|
2086
|
-
if (
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
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
|
+
}
|
|
2094
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" };
|
|
2095
2197
|
} finally {
|
|
2096
2198
|
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
2097
2199
|
}
|
|
@@ -2109,6 +2211,24 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
2109
2211
|
if (!isTelegramConfigured(cfg)) return "disabled";
|
|
2110
2212
|
const root = notificationRootForCwd(input.cwd);
|
|
2111
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
|
+
}
|
|
2112
2232
|
let spawned = await withTelegramSetupLease(
|
|
2113
2233
|
cfg.botToken,
|
|
2114
2234
|
async () =>
|
|
@@ -2150,31 +2270,31 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
2150
2270
|
);
|
|
2151
2271
|
}
|
|
2152
2272
|
}
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
(await reclaimDeadDaemonOwner({
|
|
2273
|
+
let recoveryReason: Extract<DeadOwnerRecoveryResult, { recovered: false }>["reason"] | undefined;
|
|
2274
|
+
if (spawned.result === "blocked" && !spawned.warnings[0]?.includes("provisional")) {
|
|
2275
|
+
const recovery = await reclaimDeadDaemonOwner({
|
|
2157
2276
|
settings: input.settings,
|
|
2277
|
+
endpointDir: path.join(root, "sdk"),
|
|
2158
2278
|
fs: deps.fs,
|
|
2159
2279
|
now: deps.now,
|
|
2160
2280
|
pidAlive: deps.pidAlive,
|
|
2161
2281
|
pidIncarnation: deps.pidIncarnation,
|
|
2162
|
-
})
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
),
|
|
2174
|
-
);
|
|
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;
|
|
2175
2293
|
}
|
|
2176
2294
|
if (spawned.result === "blocked") {
|
|
2177
|
-
logger.warn(
|
|
2295
|
+
logger.warn(
|
|
2296
|
+
`notifications: failed to ensure Telegram daemon: ${recoveryReason ? `stale recovery ${recoveryReason}; run \`gjc notify recovery\`` : spawned.warnings.join("; ")}`,
|
|
2297
|
+
);
|
|
2178
2298
|
return "blocked_identity";
|
|
2179
2299
|
}
|
|
2180
2300
|
if (spawned.result === "attached" && spawned.reloadRequired) {
|
|
@@ -2521,7 +2641,10 @@ export class TelegramUpdatePoller {
|
|
|
2521
2641
|
/** Mutable dispatch state shared by session frames and inbound Telegram updates. */
|
|
2522
2642
|
export class TelegramEventDispatchState {
|
|
2523
2643
|
readonly busy = new Set<string>();
|
|
2524
|
-
readonly inboundReactions = new Map<
|
|
2644
|
+
readonly inboundReactions = new Map<
|
|
2645
|
+
number,
|
|
2646
|
+
{ messageId: number; socketLease?: { session: SessionSocket; token: number; logicalSessionId: string } }
|
|
2647
|
+
>();
|
|
2525
2648
|
readonly seenUpdateIds = new Set<number>();
|
|
2526
2649
|
}
|
|
2527
2650
|
|
|
@@ -2610,11 +2733,16 @@ interface SessionSocket {
|
|
|
2610
2733
|
sessionId: string;
|
|
2611
2734
|
/** Current logical session carried by valid threaded frames on this transport. */
|
|
2612
2735
|
logicalSessionId: string;
|
|
2736
|
+
/** Endpoint metadata proved this logical id belongs to this discovery record. */
|
|
2737
|
+
logicalSessionIdTrusted: boolean;
|
|
2613
2738
|
token: string;
|
|
2614
2739
|
endpointKey: string;
|
|
2615
2740
|
endpointDigest: string;
|
|
2616
2741
|
hostGeneration: number;
|
|
2617
2742
|
ws: WebSocket;
|
|
2743
|
+
/** Timestamp (via opts.now) at which this socket began connecting. */
|
|
2744
|
+
connectingSince: number;
|
|
2745
|
+
|
|
2618
2746
|
pending: Map<string, { sessionId: string; actionId: string }>;
|
|
2619
2747
|
/** True once the server advertised the `client_ping_pong` capability. */
|
|
2620
2748
|
capable: boolean;
|
|
@@ -2630,11 +2758,20 @@ interface SessionSocket {
|
|
|
2630
2758
|
/** Queues live frames until startup replay is applied. */
|
|
2631
2759
|
replayPending: boolean;
|
|
2632
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
|
+
};
|
|
2633
2768
|
}
|
|
2634
2769
|
|
|
2635
2770
|
interface ModelChoiceRoute {
|
|
2636
2771
|
/** Exact transport socket that rendered this choice. */
|
|
2637
2772
|
session: SessionSocket;
|
|
2773
|
+
/** Immutable owner lease captured when this choice was rendered. */
|
|
2774
|
+
socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
2638
2775
|
/** Logical session current when this choice was rendered. */
|
|
2639
2776
|
sessionId: string;
|
|
2640
2777
|
selector: string;
|
|
@@ -2665,7 +2802,8 @@ interface ToolActivityOwner {
|
|
|
2665
2802
|
interface PendingThreadedFrame {
|
|
2666
2803
|
send: ThreadedSend;
|
|
2667
2804
|
msg: Record<string, unknown>;
|
|
2668
|
-
|
|
2805
|
+
logicalSessionId: string;
|
|
2806
|
+
socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
2669
2807
|
toolActivity?: ToolActivityOwner;
|
|
2670
2808
|
}
|
|
2671
2809
|
|
|
@@ -2690,6 +2828,8 @@ interface SelectedAckQueueItem {
|
|
|
2690
2828
|
requestId: string;
|
|
2691
2829
|
commitKey: string;
|
|
2692
2830
|
session: SessionSocket;
|
|
2831
|
+
/** Immutable owner lease captured when this acknowledgement was admitted. */
|
|
2832
|
+
socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
2693
2833
|
state: "queued" | "dispatching" | "sending";
|
|
2694
2834
|
controller?: AbortController;
|
|
2695
2835
|
followers: Array<{ pendingKey: string; requestId: string; commitKey: string }>;
|
|
@@ -2698,6 +2838,8 @@ interface SelectedAckQueueItem {
|
|
|
2698
2838
|
interface TelegramQueuePayload {
|
|
2699
2839
|
send: ThreadedSend;
|
|
2700
2840
|
topicLease?: TopicAuthorityLease;
|
|
2841
|
+
/** Immutable owner lease captured when the work was admitted. */
|
|
2842
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
2701
2843
|
selectedAck?: SelectedAckQueueItem;
|
|
2702
2844
|
btwDelivery?: BtwQueuedDelivery;
|
|
2703
2845
|
toolActivity?: ToolActivityOwner;
|
|
@@ -2708,6 +2850,10 @@ interface PendingBtwTurn {
|
|
|
2708
2850
|
transportSessionId: string;
|
|
2709
2851
|
/** Logical session id supplied to the session endpoint. */
|
|
2710
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;
|
|
2711
2857
|
endpointDigest: string;
|
|
2712
2858
|
generation: number;
|
|
2713
2859
|
question: string;
|
|
@@ -2810,12 +2956,22 @@ export class TelegramNotificationDaemon {
|
|
|
2810
2956
|
private readonly pollConflictBackoff = new OperatorBackoffPolicy({ initialMs: 500, maxMs: 5_000 });
|
|
2811
2957
|
private readonly loopBackoff = new OperatorBackoffPolicy({ initialMs: 250, maxMs: 4_000 });
|
|
2812
2958
|
private running = false;
|
|
2959
|
+
/** Once set, a concurrent startup await can never restore a running daemon. */
|
|
2960
|
+
private stopRequested = false;
|
|
2961
|
+
|
|
2813
2962
|
private readonly fsImpl: TelegramDaemonFs;
|
|
2814
2963
|
private readonly botApi: BotApi;
|
|
2815
2964
|
private readonly effects = new TelegramEffectSupervisor();
|
|
2816
2965
|
private readonly topics = new TopicRegistry();
|
|
2817
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>();
|
|
2818
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
|
+
|
|
2819
2975
|
/** Daemon edit attempts that can race an accepted user service message. */
|
|
2820
2976
|
private readonly daemonRenameAttempts = new Map<string, number>();
|
|
2821
2977
|
private readonly selectedAckPending = new Map<string, SelectedAckQueueItem>();
|
|
@@ -2837,10 +2993,17 @@ export class TelegramNotificationDaemon {
|
|
|
2837
2993
|
private readonly draftStream = new DraftStreamState();
|
|
2838
2994
|
/** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
|
|
2839
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>();
|
|
2840
3000
|
/** Non-identity frames held until identity creates the correct thread. */
|
|
2841
3001
|
private readonly pendingThreadedFrames = new Map<string, PendingThreadedFrame[]>();
|
|
2842
|
-
/**
|
|
2843
|
-
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;
|
|
2844
3007
|
/** True once the daemon has nudged the user to enable Threaded Mode. */
|
|
2845
3008
|
private threadedFallbackNoticeSent = false;
|
|
2846
3009
|
/** Sessions whose identity header was already sent flat (Threaded Mode off). */
|
|
@@ -2854,7 +3017,10 @@ export class TelegramNotificationDaemon {
|
|
|
2854
3017
|
return this.dispatchState.busy;
|
|
2855
3018
|
}
|
|
2856
3019
|
/** Inbound update id → originating Telegram message, for delivery reactions. */
|
|
2857
|
-
private get inboundReactions(): Map<
|
|
3020
|
+
private get inboundReactions(): Map<
|
|
3021
|
+
number,
|
|
3022
|
+
{ messageId: number; socketLease?: { session: SessionSocket; token: number; logicalSessionId: string } }
|
|
3023
|
+
> {
|
|
2858
3024
|
return this.dispatchState.inboundReactions;
|
|
2859
3025
|
}
|
|
2860
3026
|
/**
|
|
@@ -2890,7 +3056,7 @@ export class TelegramNotificationDaemon {
|
|
|
2890
3056
|
this.selectedAckPending.delete(item.pendingKey);
|
|
2891
3057
|
for (const follower of item.followers) this.selectedAckPending.delete(follower.pendingKey);
|
|
2892
3058
|
this.cacheSelectedAck(item.cacheKey, outcome);
|
|
2893
|
-
if (item.session.ws.readyState === WebSocket.OPEN) {
|
|
3059
|
+
if (this.#leaseTokenAllows(item.socketLease) && item.session.ws.readyState === WebSocket.OPEN) {
|
|
2894
3060
|
for (const result of [{ requestId: item.requestId, commitKey: item.commitKey }, ...item.followers]) {
|
|
2895
3061
|
item.session.ws.send(
|
|
2896
3062
|
JSON.stringify({
|
|
@@ -2910,6 +3076,8 @@ export class TelegramNotificationDaemon {
|
|
|
2910
3076
|
* ~25s getUpdates timeout. Safe to call from a signal handler.
|
|
2911
3077
|
*/
|
|
2912
3078
|
requestStop(_reason?: "reload" | "stop" | "signal"): void {
|
|
3079
|
+
this.stopRequested = true;
|
|
3080
|
+
|
|
2913
3081
|
const toolShutdown = this.beginToolActivityShutdown();
|
|
2914
3082
|
void toolShutdown
|
|
2915
3083
|
.finally(() => {
|
|
@@ -3231,7 +3399,7 @@ export class TelegramNotificationDaemon {
|
|
|
3231
3399
|
|
|
3232
3400
|
const frame = this.buildLifecycleFrame(parsed, updateId ?? Date.now());
|
|
3233
3401
|
const response = await this.submitLifecycleFrame(frame);
|
|
3234
|
-
await reply(this.formatLifecycleResponse(response));
|
|
3402
|
+
await reply(this.formatLifecycleResponse(response, verb));
|
|
3235
3403
|
return true;
|
|
3236
3404
|
}
|
|
3237
3405
|
|
|
@@ -3247,8 +3415,8 @@ export class TelegramNotificationDaemon {
|
|
|
3247
3415
|
}
|
|
3248
3416
|
|
|
3249
3417
|
/** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
|
|
3250
|
-
private formatLifecycleResponse(r: SessionLifecycleResponse): string {
|
|
3251
|
-
return formatLifecycleOutcome(r);
|
|
3418
|
+
private formatLifecycleResponse(r: SessionLifecycleResponse, verb: LifecycleCommandVerb): string {
|
|
3419
|
+
return formatLifecycleOutcome(r, verb);
|
|
3252
3420
|
}
|
|
3253
3421
|
|
|
3254
3422
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
@@ -3330,10 +3498,14 @@ export class TelegramNotificationDaemon {
|
|
|
3330
3498
|
finishImmediately({ status: "failed", reason: "route_missing" });
|
|
3331
3499
|
return;
|
|
3332
3500
|
}
|
|
3333
|
-
const
|
|
3501
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
3502
|
+
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
3503
|
+
if (!socketLease) return;
|
|
3504
|
+
const topicLease = this.topicAuthorityLeaseFromRegistry(logicalSessionId);
|
|
3334
3505
|
if (
|
|
3335
|
-
this.topics.get(
|
|
3336
|
-
(
|
|
3506
|
+
this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
|
|
3507
|
+
this.topics.get(logicalSessionId)?.bindingMalformed ||
|
|
3508
|
+
(mode === "recovery" && (!topicLease || msg.sessionId !== logicalSessionId))
|
|
3337
3509
|
) {
|
|
3338
3510
|
finishImmediately({ status: "failed", reason: "route_missing" });
|
|
3339
3511
|
return;
|
|
@@ -3359,13 +3531,14 @@ export class TelegramNotificationDaemon {
|
|
|
3359
3531
|
requestId,
|
|
3360
3532
|
commitKey,
|
|
3361
3533
|
session,
|
|
3534
|
+
socketLease,
|
|
3362
3535
|
state: "queued",
|
|
3363
3536
|
followers: [],
|
|
3364
3537
|
};
|
|
3365
3538
|
this.selectedAckPending.set(pendingKey, item);
|
|
3366
3539
|
this.submitPool({
|
|
3367
|
-
sessionId: session.sessionId,
|
|
3368
3540
|
lane: "ask",
|
|
3541
|
+
sessionId: logicalSessionId,
|
|
3369
3542
|
itemId: item.itemId,
|
|
3370
3543
|
deadlineAt,
|
|
3371
3544
|
payload: {
|
|
@@ -3442,34 +3615,117 @@ export class TelegramNotificationDaemon {
|
|
|
3442
3615
|
name: "activity",
|
|
3443
3616
|
matches: msg => msg.type === "activity",
|
|
3444
3617
|
handle: async (session, msg) => {
|
|
3618
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
3445
3619
|
if (msg.state === "busy") {
|
|
3446
|
-
this.busy.add(
|
|
3447
|
-
await this.sendTyping(session
|
|
3620
|
+
this.busy.add(logicalSessionId);
|
|
3621
|
+
await this.sendTyping(logicalSessionId, this.#socketLease(session, logicalSessionId));
|
|
3448
3622
|
} else {
|
|
3449
|
-
this.busy.delete(
|
|
3623
|
+
this.busy.delete(logicalSessionId);
|
|
3450
3624
|
}
|
|
3451
3625
|
},
|
|
3452
3626
|
})
|
|
3453
3627
|
.add({
|
|
3454
3628
|
name: "inbound_ack",
|
|
3455
3629
|
matches: msg => msg.type === "inbound_ack" && typeof msg.updateId === "number",
|
|
3456
|
-
handle: async (
|
|
3630
|
+
handle: async (session, msg) => {
|
|
3457
3631
|
const target = this.inboundReactions.get(msg.updateId as number);
|
|
3458
3632
|
if (target && msg.state === "consumed") {
|
|
3459
3633
|
this.inboundReactions.delete(msg.updateId as number);
|
|
3460
|
-
await this.setReaction(
|
|
3634
|
+
await this.setReaction(
|
|
3635
|
+
target.messageId,
|
|
3636
|
+
CONSUMED_REACTION,
|
|
3637
|
+
target.socketLease ?? this.#socketLease(session),
|
|
3638
|
+
);
|
|
3461
3639
|
}
|
|
3462
3640
|
},
|
|
3463
3641
|
})
|
|
3464
3642
|
.add({
|
|
3465
3643
|
name: "session_closed",
|
|
3466
3644
|
matches: msg => msg.type === "session_closed",
|
|
3467
|
-
handle: async session => {
|
|
3468
|
-
this
|
|
3469
|
-
|
|
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);
|
|
3470
3659
|
await this.#terminalizeBtwTurnsForSession(session, true);
|
|
3471
|
-
|
|
3472
|
-
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
|
+
});
|
|
3473
3729
|
},
|
|
3474
3730
|
});
|
|
3475
3731
|
}
|
|
@@ -3550,7 +3806,27 @@ export class TelegramNotificationDaemon {
|
|
|
3550
3806
|
return true;
|
|
3551
3807
|
}
|
|
3552
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
|
+
|
|
3553
3828
|
async scanRoots(): Promise<void> {
|
|
3829
|
+
await this.reconcilePendingTopicDeletes();
|
|
3554
3830
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
3555
3831
|
const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
|
|
3556
3832
|
const endpointSessionIds = new Set<string>();
|
|
@@ -3573,6 +3849,11 @@ export class TelegramNotificationDaemon {
|
|
|
3573
3849
|
// A hard-killed owner can leave both its endpoint file and socket map
|
|
3574
3850
|
// entry behind; skipping the read in that case permanently preserves
|
|
3575
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
|
+
}
|
|
3576
3857
|
const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
|
|
3577
3858
|
if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) {
|
|
3578
3859
|
const connected = this.sessions.get(sessionId);
|
|
@@ -3581,22 +3862,36 @@ export class TelegramNotificationDaemon {
|
|
|
3581
3862
|
await this.observeOrphanedTopic(sessionId);
|
|
3582
3863
|
continue;
|
|
3583
3864
|
}
|
|
3865
|
+
|
|
3584
3866
|
if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
|
|
3585
3867
|
const endpointKey = endpointGenerationKey(endpoint.url, endpoint.token);
|
|
3586
3868
|
const connected = this.sessions.get(sessionId);
|
|
3587
3869
|
if (connected) {
|
|
3588
|
-
if (
|
|
3870
|
+
if (
|
|
3871
|
+
connected.endpointKey !== endpointKey ||
|
|
3872
|
+
(connected.ws.readyState === WebSocket.CONNECTING &&
|
|
3873
|
+
this.runtime.now() - connected.connectingSince >= CONNECTING_RECONNECT_MS)
|
|
3874
|
+
)
|
|
3589
3875
|
this.connectSession(sessionId, endpoint.url, endpoint.token);
|
|
3590
3876
|
continue;
|
|
3591
3877
|
}
|
|
3592
|
-
|
|
3593
|
-
|
|
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
|
+
}
|
|
3594
3884
|
this.connectSession(sessionId, endpoint.url, endpoint.token);
|
|
3595
3885
|
} catch {}
|
|
3596
3886
|
}
|
|
3597
3887
|
}
|
|
3598
3888
|
if (allRootsReadable) {
|
|
3599
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
|
+
}
|
|
3600
3895
|
if (!this.sessions.has(sessionId) && !endpointSessionIds.has(sessionId)) {
|
|
3601
3896
|
this.#terminalizeBtwTurnsForTransportSession(sessionId);
|
|
3602
3897
|
await this.observeOrphanedTopic(sessionId);
|
|
@@ -3610,7 +3905,6 @@ export class TelegramNotificationDaemon {
|
|
|
3610
3905
|
const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
|
|
3611
3906
|
const endpointKey = endpointGenerationKey(url, token);
|
|
3612
3907
|
const endpointDigest = endpointAuthorityDigest(url, token);
|
|
3613
|
-
this.closedEndpointKeys.delete(sessionId);
|
|
3614
3908
|
const existing = this.sessions.get(sessionId);
|
|
3615
3909
|
if (existing) {
|
|
3616
3910
|
this.dropSession(
|
|
@@ -3625,12 +3919,13 @@ export class TelegramNotificationDaemon {
|
|
|
3625
3919
|
const session: SessionSocket = {
|
|
3626
3920
|
sessionId,
|
|
3627
3921
|
logicalSessionId: sessionId,
|
|
3628
|
-
|
|
3922
|
+
logicalSessionIdTrusted: false,
|
|
3629
3923
|
token,
|
|
3630
3924
|
endpointKey,
|
|
3631
3925
|
endpointDigest,
|
|
3632
3926
|
hostGeneration: 0,
|
|
3633
3927
|
ws,
|
|
3928
|
+
connectingSince: this.runtime.now(),
|
|
3634
3929
|
pending: new Map(),
|
|
3635
3930
|
capable: false,
|
|
3636
3931
|
ephemeralCapable: false,
|
|
@@ -3642,13 +3937,20 @@ export class TelegramNotificationDaemon {
|
|
|
3642
3937
|
replayQueue: [],
|
|
3643
3938
|
};
|
|
3644
3939
|
this.sessions.set(sessionId, session);
|
|
3940
|
+
if (this.topics.get(sessionId)) this.preservedInitiatorTopics.add(sessionId);
|
|
3941
|
+
|
|
3645
3942
|
// Bidirectional capability advertisement: announce client_ping_pong once the
|
|
3646
3943
|
// socket is open. Sent on "open" only — a real WHATWG WebSocket cannot send
|
|
3647
3944
|
// while CONNECTING — and liveness starts only after a capable ServerHello.
|
|
3648
3945
|
ws.addEventListener("open", () => {
|
|
3946
|
+
if (this.sessions.get(sessionId) !== session) return;
|
|
3649
3947
|
session.replayPending = true;
|
|
3650
3948
|
session.replayQueue = [];
|
|
3651
|
-
|
|
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;
|
|
3652
3954
|
if (session.ws.readyState === WebSocket.OPEN) {
|
|
3653
3955
|
try {
|
|
3654
3956
|
session.ws.send(
|
|
@@ -3676,12 +3978,20 @@ export class TelegramNotificationDaemon {
|
|
|
3676
3978
|
);
|
|
3677
3979
|
} catch {}
|
|
3678
3980
|
}
|
|
3679
|
-
//
|
|
3680
|
-
//
|
|
3681
|
-
//
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
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
|
+
);
|
|
3685
3995
|
});
|
|
3686
3996
|
ws.addEventListener("message", ev => {
|
|
3687
3997
|
// Identity guard: a delayed frame from a superseded socket must not act
|
|
@@ -3856,7 +4166,12 @@ export class TelegramNotificationDaemon {
|
|
|
3856
4166
|
}
|
|
3857
4167
|
|
|
3858
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" };
|
|
3859
4173
|
const isCurrentSession = this.sessions.get(session.sessionId) === session;
|
|
4174
|
+
if (isCurrentSession) this.droppedSessions.add(session);
|
|
3860
4175
|
if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
|
|
3861
4176
|
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
3862
4177
|
if (session.pingTimer) {
|
|
@@ -3874,6 +4189,12 @@ export class TelegramNotificationDaemon {
|
|
|
3874
4189
|
if (isCurrentSession) {
|
|
3875
4190
|
this.#clearModelChoiceAliasesForSocket(session);
|
|
3876
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
|
+
}
|
|
3877
4198
|
}
|
|
3878
4199
|
|
|
3879
4200
|
for (const item of new Set(this.selectedAckPending.values())) {
|
|
@@ -3951,6 +4272,7 @@ export class TelegramNotificationDaemon {
|
|
|
3951
4272
|
void this.#terminalizeBtwTurn(requestId, pending).catch(() => undefined);
|
|
3952
4273
|
}
|
|
3953
4274
|
}
|
|
4275
|
+
|
|
3954
4276
|
#resumeBtwTurnsForSession(session: SessionSocket): void {
|
|
3955
4277
|
if (!session.ephemeralCapable || session.hostGeneration < 1 || session.ws.readyState !== WebSocket.OPEN) return;
|
|
3956
4278
|
const logicalSessionId = this.#logicalSessionId(session);
|
|
@@ -3961,6 +4283,7 @@ export class TelegramNotificationDaemon {
|
|
|
3961
4283
|
continue;
|
|
3962
4284
|
}
|
|
3963
4285
|
if (
|
|
4286
|
+
!this.#leaseTokenAllows(pending.socketLease) ||
|
|
3964
4287
|
pending.transportSessionId !== session.sessionId ||
|
|
3965
4288
|
pending.logicalSessionId !== logicalSessionId ||
|
|
3966
4289
|
pending.endpointDigest !== session.endpointDigest ||
|
|
@@ -3971,6 +4294,9 @@ export class TelegramNotificationDaemon {
|
|
|
3971
4294
|
}
|
|
3972
4295
|
}
|
|
3973
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;
|
|
3974
4300
|
try {
|
|
3975
4301
|
session.ws.send(
|
|
3976
4302
|
JSON.stringify({
|
|
@@ -3984,6 +4310,7 @@ export class TelegramNotificationDaemon {
|
|
|
3984
4310
|
messageId: pending.messageId,
|
|
3985
4311
|
}),
|
|
3986
4312
|
);
|
|
4313
|
+
pending.dispatchedSocketLeaseToken = pending.socketLease.token;
|
|
3987
4314
|
return true;
|
|
3988
4315
|
} catch {
|
|
3989
4316
|
return false;
|
|
@@ -4041,8 +4368,14 @@ export class TelegramNotificationDaemon {
|
|
|
4041
4368
|
parseMode?: typeof TELEGRAM_PARSE_MODE;
|
|
4042
4369
|
allowWhileStopping?: boolean;
|
|
4043
4370
|
signal?: AbortSignal;
|
|
4371
|
+
isAuthoritative?: () => boolean;
|
|
4044
4372
|
}): Promise<unknown> {
|
|
4045
|
-
if (
|
|
4373
|
+
if (
|
|
4374
|
+
(!input.allowWhileStopping && this.#stoppingBtw) ||
|
|
4375
|
+
this.#btwDeliveryAbort.signal.aborted ||
|
|
4376
|
+
(input.isAuthoritative && !input.isAuthoritative())
|
|
4377
|
+
)
|
|
4378
|
+
return undefined;
|
|
4046
4379
|
const signals = [this.#btwDeliveryAbort.signal, AbortSignal.timeout(30_000)];
|
|
4047
4380
|
if (input.signal) signals.unshift(input.signal);
|
|
4048
4381
|
return this.botApi.call(
|
|
@@ -4175,12 +4508,149 @@ export class TelegramNotificationDaemon {
|
|
|
4175
4508
|
return session.logicalSessionId ?? session.sessionId;
|
|
4176
4509
|
}
|
|
4177
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
|
+
|
|
4178
4634
|
#clearModelChoiceAliases(sessionId: string): void {
|
|
4179
4635
|
for (const [alias, route] of this.#modelChoiceAliases) {
|
|
4180
4636
|
if (route.sessionId === sessionId) this.#modelChoiceAliases.delete(alias);
|
|
4181
4637
|
}
|
|
4182
4638
|
}
|
|
4183
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
|
+
|
|
4184
4654
|
#clearModelChoiceAliasesForSocket(session: SessionSocket): void {
|
|
4185
4655
|
for (const [alias, route] of this.#modelChoiceAliases) {
|
|
4186
4656
|
if (route.session === session) this.#modelChoiceAliases.delete(alias);
|
|
@@ -4224,8 +4694,8 @@ export class TelegramNotificationDaemon {
|
|
|
4224
4694
|
"reasoning_summary",
|
|
4225
4695
|
]);
|
|
4226
4696
|
|
|
4227
|
-
/** Rekey
|
|
4228
|
-
#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> {
|
|
4229
4699
|
if (
|
|
4230
4700
|
typeof msg.type !== "string" ||
|
|
4231
4701
|
!TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type) ||
|
|
@@ -4235,10 +4705,187 @@ export class TelegramNotificationDaemon {
|
|
|
4235
4705
|
(msg.type !== "config_update" && !renderThreadedFrame(msg))
|
|
4236
4706
|
)
|
|
4237
4707
|
return;
|
|
4238
|
-
|
|
4239
|
-
this.#
|
|
4240
|
-
|
|
4241
|
-
|
|
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;
|
|
4242
4889
|
}
|
|
4243
4890
|
|
|
4244
4891
|
private topicNameFor(sessionId: string, msg: { title?: unknown; repo?: unknown; branch?: unknown }): string {
|
|
@@ -4320,13 +4967,6 @@ export class TelegramNotificationDaemon {
|
|
|
4320
4967
|
return undefined;
|
|
4321
4968
|
}
|
|
4322
4969
|
|
|
4323
|
-
private sessionCanClaimIdentity(session: SessionSocket, msg: { repo?: unknown; branch?: unknown }): boolean {
|
|
4324
|
-
const current = this.sessions.get(session.sessionId);
|
|
4325
|
-
if (current) return current === session;
|
|
4326
|
-
const ownerId = this.topicOwnerForIdentity(msg);
|
|
4327
|
-
return !ownerId || ownerId === session.sessionId;
|
|
4328
|
-
}
|
|
4329
|
-
|
|
4330
4970
|
private toolActivityOwner(session: SessionSocket, msg: Record<string, unknown>): ToolActivityOwner | undefined {
|
|
4331
4971
|
if (msg.type !== "tool_activity") return undefined;
|
|
4332
4972
|
const toolCallId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
|
|
@@ -4334,7 +4974,7 @@ export class TelegramNotificationDaemon {
|
|
|
4334
4974
|
const phase = typeof msg.phase === "string" ? msg.phase : undefined;
|
|
4335
4975
|
if (!toolCallId || !toolName || !phase) return undefined;
|
|
4336
4976
|
return {
|
|
4337
|
-
sessionId: session
|
|
4977
|
+
sessionId: this.#logicalSessionId(session),
|
|
4338
4978
|
toolCallId,
|
|
4339
4979
|
toolName,
|
|
4340
4980
|
endpointDigest: session.endpointDigest,
|
|
@@ -4345,7 +4985,8 @@ export class TelegramNotificationDaemon {
|
|
|
4345
4985
|
|
|
4346
4986
|
private toolActivityAuthorityIsCurrent(toolActivity: ToolActivityOwner): boolean {
|
|
4347
4987
|
if (this.revokedToolEndpoints.has(toolActivity.endpointDigest)) return false;
|
|
4348
|
-
const session =
|
|
4988
|
+
const session =
|
|
4989
|
+
this.logicalSessionOwners.get(toolActivity.sessionId) ?? this.sessions.get(toolActivity.sessionId);
|
|
4349
4990
|
if (toolActivity.endpointDigest === undefined) return session === undefined;
|
|
4350
4991
|
return session === toolActivity.session && session.endpointDigest === toolActivity.endpointDigest;
|
|
4351
4992
|
}
|
|
@@ -4354,12 +4995,18 @@ export class TelegramNotificationDaemon {
|
|
|
4354
4995
|
send: ThreadedSend,
|
|
4355
4996
|
topicLease: TopicAuthorityLease,
|
|
4356
4997
|
toolActivity?: ToolActivityOwner,
|
|
4998
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
4357
4999
|
): Promise<void> {
|
|
4358
5000
|
this.submitPool({
|
|
4359
5001
|
sessionId,
|
|
4360
5002
|
lane: send.lane,
|
|
4361
5003
|
coalesceKey: send.coalesceKey,
|
|
4362
|
-
payload: {
|
|
5004
|
+
payload: {
|
|
5005
|
+
send,
|
|
5006
|
+
topicLease,
|
|
5007
|
+
...(socketLease ? { socketLease } : {}),
|
|
5008
|
+
...(toolActivity ? { toolActivity } : {}),
|
|
5009
|
+
},
|
|
4363
5010
|
});
|
|
4364
5011
|
await this.flushPool();
|
|
4365
5012
|
}
|
|
@@ -4375,14 +5022,15 @@ export class TelegramNotificationDaemon {
|
|
|
4375
5022
|
|
|
4376
5023
|
private topicAuthorityLeaseFromRegistry(sessionId: string): TopicAuthorityLease | undefined {
|
|
4377
5024
|
const topic = this.topics.get(sessionId);
|
|
4378
|
-
if (!topic ||
|
|
5025
|
+
if (!topic || !this.topics.isActiveUnambiguous(sessionId) || topic.bindingMalformed) return undefined;
|
|
4379
5026
|
return { sessionId, topicId: topic.topicId, authorityEpoch: topic.authorityEpoch ?? 0 };
|
|
4380
5027
|
}
|
|
4381
5028
|
|
|
4382
5029
|
private topicLeaseIsCurrent(lease: TopicAuthorityLease): boolean {
|
|
4383
5030
|
const topic = this.topics.get(lease.sessionId);
|
|
4384
5031
|
return (
|
|
4385
|
-
|
|
5032
|
+
this.topics.isActiveUnambiguous(lease.sessionId) &&
|
|
5033
|
+
!topic?.bindingMalformed &&
|
|
4386
5034
|
topic?.topicId === lease.topicId &&
|
|
4387
5035
|
(topic.authorityEpoch ?? 0) === lease.authorityEpoch
|
|
4388
5036
|
);
|
|
@@ -4427,26 +5075,29 @@ export class TelegramNotificationDaemon {
|
|
|
4427
5075
|
msg: Record<string, unknown>,
|
|
4428
5076
|
toolActivity?: ToolActivityOwner,
|
|
4429
5077
|
): void {
|
|
4430
|
-
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) ?? [];
|
|
4431
5082
|
frames.push({
|
|
4432
5083
|
send,
|
|
4433
5084
|
msg,
|
|
4434
|
-
|
|
5085
|
+
logicalSessionId,
|
|
5086
|
+
socketLease,
|
|
4435
5087
|
...(toolActivity ? { toolActivity } : {}),
|
|
4436
5088
|
});
|
|
4437
5089
|
if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
|
|
4438
|
-
this.pendingThreadedFrames.set(
|
|
5090
|
+
this.pendingThreadedFrames.set(logicalSessionId, frames);
|
|
4439
5091
|
}
|
|
4440
5092
|
|
|
4441
5093
|
private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
|
|
4442
5094
|
const frames = this.pendingThreadedFrames.get(sessionId);
|
|
4443
5095
|
if (!frames || frames.length === 0) return;
|
|
4444
5096
|
this.pendingThreadedFrames.delete(sessionId);
|
|
4445
|
-
const session = this.sessions.get(sessionId);
|
|
4446
5097
|
for (const frame of frames) {
|
|
4447
|
-
if (
|
|
5098
|
+
if (frame.logicalSessionId !== sessionId || !this.#leaseTokenAllows(frame.socketLease)) continue;
|
|
4448
5099
|
if (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled === false) continue;
|
|
4449
|
-
await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity);
|
|
5100
|
+
await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, frame.socketLease);
|
|
4450
5101
|
}
|
|
4451
5102
|
}
|
|
4452
5103
|
|
|
@@ -4456,36 +5107,163 @@ export class TelegramNotificationDaemon {
|
|
|
4456
5107
|
* `undefined`; callers then flat-deliver to a private paired chat (with a
|
|
4457
5108
|
* one-time nudge) or drop fail-closed for a non-private chat.
|
|
4458
5109
|
*/
|
|
4459
|
-
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> {
|
|
4460
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;
|
|
4461
5120
|
const existing = this.topics.get(sessionId);
|
|
4462
|
-
if (existing?.authorityState === "delete_pending") return undefined;
|
|
5121
|
+
if (existing?.authorityState === "delete_pending" || existing?.bindingMalformed) return undefined;
|
|
4463
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;
|
|
4464
5135
|
try {
|
|
4465
5136
|
const rec = await this.topics.getOrCreateTopic(
|
|
4466
5137
|
sessionId,
|
|
4467
5138
|
async () => {
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
});
|
|
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 });
|
|
4472
5142
|
if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
|
|
4473
5143
|
const tid = (res as { result?: { message_thread_id?: unknown } }).result?.message_thread_id;
|
|
4474
5144
|
if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0)
|
|
4475
5145
|
throw new Error("createForumTopic: invalid message_thread_id");
|
|
4476
|
-
|
|
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;
|
|
4477
5180
|
},
|
|
4478
5181
|
this.opts.now,
|
|
4479
|
-
// The create winner records the name it actually used; callers that
|
|
4480
|
-
// merely JOIN an in-flight create must not overwrite it locally, or a
|
|
4481
|
-
// later identity rename would be wrongly skipped (topic stuck at the
|
|
4482
|
-
// provisional name on Telegram).
|
|
4483
5182
|
name,
|
|
5183
|
+
creationBinding,
|
|
5184
|
+
() => this.persistTopics(),
|
|
5185
|
+
session,
|
|
4484
5186
|
);
|
|
4485
|
-
|
|
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
|
+
}
|
|
4486
5216
|
return rec.topicId;
|
|
4487
5217
|
} catch (err) {
|
|
4488
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;
|
|
4489
5267
|
logger.warn(
|
|
4490
5268
|
`notifications: Telegram topic creation failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
|
|
4491
5269
|
);
|
|
@@ -4499,24 +5277,39 @@ export class TelegramNotificationDaemon {
|
|
|
4499
5277
|
}
|
|
4500
5278
|
|
|
4501
5279
|
private async observeOrphanedTopic(sessionId: string): Promise<void> {
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
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
|
+
});
|
|
4505
5292
|
}
|
|
4506
5293
|
|
|
4507
5294
|
/** Best-effort delete of a session topic once its local notification endpoint shuts down. */
|
|
4508
|
-
private async deleteTopic(
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
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";
|
|
4512
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);
|
|
4513
5308
|
if (!record) {
|
|
4514
5309
|
await this.topics.awaitInflight(sessionId);
|
|
4515
5310
|
record = this.topics.get(sessionId);
|
|
4516
|
-
// A remotely accepted revoked create installs a delete_pending record while
|
|
4517
|
-
// its caller rejects; durably retain it before compensating remotely.
|
|
4518
5311
|
await this.persistTopics();
|
|
4519
|
-
if (!record) return;
|
|
5312
|
+
if (!record) return "settled";
|
|
4520
5313
|
}
|
|
4521
5314
|
const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
|
|
4522
5315
|
for (const item of removed) {
|
|
@@ -4526,11 +5319,12 @@ export class TelegramNotificationDaemon {
|
|
|
4526
5319
|
}
|
|
4527
5320
|
try {
|
|
4528
5321
|
await this.flushPool();
|
|
5322
|
+
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
4529
5323
|
const res = (await this.botApi.call("deleteForumTopic", {
|
|
4530
5324
|
chat_id: this.opts.chatId,
|
|
4531
5325
|
message_thread_id: Number(record.topicId),
|
|
4532
5326
|
})) as { ok?: boolean };
|
|
4533
|
-
if (!topicDeleteSettled(res)) return;
|
|
5327
|
+
if (!topicDeleteSettled(res)) return "post_dispatch_pending";
|
|
4534
5328
|
this.topics.settleDelete(sessionId, record.topicId);
|
|
4535
5329
|
for (const k of [...this.liveMessages.keys()])
|
|
4536
5330
|
if (k.startsWith(`${sessionId}:`)) {
|
|
@@ -4541,32 +5335,119 @@ export class TelegramNotificationDaemon {
|
|
|
4541
5335
|
if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
|
|
4542
5336
|
});
|
|
4543
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 {
|
|
4544
5377
|
await this.persistTopics();
|
|
4545
5378
|
} catch {
|
|
4546
|
-
|
|
5379
|
+
await this.persistTopics();
|
|
4547
5380
|
}
|
|
4548
5381
|
}
|
|
4549
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
|
+
|
|
4550
5401
|
private persistTopics(): Promise<void> {
|
|
4551
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();
|
|
4552
5407
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
4553
5408
|
await ensureDir(this.fsImpl, paths.dir);
|
|
4554
|
-
await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"),
|
|
5409
|
+
await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), snapshot);
|
|
4555
5410
|
});
|
|
4556
5411
|
this.topicsPersistQueue = pending.catch(() => undefined);
|
|
4557
5412
|
return pending;
|
|
4558
5413
|
}
|
|
4559
5414
|
|
|
5415
|
+
#topicStateForPersistence(): TopicRegistryState {
|
|
5416
|
+
return { ...this.topics.serialize(), closedEndpoints: Object.fromEntries(this.closedEndpointKeys) };
|
|
5417
|
+
}
|
|
5418
|
+
|
|
4560
5419
|
async loadTopics(): Promise<void> {
|
|
4561
5420
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
4562
5421
|
const raw = await readJson<TopicRegistryState>(this.fsImpl, path.join(paths.dir, "telegram-topics.json"));
|
|
4563
5422
|
// Restore the full serialized registry (topicId + identitySent + name) so a
|
|
4564
5423
|
// fresh daemon after reload does not resend identity headers or lose renames.
|
|
4565
|
-
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
|
+
}
|
|
4566
5438
|
}
|
|
4567
5439
|
|
|
4568
|
-
/**
|
|
4569
|
-
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> {
|
|
4570
5451
|
const apiBase = this.opts.apiBase ?? "https://api.telegram.org";
|
|
4571
5452
|
const fetchImpl = this.opts.fetchImpl ?? fetch;
|
|
4572
5453
|
// `filePath` is remote metadata from getFile; reject suspicious segments
|
|
@@ -4574,17 +5455,68 @@ export class TelegramNotificationDaemon {
|
|
|
4574
5455
|
// composing the download URL.
|
|
4575
5456
|
if (filePath.includes("..") || filePath.startsWith("/") || filePath.includes("\\")) {
|
|
4576
5457
|
logger.warn("notifications: rejecting suspicious Telegram file_path");
|
|
4577
|
-
return
|
|
5458
|
+
return { failure: "download_failed" };
|
|
4578
5459
|
}
|
|
4579
5460
|
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
|
4580
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 });
|
|
4581
5474
|
try {
|
|
4582
|
-
const res = await fetchImpl(url);
|
|
4583
|
-
if (!res.ok)
|
|
4584
|
-
|
|
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) };
|
|
4585
5512
|
} catch (e) {
|
|
5513
|
+
await cancelActiveReader?.();
|
|
5514
|
+
controller.abort();
|
|
4586
5515
|
logger.warn(`notifications: file download failed: ${sanitizeDiagnostic(String(e))}`);
|
|
4587
|
-
return
|
|
5516
|
+
return { failure: "download_failed" };
|
|
5517
|
+
} finally {
|
|
5518
|
+
controller.signal.removeEventListener("abort", cancelReader);
|
|
5519
|
+
if (timeout !== undefined) clearTimeoutImpl(timeout);
|
|
4588
5520
|
}
|
|
4589
5521
|
}
|
|
4590
5522
|
|
|
@@ -4594,6 +5526,8 @@ export class TelegramNotificationDaemon {
|
|
|
4594
5526
|
* removed when the daemon stops (see {@link cleanupAllAttachmentDirs}).
|
|
4595
5527
|
*/
|
|
4596
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>>();
|
|
4597
5531
|
|
|
4598
5532
|
/** Lazily create a private, unguessable 0700 temp dir for `sessionId`. */
|
|
4599
5533
|
private async ensureAttachmentDir(sessionId: string): Promise<string> {
|
|
@@ -4611,6 +5545,8 @@ export class TelegramNotificationDaemon {
|
|
|
4611
5545
|
private async cleanupAllAttachmentDirs(): Promise<void> {
|
|
4612
5546
|
const dirs = [...this.attachmentDirs.values()];
|
|
4613
5547
|
this.attachmentDirs.clear();
|
|
5548
|
+
this.#attachmentUsage.clear();
|
|
5549
|
+
this.#attachmentChains.clear();
|
|
4614
5550
|
await Promise.all(dirs.map(dir => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => undefined)));
|
|
4615
5551
|
}
|
|
4616
5552
|
|
|
@@ -4626,22 +5562,83 @@ export class TelegramNotificationDaemon {
|
|
|
4626
5562
|
private async resolveInboundAttachment(
|
|
4627
5563
|
att: InboundAttachment,
|
|
4628
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,
|
|
4629
5581
|
): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
|
|
4630
5582
|
const images: { data: string; mime?: string }[] = [];
|
|
4631
5583
|
const fileNotes: string[] = [];
|
|
4632
5584
|
const label = att.fileName ?? att.kind;
|
|
5585
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
4633
5586
|
try {
|
|
4634
|
-
const
|
|
4635
|
-
|
|
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 };
|
|
4636
5602
|
};
|
|
4637
5603
|
const filePath = typeof got?.result?.file_path === "string" ? got.result.file_path : undefined;
|
|
4638
5604
|
if (!filePath) {
|
|
4639
5605
|
fileNotes.push(`[attachment unavailable: ${label}]`);
|
|
4640
5606
|
return { images, fileNotes };
|
|
4641
5607
|
}
|
|
4642
|
-
const
|
|
4643
|
-
|
|
4644
|
-
|
|
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]`);
|
|
4645
5642
|
return { images, fileNotes };
|
|
4646
5643
|
}
|
|
4647
5644
|
const isImage = att.kind === "photo" || (typeof att.mime === "string" && att.mime.startsWith("image/"));
|
|
@@ -4658,12 +5655,20 @@ export class TelegramNotificationDaemon {
|
|
|
4658
5655
|
// Unguessable, non-colliding name inside the private 0700 dir; the
|
|
4659
5656
|
// exclusive 0600 create (`wx`) refuses to follow a pre-existing file/symlink.
|
|
4660
5657
|
const dest = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safeBase}`);
|
|
4661
|
-
|
|
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
|
+
}
|
|
4662
5664
|
fileNotes.push(`[user attached a file, saved to ${dest}${att.mime ? ` (${att.mime})` : ""}]`);
|
|
4663
5665
|
}
|
|
5666
|
+
this.#attachmentUsage.set(sessionId, { count: current.count + 1, bytes: current.bytes + accountedBytes });
|
|
4664
5667
|
} catch (e) {
|
|
4665
5668
|
logger.warn(`notifications: inbound attachment failed: ${String(e)}`);
|
|
4666
5669
|
fileNotes.push(`[attachment error: ${label}]`);
|
|
5670
|
+
} finally {
|
|
5671
|
+
if (timeout !== undefined) (this.opts.clearTimeoutImpl ?? clearTimeout)(timeout);
|
|
4667
5672
|
}
|
|
4668
5673
|
return { images, fileNotes };
|
|
4669
5674
|
}
|
|
@@ -4769,7 +5774,9 @@ export class TelegramNotificationDaemon {
|
|
|
4769
5774
|
const controller = new AbortController();
|
|
4770
5775
|
selectedAck.controller = controller;
|
|
4771
5776
|
const routeAvailable =
|
|
4772
|
-
(
|
|
5777
|
+
this.#leaseTokenAllows(selectedAck.socketLease) &&
|
|
5778
|
+
(await this.pairedChatIsPrivate()) &&
|
|
5779
|
+
(!topicLease || this.topicLeaseIsCurrent(topicLease));
|
|
4773
5780
|
if (this.selectedAckPending.get(selectedAck.pendingKey) !== selectedAck) {
|
|
4774
5781
|
this.pool.settle(item.itemId!, "removed");
|
|
4775
5782
|
continue;
|
|
@@ -4791,6 +5798,14 @@ export class TelegramNotificationDaemon {
|
|
|
4791
5798
|
Math.min(8_000, remaining),
|
|
4792
5799
|
);
|
|
4793
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
|
+
}
|
|
4794
5809
|
const response = (await this.botApi.call(
|
|
4795
5810
|
"sendMessage",
|
|
4796
5811
|
{
|
|
@@ -4815,8 +5830,11 @@ export class TelegramNotificationDaemon {
|
|
|
4815
5830
|
}
|
|
4816
5831
|
continue;
|
|
4817
5832
|
}
|
|
4818
|
-
const { send, topicLease } = item.payload;
|
|
4819
|
-
if (
|
|
5833
|
+
const { send, topicLease, socketLease } = item.payload;
|
|
5834
|
+
if (
|
|
5835
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
5836
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
5837
|
+
) {
|
|
4820
5838
|
this.pool.settle(item.itemId!, "rejected");
|
|
4821
5839
|
continue;
|
|
4822
5840
|
}
|
|
@@ -4825,7 +5843,10 @@ export class TelegramNotificationDaemon {
|
|
|
4825
5843
|
this.pool.settle(item.itemId!, "rejected");
|
|
4826
5844
|
continue;
|
|
4827
5845
|
}
|
|
4828
|
-
if (
|
|
5846
|
+
if (
|
|
5847
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
5848
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
5849
|
+
) {
|
|
4829
5850
|
this.pool.settle(item.itemId!, "rejected");
|
|
4830
5851
|
continue;
|
|
4831
5852
|
}
|
|
@@ -4870,7 +5891,10 @@ export class TelegramNotificationDaemon {
|
|
|
4870
5891
|
}
|
|
4871
5892
|
}
|
|
4872
5893
|
}
|
|
4873
|
-
if (
|
|
5894
|
+
if (
|
|
5895
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
5896
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
5897
|
+
) {
|
|
4874
5898
|
disposition = "rejected";
|
|
4875
5899
|
continue;
|
|
4876
5900
|
}
|
|
@@ -4910,7 +5934,11 @@ export class TelegramNotificationDaemon {
|
|
|
4910
5934
|
// non-editable, HTML-only pool items (rich markers stripped) — same
|
|
4911
5935
|
// per-token discipline as the non-rich split path.
|
|
4912
5936
|
const chunks = splitTelegramHtml(send.text!);
|
|
4913
|
-
if (
|
|
5937
|
+
if (
|
|
5938
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
5939
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
5940
|
+
)
|
|
5941
|
+
return;
|
|
4914
5942
|
await this.botApi.call("sendMessage", {
|
|
4915
5943
|
chat_id: this.opts.chatId,
|
|
4916
5944
|
...threadField,
|
|
@@ -4935,6 +5963,7 @@ export class TelegramNotificationDaemon {
|
|
|
4935
5963
|
richClass: undefined,
|
|
4936
5964
|
},
|
|
4937
5965
|
topicLease,
|
|
5966
|
+
socketLease,
|
|
4938
5967
|
},
|
|
4939
5968
|
});
|
|
4940
5969
|
}
|
|
@@ -4969,6 +5998,11 @@ export class TelegramNotificationDaemon {
|
|
|
4969
5998
|
// transport error) resends so the first chunk is never lost.
|
|
4970
5999
|
let edited = false;
|
|
4971
6000
|
try {
|
|
6001
|
+
if (
|
|
6002
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
6003
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6004
|
+
)
|
|
6005
|
+
return;
|
|
4972
6006
|
const res = (await this.botApi.call("editMessageText", {
|
|
4973
6007
|
chat_id: this.opts.chatId,
|
|
4974
6008
|
message_id: existingId,
|
|
@@ -4981,7 +6015,10 @@ export class TelegramNotificationDaemon {
|
|
|
4981
6015
|
}
|
|
4982
6016
|
if (edited) {
|
|
4983
6017
|
firstMessageId = existingId;
|
|
4984
|
-
} else if (
|
|
6018
|
+
} else if (
|
|
6019
|
+
(!socketLease || this.#leaseTokenAllows(socketLease)) &&
|
|
6020
|
+
(!topicLease || this.topicLeaseIsCurrent(topicLease))
|
|
6021
|
+
) {
|
|
4985
6022
|
const res = (await this.botApi.call("sendMessage", {
|
|
4986
6023
|
chat_id: this.opts.chatId,
|
|
4987
6024
|
...threadField,
|
|
@@ -4993,6 +6030,11 @@ export class TelegramNotificationDaemon {
|
|
|
4993
6030
|
} else {
|
|
4994
6031
|
// No streamed message to edit: a single granted slot maps to a
|
|
4995
6032
|
// single Telegram send.
|
|
6033
|
+
if (
|
|
6034
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
6035
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6036
|
+
)
|
|
6037
|
+
return;
|
|
4996
6038
|
const res = (await this.botApi.call("sendMessage", {
|
|
4997
6039
|
chat_id: this.opts.chatId,
|
|
4998
6040
|
...threadField,
|
|
@@ -5026,6 +6068,7 @@ export class TelegramNotificationDaemon {
|
|
|
5026
6068
|
richClass: undefined,
|
|
5027
6069
|
},
|
|
5028
6070
|
topicLease,
|
|
6071
|
+
socketLease,
|
|
5029
6072
|
},
|
|
5030
6073
|
});
|
|
5031
6074
|
}
|
|
@@ -5103,17 +6146,21 @@ export class TelegramNotificationDaemon {
|
|
|
5103
6146
|
sessionId: string,
|
|
5104
6147
|
send: ThreadedSend,
|
|
5105
6148
|
toolActivity?: ToolActivityOwner,
|
|
6149
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
5106
6150
|
): Promise<void> {
|
|
5107
|
-
if (!(await this.pairedChatIsPrivate())) return;
|
|
5108
|
-
|
|
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;
|
|
5109
6155
|
if (send.identity && this.flatIdentitySent.has(sessionId)) return;
|
|
5110
6156
|
this.submitPool({
|
|
5111
6157
|
sessionId,
|
|
5112
6158
|
lane: send.lane,
|
|
5113
6159
|
coalesceKey: send.coalesceKey,
|
|
5114
|
-
payload: { send, ...(toolActivity ? { toolActivity } : {}) },
|
|
6160
|
+
payload: { send, ...(socketLease ? { socketLease } : {}), ...(toolActivity ? { toolActivity } : {}) },
|
|
5115
6161
|
});
|
|
5116
6162
|
await this.flushPool();
|
|
6163
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
5117
6164
|
if (send.identity) this.flatIdentitySent.add(sessionId);
|
|
5118
6165
|
}
|
|
5119
6166
|
|
|
@@ -5156,10 +6203,21 @@ export class TelegramNotificationDaemon {
|
|
|
5156
6203
|
}
|
|
5157
6204
|
|
|
5158
6205
|
/** Tell the user once (per daemon run) how to enable Threaded Mode. */
|
|
5159
|
-
private async notifyThreadedFallback(
|
|
5160
|
-
|
|
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;
|
|
5161
6218
|
this.threadedFallbackNoticeSent = true;
|
|
5162
6219
|
try {
|
|
6220
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
5163
6221
|
await this.botApi.call("sendMessage", {
|
|
5164
6222
|
chat_id: this.opts.chatId,
|
|
5165
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.",
|
|
@@ -5234,14 +6292,20 @@ export class TelegramNotificationDaemon {
|
|
|
5234
6292
|
}
|
|
5235
6293
|
|
|
5236
6294
|
/** Send a single `typing` chat action into a busy session's topic (best-effort). */
|
|
5237
|
-
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;
|
|
5238
6302
|
const topicLease = await this.topicAuthorityLease(sessionId);
|
|
5239
|
-
if (!topicLease || !this.topicLeaseIsCurrent(topicLease)) return;
|
|
5240
|
-
const topicId = topicLease.topicId;
|
|
6303
|
+
if (!topicLease || !this.topicLeaseIsCurrent(topicLease) || !this.#leaseTokenAllows(socketLease)) return;
|
|
5241
6304
|
try {
|
|
6305
|
+
if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return;
|
|
5242
6306
|
await this.botApi.call("sendChatAction", {
|
|
5243
6307
|
chat_id: this.opts.chatId,
|
|
5244
|
-
message_thread_id: Number(topicId),
|
|
6308
|
+
message_thread_id: Number(topicLease.topicId),
|
|
5245
6309
|
action: "typing",
|
|
5246
6310
|
});
|
|
5247
6311
|
} catch {
|
|
@@ -5250,9 +6314,14 @@ export class TelegramNotificationDaemon {
|
|
|
5250
6314
|
}
|
|
5251
6315
|
|
|
5252
6316
|
/** Set a native reaction on an inbound thread message (best-effort). */
|
|
5253
|
-
private async setReaction(
|
|
5254
|
-
|
|
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;
|
|
5255
6323
|
try {
|
|
6324
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
5256
6325
|
await this.botApi.call("setMessageReaction", {
|
|
5257
6326
|
chat_id: this.opts.chatId,
|
|
5258
6327
|
message_id: messageId,
|
|
@@ -5276,7 +6345,10 @@ export class TelegramNotificationDaemon {
|
|
|
5276
6345
|
|
|
5277
6346
|
/** Render successful `/model` lists as session-bound, one-shot inline choices. */
|
|
5278
6347
|
async #renderModelChoices(session: SessionSocket, msg: Record<string, unknown>): Promise<boolean> {
|
|
5279
|
-
const logicalSessionId =
|
|
6348
|
+
const logicalSessionId =
|
|
6349
|
+
!session.logicalSessionIdTrusted && typeof msg.sessionId === "string" && msg.sessionId.trim()
|
|
6350
|
+
? msg.sessionId
|
|
6351
|
+
: this.#logicalSessionId(session);
|
|
5280
6352
|
if (
|
|
5281
6353
|
msg.type !== "control_command_result" ||
|
|
5282
6354
|
msg.status !== "ok" ||
|
|
@@ -5285,6 +6357,8 @@ export class TelegramNotificationDaemon {
|
|
|
5285
6357
|
this.sessions.get(session.sessionId) !== session
|
|
5286
6358
|
)
|
|
5287
6359
|
return false;
|
|
6360
|
+
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
6361
|
+
if (!socketLease) return false;
|
|
5288
6362
|
|
|
5289
6363
|
const choices: RenderedModelChoice[] = [];
|
|
5290
6364
|
for (const choice of msg.modelChoices) {
|
|
@@ -5299,21 +6373,27 @@ export class TelegramNotificationDaemon {
|
|
|
5299
6373
|
const rendered = renderThreadedFrame({ ...msg, type: "control_command_result" });
|
|
5300
6374
|
if (!rendered?.text) return false;
|
|
5301
6375
|
const topicId =
|
|
5302
|
-
(await this.existingTopicForPrivateChat(
|
|
5303
|
-
(await this.ensureTopic(
|
|
5304
|
-
|
|
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);
|
|
5305
6384
|
if (!topicId || !topicLease || topicLease.topicId !== topicId) return false;
|
|
6385
|
+
if (!session.logicalSessionIdTrusted) this.legacyTopicOwners.set(logicalSessionId, session);
|
|
5306
6386
|
|
|
5307
6387
|
// Each logical session owns only its most recently rendered menu.
|
|
5308
6388
|
this.#clearModelChoiceAliases(logicalSessionId);
|
|
5309
6389
|
const aliases = choices.map(choice =>
|
|
5310
|
-
this.#putModelChoiceAlias({ session, sessionId: logicalSessionId, selector: choice.selector }),
|
|
6390
|
+
this.#putModelChoiceAlias({ session, socketLease, sessionId: logicalSessionId, selector: choice.selector }),
|
|
5311
6391
|
);
|
|
5312
6392
|
const inline_keyboard = buildButtonGrid(
|
|
5313
6393
|
choices.map(choice => choice.label),
|
|
5314
6394
|
index => aliases[index]!,
|
|
5315
6395
|
);
|
|
5316
|
-
if (!this.topicLeaseIsCurrent(topicLease)) return false;
|
|
6396
|
+
if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return false;
|
|
5317
6397
|
try {
|
|
5318
6398
|
const response = await this.botApi.call("sendMessage", {
|
|
5319
6399
|
chat_id: this.opts.chatId,
|
|
@@ -5336,6 +6416,18 @@ export class TelegramNotificationDaemon {
|
|
|
5336
6416
|
}
|
|
5337
6417
|
|
|
5338
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
|
+
}
|
|
5339
6431
|
if (session.replayPending) {
|
|
5340
6432
|
if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled === false || this.toolActivityStopping))
|
|
5341
6433
|
return;
|
|
@@ -5346,34 +6438,100 @@ export class TelegramNotificationDaemon {
|
|
|
5346
6438
|
session.replayQueue.push(frame);
|
|
5347
6439
|
return;
|
|
5348
6440
|
}
|
|
5349
|
-
session.replayPending = false;
|
|
5350
6441
|
const replayValid =
|
|
6442
|
+
msg.ok === true &&
|
|
5351
6443
|
Number.isSafeInteger(msg.generation) &&
|
|
5352
6444
|
msg.generation >= 1 &&
|
|
5353
6445
|
Number.isSafeInteger(msg.lastSeq) &&
|
|
5354
6446
|
msg.lastSeq >= 0 &&
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
}
|
|
6447
|
+
msg.gap === undefined &&
|
|
6448
|
+
Array.isArray(msg.events) &&
|
|
6449
|
+
msg.events.every((event: unknown) => event !== null && typeof event === "object" && !Array.isArray(event));
|
|
5359
6450
|
const replayed: Record<string, unknown>[] = replayValid
|
|
5360
|
-
? (msg.events as
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
payload && typeof payload === "object" && !Array.isArray(payload)
|
|
5366
|
-
? (payload as Record<string, unknown>)
|
|
5367
|
-
: envelope,
|
|
5368
|
-
];
|
|
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;
|
|
5369
6456
|
})
|
|
5370
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;
|
|
5371
6531
|
// Replay restores durable attachment state only. Live notification effects
|
|
5372
6532
|
// (turn streams, context updates, lifecycle messages) may already have been
|
|
5373
6533
|
// delivered before a reconnect and must never be rendered a second time.
|
|
5374
|
-
const identityIndex = replayed.findLastIndex(frame => frame.type === "identity_header");
|
|
5375
6534
|
const currentGeneration = identityIndex < 0 ? replayed : replayed.slice(identityIndex);
|
|
5376
|
-
const latestIdentity = identityIndex < 0 ? undefined : replayed[identityIndex];
|
|
5377
6535
|
const latestActions = new Map<string, Record<string, unknown>>();
|
|
5378
6536
|
for (const frame of currentGeneration) {
|
|
5379
6537
|
if ((frame.type === "action_needed" || frame.type === "action_resolved") && typeof frame.id === "string")
|
|
@@ -5384,7 +6542,15 @@ export class TelegramNotificationDaemon {
|
|
|
5384
6542
|
for (const frame of replayState) {
|
|
5385
6543
|
const fingerprint = JSON.stringify(frame);
|
|
5386
6544
|
replayCounts.set(fingerprint, (replayCounts.get(fingerprint) ?? 0) + 1);
|
|
5387
|
-
|
|
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
|
+
}
|
|
5388
6554
|
}
|
|
5389
6555
|
const queued = session.replayQueue.splice(0);
|
|
5390
6556
|
for (const frame of queued) {
|
|
@@ -5402,16 +6568,19 @@ export class TelegramNotificationDaemon {
|
|
|
5402
6568
|
}
|
|
5403
6569
|
await this.handleSessionMessage(session, frame);
|
|
5404
6570
|
}
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
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();
|
|
5411
6579
|
return;
|
|
5412
6580
|
}
|
|
5413
6581
|
if (msg?.type === "event_replay_result") return;
|
|
5414
|
-
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;
|
|
5415
6584
|
if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
|
|
5416
6585
|
if (await this.#renderModelChoices(session, msg as Record<string, unknown>)) return;
|
|
5417
6586
|
|
|
@@ -5445,7 +6614,7 @@ export class TelegramNotificationDaemon {
|
|
|
5445
6614
|
msg.threadId !== pending.threadId ||
|
|
5446
6615
|
msg.updateId !== pending.updateId ||
|
|
5447
6616
|
msg.messageId !== pending.messageId ||
|
|
5448
|
-
this.topics.sessionForTopic(pending.threadId) !== pending.
|
|
6617
|
+
this.topics.sessionForTopic(pending.threadId) !== pending.logicalSessionId
|
|
5449
6618
|
)
|
|
5450
6619
|
return;
|
|
5451
6620
|
if (
|
|
@@ -5459,6 +6628,21 @@ export class TelegramNotificationDaemon {
|
|
|
5459
6628
|
return;
|
|
5460
6629
|
if (msg.status === "ok" && typeof msg.text !== "string") return;
|
|
5461
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;
|
|
5462
6646
|
const finished = Promise.withResolvers<void>();
|
|
5463
6647
|
const terminalDelivery: PendingBtwDelivery = {
|
|
5464
6648
|
pending,
|
|
@@ -5487,6 +6671,7 @@ export class TelegramNotificationDaemon {
|
|
|
5487
6671
|
messageId: pending.messageId,
|
|
5488
6672
|
text,
|
|
5489
6673
|
signal: terminalDelivery.controller.signal,
|
|
6674
|
+
isAuthoritative,
|
|
5490
6675
|
});
|
|
5491
6676
|
deliveryOutcome =
|
|
5492
6677
|
response && typeof response === "object" && (response as { ok?: unknown }).ok === true
|
|
@@ -5501,20 +6686,6 @@ export class TelegramNotificationDaemon {
|
|
|
5501
6686
|
return;
|
|
5502
6687
|
}
|
|
5503
6688
|
const markdown = msg.text;
|
|
5504
|
-
const isAuthoritative = (): boolean =>
|
|
5505
|
-
!this.#stoppingBtw &&
|
|
5506
|
-
this.sessions.get(session.sessionId) === session &&
|
|
5507
|
-
session.ws.readyState === WebSocket.OPEN &&
|
|
5508
|
-
pending.endpointDigest === session.endpointDigest &&
|
|
5509
|
-
pending.generation === session.hostGeneration &&
|
|
5510
|
-
pending.logicalSessionId === this.#logicalSessionId(session) &&
|
|
5511
|
-
pending.transportSessionId === session.sessionId &&
|
|
5512
|
-
requestId === msg.requestId &&
|
|
5513
|
-
pending.logicalSessionId === msg.sessionId &&
|
|
5514
|
-
pending.messageId === msg.messageId &&
|
|
5515
|
-
pending.threadId === msg.threadId &&
|
|
5516
|
-
pending.updateId === msg.updateId &&
|
|
5517
|
-
this.topics.sessionForTopic(pending.threadId) === pending.transportSessionId;
|
|
5518
6689
|
const signal = AbortSignal.any([
|
|
5519
6690
|
this.#btwDeliveryAbort.signal,
|
|
5520
6691
|
terminalDelivery.controller.signal,
|
|
@@ -5641,13 +6812,13 @@ export class TelegramNotificationDaemon {
|
|
|
5641
6812
|
toolAdmissionEpoch === this.toolActivityPolicyEpoch);
|
|
5642
6813
|
const abandonStaleToolStart = (): void => {
|
|
5643
6814
|
if (toolActivity?.phase !== "started") return;
|
|
5644
|
-
const key = `${
|
|
6815
|
+
const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
5645
6816
|
const owner = this.toolActivityOwners.get(key);
|
|
5646
6817
|
if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
|
|
5647
6818
|
this.toolActivityOwners.delete(key);
|
|
5648
6819
|
};
|
|
5649
6820
|
if (toolActivity) {
|
|
5650
|
-
const liveKey = `${
|
|
6821
|
+
const liveKey = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
5651
6822
|
const currentOwner = this.toolActivityOwners.get(liveKey);
|
|
5652
6823
|
if (toolActivity.phase === "started") {
|
|
5653
6824
|
if (!toolStartIsCurrent()) return;
|
|
@@ -5672,102 +6843,106 @@ export class TelegramNotificationDaemon {
|
|
|
5672
6843
|
}
|
|
5673
6844
|
const send = renderThreadedFrame(msg);
|
|
5674
6845
|
if (!send) return;
|
|
5675
|
-
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);
|
|
5676
6858
|
if (!toolStartIsCurrent()) {
|
|
5677
6859
|
abandonStaleToolStart();
|
|
5678
6860
|
return;
|
|
5679
6861
|
}
|
|
5680
|
-
if (
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
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)) {
|
|
5686
6868
|
this.rememberPendingThreadedFrame(session, send, threadedFrame, toolActivity);
|
|
5687
6869
|
return;
|
|
5688
6870
|
}
|
|
5689
|
-
if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
|
|
5690
|
-
const ownerId = this.topicOwnerForIdentity(msg);
|
|
5691
|
-
const ownerLease = ownerId ? await this.topicAuthorityLease(ownerId) : undefined;
|
|
5692
|
-
if (ownerId && ownerId !== session.sessionId && ownerLease) {
|
|
5693
|
-
await this.flushPendingThreadedFrames(session.sessionId, ownerLease);
|
|
5694
|
-
return;
|
|
5695
|
-
}
|
|
5696
|
-
}
|
|
5697
6871
|
const topicId =
|
|
5698
|
-
existingTopic ??
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
abandonStaleToolStart();
|
|
5702
|
-
return;
|
|
5703
|
-
}
|
|
6872
|
+
existingTopic ??
|
|
6873
|
+
(await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session));
|
|
6874
|
+
const topicLease = await this.topicAuthorityLease(logicalSessionId);
|
|
5704
6875
|
if (!topicId || !topicLease || topicLease.topicId !== topicId) {
|
|
5705
|
-
if (
|
|
5706
|
-
|
|
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);
|
|
5707
6882
|
return;
|
|
5708
6883
|
}
|
|
5709
6884
|
if (send.identity) {
|
|
5710
6885
|
const identityKey = this.topicIdentityKey(msg);
|
|
5711
6886
|
if (identityKey) {
|
|
5712
|
-
this.topicOwnerByIdentity.set(identityKey,
|
|
5713
|
-
if (this.topics.markIdentityKey(
|
|
6887
|
+
this.topicOwnerByIdentity.set(identityKey, logicalSessionId);
|
|
6888
|
+
if (this.topics.markIdentityKey(logicalSessionId, identityKey)) await this.persistTopics();
|
|
5714
6889
|
}
|
|
5715
|
-
// Explicit Telegram-side user renames own the topic title. Pending user
|
|
5716
|
-
// reconciliation runs before daemon identity naming, so retries and daemon
|
|
5717
|
-
// restarts cannot silently replace the preserved name.
|
|
5718
6890
|
await this.reconcileUserTopicName(topicLease);
|
|
5719
|
-
const name = this.topicNameFor(
|
|
5720
|
-
if (this.topics.needsRename(
|
|
5721
|
-
this.daemonRenameAttempts.set(
|
|
5722
|
-
session.sessionId,
|
|
5723
|
-
(this.daemonRenameAttempts.get(session.sessionId) ?? 0) + 1,
|
|
5724
|
-
);
|
|
6891
|
+
const name = this.topicNameFor(logicalSessionId, msg);
|
|
6892
|
+
if (this.topics.needsRename(logicalSessionId, name)) {
|
|
5725
6893
|
try {
|
|
5726
|
-
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
|
+
);
|
|
5727
6899
|
const response = await this.botApi.call("editForumTopic", {
|
|
5728
6900
|
chat_id: this.opts.chatId,
|
|
5729
6901
|
message_thread_id: Number(topicId),
|
|
5730
6902
|
name,
|
|
5731
6903
|
});
|
|
5732
|
-
if (topicRenameApplied(response)) this.topics.markNameApplied(
|
|
6904
|
+
if (topicRenameApplied(response)) this.topics.markNameApplied(logicalSessionId, name);
|
|
5733
6905
|
} catch {
|
|
5734
|
-
//
|
|
5735
|
-
// later identity frame retries.
|
|
6906
|
+
// A later identity frame retries a transient daemon rename failure.
|
|
5736
6907
|
} finally {
|
|
5737
|
-
const
|
|
5738
|
-
if (
|
|
5739
|
-
else
|
|
5740
|
-
this.daemonRenameAttempts.delete(session.sessionId);
|
|
5741
|
-
await this.reconcileUserTopicName(topicLease);
|
|
5742
|
-
}
|
|
6908
|
+
const attempts = (this.daemonRenameAttempts.get(logicalSessionId) ?? 1) - 1;
|
|
6909
|
+
if (attempts > 0) this.daemonRenameAttempts.set(logicalSessionId, attempts);
|
|
6910
|
+
else this.daemonRenameAttempts.delete(logicalSessionId);
|
|
5743
6911
|
}
|
|
5744
6912
|
}
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
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);
|
|
5749
6916
|
}
|
|
5750
|
-
await this.flushPendingThreadedFrames(session.sessionId, topicLease);
|
|
5751
6917
|
await this.persistTopics();
|
|
6918
|
+
await this.flushPendingThreadedFrames(logicalSessionId, topicLease);
|
|
6919
|
+
await this.reconcileUserTopicName(topicLease);
|
|
5752
6920
|
return;
|
|
5753
6921
|
}
|
|
5754
6922
|
if (!toolStartIsCurrent()) {
|
|
5755
6923
|
abandonStaleToolStart();
|
|
5756
6924
|
return;
|
|
5757
6925
|
}
|
|
5758
|
-
await this.submitThreadedFrame(
|
|
6926
|
+
await this.submitThreadedFrame(logicalSessionId, send, topicLease, toolActivity, socketLease);
|
|
5759
6927
|
return;
|
|
5760
6928
|
}
|
|
5761
6929
|
if (msg.type === "action_needed" && msg.id) {
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
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;
|
|
5766
6941
|
if (topicId && (!topicLease || topicLease.topicId !== topicId)) return;
|
|
5767
6942
|
if (!topicId) {
|
|
5768
6943
|
// Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
|
|
5769
6944
|
if (!(await this.pairedChatIsPrivate())) return;
|
|
5770
|
-
await this.notifyThreadedFallback();
|
|
6945
|
+
await this.notifyThreadedFallback(socketLease);
|
|
5771
6946
|
}
|
|
5772
6947
|
const threadField = topicLease ? { message_thread_id: Number(topicLease.topicId) } : {};
|
|
5773
6948
|
const controls: Array<{
|
|
@@ -5805,13 +6980,13 @@ export class TelegramNotificationDaemon {
|
|
|
5805
6980
|
const options = Array.isArray(msg.options) ? msg.options : [];
|
|
5806
6981
|
const inline_keyboard = [
|
|
5807
6982
|
...buildCompactChoiceGrid(options, (i: number) =>
|
|
5808
|
-
this.aliasTable.put({ sessionId:
|
|
6983
|
+
this.aliasTable.put({ sessionId: logicalSessionId, actionId: msg.id, answer: i }),
|
|
5809
6984
|
),
|
|
5810
6985
|
...controls.map(control => [
|
|
5811
6986
|
{
|
|
5812
6987
|
text: control.label,
|
|
5813
6988
|
callback_data: this.aliasTable.put({
|
|
5814
|
-
sessionId:
|
|
6989
|
+
sessionId: logicalSessionId,
|
|
5815
6990
|
actionId: msg.id,
|
|
5816
6991
|
answer: { controlId: control.id },
|
|
5817
6992
|
}),
|
|
@@ -5824,7 +6999,8 @@ export class TelegramNotificationDaemon {
|
|
|
5824
6999
|
const chunks = splitTelegramHtml(rendered.text);
|
|
5825
7000
|
let result: { result?: { message_id?: number } } = {};
|
|
5826
7001
|
for (let i = 0; i < chunks.length; i++) {
|
|
5827
|
-
if (topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
7002
|
+
if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease)))
|
|
7003
|
+
return undefined;
|
|
5828
7004
|
result = (await this.botApi.call("sendMessage", {
|
|
5829
7005
|
chat_id: this.opts.chatId,
|
|
5830
7006
|
...threadField,
|
|
@@ -5840,7 +7016,7 @@ export class TelegramNotificationDaemon {
|
|
|
5840
7016
|
// Rich (default on): promote to sendRichMessage with a top-level
|
|
5841
7017
|
// reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
|
|
5842
7018
|
|
|
5843
|
-
if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
|
|
7019
|
+
if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease))) return;
|
|
5844
7020
|
const outcome = await deliverRichActionWithFallback(
|
|
5845
7021
|
this.botApi,
|
|
5846
7022
|
{ chat_id: this.opts.chatId, ...threadField },
|
|
@@ -5859,21 +7035,22 @@ export class TelegramNotificationDaemon {
|
|
|
5859
7035
|
);
|
|
5860
7036
|
// Only asks are reply-routable; idle pings register no route.
|
|
5861
7037
|
if (kind === "ask" && outcome.messageId !== undefined)
|
|
5862
|
-
this.messageRoutes.set(String(outcome.messageId), { sessionId:
|
|
7038
|
+
this.messageRoutes.set(String(outcome.messageId), { sessionId: logicalSessionId, actionId: msg.id });
|
|
5863
7039
|
} else {
|
|
5864
7040
|
// Off: byte-identical to the pre-rich HTML path.
|
|
5865
7041
|
const messageId = await sendHtmlChunks();
|
|
5866
7042
|
// Only asks are reply-routable; idle pings register no route (parity
|
|
5867
7043
|
// with the rich branch and correct even in the byte-identical off path).
|
|
5868
7044
|
if (kind === "ask" && messageId !== undefined)
|
|
5869
|
-
this.messageRoutes.set(String(messageId), { sessionId:
|
|
7045
|
+
this.messageRoutes.set(String(messageId), { sessionId: logicalSessionId, actionId: msg.id });
|
|
5870
7046
|
}
|
|
5871
7047
|
await this.persistAliases();
|
|
5872
7048
|
} else if (msg.type === "action_resolved" && msg.id) {
|
|
5873
7049
|
session.pending.delete(msg.id);
|
|
5874
|
-
this.deleteMessageRoutes(session
|
|
7050
|
+
this.deleteMessageRoutes(this.#logicalSessionId(session), msg.id);
|
|
5875
7051
|
for (const [alias, route] of this.aliasTable.entries()) {
|
|
5876
|
-
if (route.sessionId === session
|
|
7052
|
+
if (route.sessionId === this.#logicalSessionId(session) && route.actionId === msg.id)
|
|
7053
|
+
this.aliasTable.delete(alias);
|
|
5877
7054
|
}
|
|
5878
7055
|
await this.persistAliases();
|
|
5879
7056
|
}
|
|
@@ -5920,7 +7097,9 @@ export class TelegramNotificationDaemon {
|
|
|
5920
7097
|
if (
|
|
5921
7098
|
this.sessions.get(session.sessionId) !== session ||
|
|
5922
7099
|
this.#logicalSessionId(session) !== route.sessionId ||
|
|
5923
|
-
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))
|
|
5924
7103
|
) {
|
|
5925
7104
|
this.#modelChoiceAliases.delete(alias);
|
|
5926
7105
|
await this.#sendModelStaleGuidance(callbackId);
|
|
@@ -5932,6 +7111,13 @@ export class TelegramNotificationDaemon {
|
|
|
5932
7111
|
const updateId = (update as { update_id?: unknown }).update_id;
|
|
5933
7112
|
const safeUpdateId =
|
|
5934
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
|
+
}
|
|
5935
7121
|
try {
|
|
5936
7122
|
session.ws.send(
|
|
5937
7123
|
JSON.stringify({
|
|
@@ -6205,7 +7391,40 @@ export class TelegramNotificationDaemon {
|
|
|
6205
7391
|
if (!raw.callback_query && (!isAskReply || interceptAskControl || reservedBtw !== undefined)) {
|
|
6206
7392
|
const inbound = decideThreadedInbound(update as never, {
|
|
6207
7393
|
pairedChatId: this.opts.chatId,
|
|
6208
|
-
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
|
+
},
|
|
6209
7428
|
isDuplicate: id => this.dispatchState.seenUpdateIds.has(id),
|
|
6210
7429
|
});
|
|
6211
7430
|
if (inbound.kind === "duplicate") return;
|
|
@@ -6215,10 +7434,45 @@ export class TelegramNotificationDaemon {
|
|
|
6215
7434
|
? { kind: "none" as const }
|
|
6216
7435
|
: parseTelegramControlCommand(inbound.text, this.botUsername);
|
|
6217
7436
|
if (preliminaryControl.kind === "ignored") return;
|
|
6218
|
-
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;
|
|
6219
7473
|
if (preliminaryControl.kind === "invalid" && session?.ws.readyState !== WebSocket.OPEN) return;
|
|
6220
7474
|
if (preliminaryControl.kind === "command" && session?.ws.readyState !== WebSocket.OPEN) {
|
|
6221
|
-
if (await
|
|
7475
|
+
if (await reserveRouteUpdate()) {
|
|
6222
7476
|
try {
|
|
6223
7477
|
await this.botApi.call("sendMessage", {
|
|
6224
7478
|
chat_id: this.opts.chatId,
|
|
@@ -6237,36 +7491,41 @@ export class TelegramNotificationDaemon {
|
|
|
6237
7491
|
return;
|
|
6238
7492
|
}
|
|
6239
7493
|
if (reservedBtw?.kind === "question" && (!reservedBtw.question || inbound.attachment)) {
|
|
6240
|
-
if (!(await
|
|
7494
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6241
7495
|
await this.#sendBtwMessage({
|
|
6242
7496
|
threadId: inbound.threadId,
|
|
6243
7497
|
messageId: inbound.messageId,
|
|
6244
7498
|
text: BTW_USAGE_TEXT,
|
|
7499
|
+
isAuthoritative: routeLeaseAllows,
|
|
6245
7500
|
});
|
|
6246
7501
|
return;
|
|
6247
7502
|
}
|
|
6248
7503
|
if (reservedBtw?.kind === "question" && this.opts.btw?.enabled === false) {
|
|
6249
|
-
if (!(await
|
|
7504
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6250
7505
|
await this.#sendBtwMessage({
|
|
6251
7506
|
threadId: inbound.threadId,
|
|
6252
7507
|
messageId: inbound.messageId,
|
|
6253
7508
|
text: "Telegram /btw is disabled in local settings.",
|
|
7509
|
+
isAuthoritative: routeLeaseAllows,
|
|
6254
7510
|
});
|
|
6255
7511
|
return;
|
|
6256
7512
|
}
|
|
6257
7513
|
if (reservedBtw?.kind === "question" && session?.ws.readyState !== WebSocket.OPEN) {
|
|
6258
|
-
if (!(await
|
|
7514
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6259
7515
|
await this.#sendBtwMessage({
|
|
6260
7516
|
threadId: inbound.threadId,
|
|
6261
7517
|
messageId: inbound.messageId,
|
|
6262
7518
|
text: "Restart this GJC session to enable /btw.",
|
|
7519
|
+
isAuthoritative: routeLeaseAllows,
|
|
6263
7520
|
});
|
|
6264
7521
|
return;
|
|
6265
7522
|
}
|
|
6266
|
-
if (session
|
|
7523
|
+
if (!session || !routeLease) return;
|
|
7524
|
+
if (routeAllows()) {
|
|
6267
7525
|
const attachmentResult = inbound.attachment
|
|
6268
7526
|
? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
|
|
6269
7527
|
: undefined;
|
|
7528
|
+
if (!routeAllows()) return;
|
|
6270
7529
|
const images = attachmentResult?.images ?? [];
|
|
6271
7530
|
const fileNotes = attachmentResult?.fileNotes ?? [];
|
|
6272
7531
|
const hasMedia = inbound.attachment !== undefined || images.length > 0 || fileNotes.length > 0;
|
|
@@ -6289,11 +7548,12 @@ export class TelegramNotificationDaemon {
|
|
|
6289
7548
|
if (btw?.kind === "question") {
|
|
6290
7549
|
const btwQuestion = btw.question;
|
|
6291
7550
|
if (!btwQuestion || hasMedia) {
|
|
6292
|
-
if (!(await
|
|
7551
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6293
7552
|
await this.#sendBtwMessage({
|
|
6294
7553
|
threadId: inbound.threadId,
|
|
6295
7554
|
messageId: inbound.messageId,
|
|
6296
7555
|
text: BTW_USAGE_TEXT,
|
|
7556
|
+
isAuthoritative: routeLeaseAllows,
|
|
6297
7557
|
});
|
|
6298
7558
|
return;
|
|
6299
7559
|
}
|
|
@@ -6303,28 +7563,31 @@ export class TelegramNotificationDaemon {
|
|
|
6303
7563
|
threadId: inbound.threadId,
|
|
6304
7564
|
messageId: inbound.messageId,
|
|
6305
7565
|
text: BTW_QUESTION_LIMIT_TEXT,
|
|
7566
|
+
isAuthoritative: routeLeaseAllows,
|
|
6306
7567
|
});
|
|
6307
7568
|
} catch {
|
|
6308
7569
|
logger.warn("notifications: /btw question-limit delivery failed");
|
|
6309
7570
|
}
|
|
6310
|
-
await
|
|
7571
|
+
await reserveRouteUpdate();
|
|
6311
7572
|
return;
|
|
6312
7573
|
}
|
|
6313
7574
|
if (this.opts.btw?.enabled === false) {
|
|
6314
|
-
if (!(await
|
|
7575
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6315
7576
|
await this.#sendBtwMessage({
|
|
6316
7577
|
threadId: inbound.threadId,
|
|
6317
7578
|
messageId: inbound.messageId,
|
|
6318
7579
|
text: "Telegram /btw is disabled in local settings.",
|
|
7580
|
+
isAuthoritative: routeLeaseAllows,
|
|
6319
7581
|
});
|
|
6320
7582
|
return;
|
|
6321
7583
|
}
|
|
6322
7584
|
if (!session.ephemeralCapable || session.hostGeneration < 1) {
|
|
6323
|
-
if (!(await
|
|
7585
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6324
7586
|
await this.#sendBtwMessage({
|
|
6325
7587
|
threadId: inbound.threadId,
|
|
6326
7588
|
messageId: inbound.messageId,
|
|
6327
7589
|
text: "Restart this GJC session to enable /btw.",
|
|
7590
|
+
isAuthoritative: routeLeaseAllows,
|
|
6328
7591
|
});
|
|
6329
7592
|
return;
|
|
6330
7593
|
}
|
|
@@ -6340,21 +7603,23 @@ export class TelegramNotificationDaemon {
|
|
|
6340
7603
|
this.#btwTerminalTombstones.delete(this.#btwTerminalTombstones.keys().next().value!);
|
|
6341
7604
|
}
|
|
6342
7605
|
if (this.#pendingBtwTurns.size >= BTW_MAX_PENDING) {
|
|
6343
|
-
if (!(await
|
|
7606
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6344
7607
|
await this.#sendBtwMessage({
|
|
6345
7608
|
threadId: inbound.threadId,
|
|
6346
7609
|
messageId: inbound.messageId,
|
|
6347
7610
|
text: BTW_CAPACITY_TEXT,
|
|
7611
|
+
isAuthoritative: routeLeaseAllows,
|
|
6348
7612
|
});
|
|
6349
7613
|
return;
|
|
6350
7614
|
}
|
|
6351
7615
|
const transportSessionId = session.sessionId;
|
|
6352
7616
|
const logicalSessionId = this.#logicalSessionId(session);
|
|
6353
7617
|
const requestId = `btw:${crypto.randomUUID()}`;
|
|
6354
|
-
if (!(await
|
|
7618
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6355
7619
|
const pending: PendingBtwTurn = {
|
|
6356
7620
|
transportSessionId,
|
|
6357
7621
|
logicalSessionId,
|
|
7622
|
+
socketLease: routeLease,
|
|
6358
7623
|
endpointDigest: session.endpointDigest,
|
|
6359
7624
|
generation: session.hostGeneration,
|
|
6360
7625
|
question: btwQuestion,
|
|
@@ -6370,6 +7635,7 @@ export class TelegramNotificationDaemon {
|
|
|
6370
7635
|
threadId: inbound.threadId,
|
|
6371
7636
|
messageId: inbound.messageId,
|
|
6372
7637
|
text: "Unable to start /btw because this GJC session disconnected. Reopen the session and try again.",
|
|
7638
|
+
isAuthoritative: routeLeaseAllows,
|
|
6373
7639
|
});
|
|
6374
7640
|
return;
|
|
6375
7641
|
}
|
|
@@ -6377,9 +7643,10 @@ export class TelegramNotificationDaemon {
|
|
|
6377
7643
|
}
|
|
6378
7644
|
const control = hasMedia ? { kind: "none" as const } : preliminaryControl;
|
|
6379
7645
|
if (control.kind !== "none") {
|
|
6380
|
-
await
|
|
7646
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6381
7647
|
const sendControlNotice = async (body: string): Promise<void> => {
|
|
6382
7648
|
try {
|
|
7649
|
+
if (!routeLeaseAllows()) return;
|
|
6383
7650
|
await this.botApi.call("sendMessage", {
|
|
6384
7651
|
chat_id: this.opts.chatId,
|
|
6385
7652
|
message_thread_id: Number(inbound.threadId),
|
|
@@ -6398,6 +7665,7 @@ export class TelegramNotificationDaemon {
|
|
|
6398
7665
|
await sendControlNotice("Session control unavailable: session is disconnected.");
|
|
6399
7666
|
return;
|
|
6400
7667
|
}
|
|
7668
|
+
if (!routeAllows()) return;
|
|
6401
7669
|
session.ws.send(
|
|
6402
7670
|
JSON.stringify({
|
|
6403
7671
|
type: "control_command",
|
|
@@ -6417,6 +7685,7 @@ export class TelegramNotificationDaemon {
|
|
|
6417
7685
|
// Telegram asks always accept custom text (the SDK maps a string answer
|
|
6418
7686
|
// to the ask's custom-input slot), so route the latest pending ask here.
|
|
6419
7687
|
const pendingAsk = cfg || hasMedia ? undefined : [...session.pending.values()].at(-1);
|
|
7688
|
+
if (!routeAllows()) return;
|
|
6420
7689
|
if (pendingAsk) {
|
|
6421
7690
|
session.ws.send(
|
|
6422
7691
|
JSON.stringify({
|
|
@@ -6427,9 +7696,11 @@ export class TelegramNotificationDaemon {
|
|
|
6427
7696
|
}),
|
|
6428
7697
|
);
|
|
6429
7698
|
await this.rememberSeenUpdateId(inbound.updateId);
|
|
6430
|
-
if (inbound.messageId !== undefined)
|
|
7699
|
+
if (inbound.messageId !== undefined)
|
|
7700
|
+
await this.setReaction(inbound.messageId, QUEUED_REACTION, routeLease);
|
|
6431
7701
|
return;
|
|
6432
7702
|
}
|
|
7703
|
+
if (!routeAllows()) return;
|
|
6433
7704
|
session.ws.send(
|
|
6434
7705
|
JSON.stringify(
|
|
6435
7706
|
cfg
|
|
@@ -6450,8 +7721,12 @@ export class TelegramNotificationDaemon {
|
|
|
6450
7721
|
// flipped to consumed when the session acks the turn that picks it
|
|
6451
7722
|
// up. Config commands are not user turns and get no reaction.
|
|
6452
7723
|
if (!cfg && inbound.messageId !== undefined) {
|
|
6453
|
-
|
|
6454
|
-
|
|
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);
|
|
6455
7730
|
}
|
|
6456
7731
|
}
|
|
6457
7732
|
return;
|
|
@@ -6465,8 +7740,14 @@ export class TelegramNotificationDaemon {
|
|
|
6465
7740
|
pairedChatId: this.opts.chatId,
|
|
6466
7741
|
});
|
|
6467
7742
|
if (decision.kind === "reply") {
|
|
6468
|
-
const session = this.sessions.get(decision.sessionId);
|
|
6469
|
-
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
|
+
) {
|
|
6470
7751
|
await this.sendStaleGuidance(callbackId);
|
|
6471
7752
|
return;
|
|
6472
7753
|
}
|
|
@@ -6517,31 +7798,38 @@ export class TelegramNotificationDaemon {
|
|
|
6517
7798
|
}
|
|
6518
7799
|
|
|
6519
7800
|
async run(): Promise<void> {
|
|
6520
|
-
// Runtime callers can bypass TypeScript's option type.
|
|
6521
|
-
//
|
|
6522
|
-
|
|
6523
|
-
|
|
6524
|
-
await this.startLifecycleControl();
|
|
6525
|
-
return;
|
|
6526
|
-
}
|
|
6527
|
-
this.running = await renewDaemonHeartbeat({
|
|
6528
|
-
settings: this.opts.settings,
|
|
6529
|
-
ownerId: this.opts.ownerId,
|
|
6530
|
-
acquisitionId: this.opts.ownerId,
|
|
6531
|
-
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
6532
|
-
chatId: this.opts.chatId,
|
|
6533
|
-
fs: this.fsImpl,
|
|
6534
|
-
now: this.opts.now,
|
|
6535
|
-
pid: this.opts.pid ?? process.pid,
|
|
6536
|
-
pidIncarnation: this.opts.pidIncarnation,
|
|
6537
|
-
});
|
|
6538
|
-
if (!this.running) return;
|
|
6539
|
-
this.runtime.start();
|
|
6540
|
-
this.startOwnershipHeartbeatTimer();
|
|
6541
|
-
this.startFlushTimer();
|
|
6542
|
-
this.startScanTimer();
|
|
6543
|
-
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;
|
|
6544
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();
|
|
6545
7833
|
await this.refreshBotIdentity();
|
|
6546
7834
|
await this.registerBotCommands();
|
|
6547
7835
|
await this.loadAliases();
|
|
@@ -6549,9 +7837,6 @@ export class TelegramNotificationDaemon {
|
|
|
6549
7837
|
await this.loadSeenUpdateIds();
|
|
6550
7838
|
await this.replyStore.load();
|
|
6551
7839
|
await this.runScan();
|
|
6552
|
-
// Owner-only: start the session-lifecycle control server now that
|
|
6553
|
-
// ownership is confirmed (singleton-safe). Best-effort; degrades.
|
|
6554
|
-
await this.startLifecycleControl();
|
|
6555
7840
|
let idleSince = this.runtime.now();
|
|
6556
7841
|
while (this.running) {
|
|
6557
7842
|
if (await this.controlStopRequested()) break;
|
|
@@ -6603,61 +7888,64 @@ export class TelegramNotificationDaemon {
|
|
|
6603
7888
|
await this.runtime.sleep(10);
|
|
6604
7889
|
}
|
|
6605
7890
|
} finally {
|
|
6606
|
-
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
6630
|
-
|
|
6631
|
-
|
|
6632
|
-
|
|
6633
|
-
const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
|
|
6634
|
-
const completed = await Promise.race([
|
|
6635
|
-
shutdown.then(
|
|
6636
|
-
() => true,
|
|
6637
|
-
error => {
|
|
6638
|
-
logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
|
|
6639
|
-
return false;
|
|
6640
|
-
},
|
|
6641
|
-
),
|
|
6642
|
-
deadline.promise,
|
|
6643
|
-
]);
|
|
6644
|
-
clearTimeout(deadlineTimer);
|
|
6645
|
-
const quiesced = completed && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
|
|
6646
|
-
if (!quiesced || !persisted) {
|
|
6647
|
-
logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
|
|
6648
|
-
} else {
|
|
6649
|
-
await releaseDaemonOwnership({
|
|
6650
|
-
settings: this.opts.settings,
|
|
6651
|
-
ownerId: this.opts.ownerId,
|
|
6652
|
-
acquisitionId: this.opts.ownerId,
|
|
6653
|
-
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
6654
|
-
chatId: this.opts.chatId,
|
|
6655
|
-
pid: this.opts.pid ?? process.pid,
|
|
6656
|
-
generation: DAEMON_GENERATION,
|
|
6657
|
-
pidIncarnation: this.opts.pidIncarnation,
|
|
6658
|
-
fs: this.fsImpl,
|
|
6659
|
-
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;
|
|
6660
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
|
+
}
|
|
6661
7949
|
}
|
|
6662
7950
|
}
|
|
6663
7951
|
}
|