@gajae-code/coding-agent 0.11.6 → 0.11.8
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 +92 -0
- package/dist/types/capability/index.d.ts +10 -20
- package/dist/types/capability/types.d.ts +3 -0
- package/dist/types/cli/args.d.ts +0 -2
- 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 +34 -4
- package/dist/types/config/model-profile-activation.d.ts +4 -3
- package/dist/types/config/model-profile-contract.d.ts +38 -0
- package/dist/types/config/settings-schema.d.ts +70 -7
- package/dist/types/edit/modes/patch.d.ts +13 -0
- package/dist/types/edit/path-mutation-lock.d.ts +15 -0
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/eval/py/runner-artifact.d.ts +8 -0
- package/dist/types/eval/py/tool-bridge.d.ts +1 -2
- package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +6 -5
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +43 -5
- package/dist/types/main.d.ts +32 -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/runtime-mcp-add-wizard.d.ts +2 -1
- package/dist/types/modes/components/tool-status-header.d.ts +4 -2
- package/dist/types/modes/components/welcome.d.ts +2 -0
- package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +7 -2
- package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
- package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
- package/dist/types/modes/types.d.ts +4 -0
- package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
- package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
- package/dist/types/runtime-mcp/manager.d.ts +1 -1
- package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
- package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
- package/dist/types/sdk/broker/broker.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -1
- package/dist/types/sdk/bus/config-commands.d.ts +2 -2
- package/dist/types/sdk/bus/config.d.ts +2 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +26 -1
- package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
- package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
- package/dist/types/sdk/bus/notification-service.d.ts +34 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +18 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +189 -4
- package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
- 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/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +1 -1
- package/dist/types/sdk/host/query/handlers.d.ts +9 -0
- package/dist/types/sdk/index.d.ts +2 -0
- package/dist/types/sdk/prompt-status.d.ts +88 -0
- package/dist/types/sdk/session-directory.d.ts +1 -1
- package/dist/types/sdk/startup-capability.d.ts +3 -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 +30 -5
- package/dist/types/session/btw-contract.d.ts +16 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +32 -9
- package/dist/types/session/internal/managed-session-storage.d.ts +27 -10
- package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
- package/dist/types/session/session-manager.d.ts +25 -4
- package/dist/types/session/session-storage.d.ts +18 -0
- package/dist/types/setup/credential-import.d.ts +5 -1
- package/dist/types/setup/provider-onboarding.d.ts +2 -0
- package/dist/types/ssh/utils.d.ts +1 -0
- package/dist/types/task/discovery.d.ts +3 -1
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/task/receipt.d.ts +2 -0
- package/dist/types/task/types.d.ts +94 -0
- package/dist/types/tools/ask.d.ts +454 -174
- package/dist/types/tools/fetch.d.ts +5 -8
- package/dist/types/tools/image-gen.d.ts +63 -1
- package/dist/types/tools/index.d.ts +4 -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/prompt-suggestion.d.ts +33 -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/capture-platform-shortcut-labels-showcase.ts +342 -0
- package/scripts/dogfood-repository-binding.ts +209 -0
- package/scripts/generate-sdk-operation-inventory.ts +4 -0
- package/scripts/run-test-manifest.ts +4 -4
- package/src/capability/index.ts +50 -42
- package/src/capability/types.ts +3 -0
- package/src/cli/args.ts +2 -92
- package/src/cli/notify-cli.ts +5 -38
- package/src/cli/setup-cli.ts +10 -4
- package/src/cli.ts +52 -9
- package/src/commands/daemon.ts +22 -5
- package/src/commands/sdk.ts +44 -11
- package/src/config/keybindings.ts +158 -25
- package/src/config/model-profile-activation.ts +7 -18
- package/src/config/model-profile-contract.ts +161 -0
- package/src/config/model-profiles.ts +18 -0
- package/src/config/model-registry.ts +11 -1
- package/src/config/settings-schema.ts +73 -7
- package/src/coordinator-mcp/model-preset.ts +21 -74
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
- package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
- package/src/discovery/ssh.ts +12 -1
- package/src/edit/index.ts +3 -6
- package/src/edit/modes/patch.ts +38 -1
- package/src/edit/modes/replace.ts +42 -0
- package/src/edit/path-mutation-lock.ts +112 -0
- package/src/eval/py/executor.ts +30 -11
- package/src/eval/py/kernel.ts +1 -21
- package/src/eval/py/prelude.py +2 -2
- package/src/eval/py/runner-artifact.ts +84 -0
- package/src/eval/py/tool-bridge.ts +34 -18
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
- package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
- package/src/gjc-runtime/launch-tmux.ts +1 -1
- package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
- package/src/gjc-runtime/ralplan-runtime.ts +75 -3
- package/src/gjc-runtime/repository-binding.ts +267 -0
- package/src/gjc-runtime/session-state-sidecar.ts +25 -9
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
- package/src/gjc-runtime/tmux-sessions.ts +45 -6
- package/src/gjc-runtime/ultragoal-guard.ts +93 -15
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +456 -31
- 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 +12 -10
- package/src/internal-urls/local-protocol.ts +64 -21
- package/src/internal-urls/mcp-protocol.ts +73 -11
- package/src/main.ts +111 -37
- package/src/modes/DESIGN.md +5 -0
- package/src/modes/acp/acp-agent.ts +50 -16
- package/src/modes/action-registry.ts +1 -1
- package/src/modes/components/btw-panel.ts +82 -16
- package/src/modes/components/model-selector.ts +73 -11
- package/src/modes/components/plan-preview-overlay.ts +1 -1
- package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
- package/src/modes/components/tool-status-header.ts +25 -4
- package/src/modes/components/welcome.ts +22 -18
- package/src/modes/controllers/btw-controller.ts +119 -36
- package/src/modes/controllers/command-controller.ts +97 -54
- package/src/modes/controllers/event-controller.ts +10 -0
- package/src/modes/controllers/input-controller.ts +38 -2
- package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
- package/src/modes/controllers/selector-controller.ts +149 -63
- package/src/modes/interactive-mode.ts +57 -34
- package/src/modes/prompt-action-autocomplete.ts +32 -13
- package/src/modes/prompt-suggestion-controller.ts +96 -0
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
- package/src/modes/types.ts +4 -0
- package/src/modes/utils/hotkeys-markdown.ts +64 -36
- package/src/modes/utils/ui-helpers.ts +5 -3
- package/src/prompts/agents/architect.md +0 -1
- package/src/prompts/agents/critic.md +0 -1
- package/src/prompts/system/btw-user.md +3 -8
- package/src/prompts/system/prompt-suggestion-system.md +32 -0
- package/src/prompts/system/system-prompt.md +6 -4
- package/src/prompts/tools/cron.md +3 -1
- 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/manager.ts +51 -53
- package/src/runtime-mcp/oauth-flow.ts +0 -72
- package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
- package/src/runtime-mcp/transports/http.ts +63 -22
- package/src/sdk/broker/broker.ts +34 -20
- package/src/sdk/broker/lifecycle.ts +185 -36
- package/src/sdk/bus/chat-daemon-control.ts +19 -5
- package/src/sdk/bus/config-commands.ts +2 -2
- package/src/sdk/bus/config.ts +9 -1
- package/src/sdk/bus/conversation-store.ts +40 -6
- package/src/sdk/bus/index.ts +244 -70
- package/src/sdk/bus/lifecycle-commands.ts +63 -10
- package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
- package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
- package/src/sdk/bus/notification-orchestration.ts +12 -0
- package/src/sdk/bus/notification-service.ts +68 -20
- package/src/sdk/bus/prompt-reconciliation.ts +239 -0
- package/src/sdk/bus/recent-activity.ts +90 -26
- package/src/sdk/bus/telegram-daemon-contract.ts +18 -3
- package/src/sdk/bus/telegram-daemon-control.ts +147 -35
- package/src/sdk/bus/telegram-daemon.ts +2463 -450
- package/src/sdk/bus/telegram-reference.ts +20 -2
- package/src/sdk/bus/topic-registry.ts +451 -12
- package/src/sdk/cli/session-cli.ts +15 -2
- package/src/sdk/client/discovery.ts +19 -3
- package/src/sdk/client/liveness.ts +49 -0
- package/src/sdk/host/control/dispatch.ts +1 -1
- package/src/sdk/host/control/operations.ts +1 -1
- package/src/sdk/host/host.ts +2 -6
- package/src/sdk/host/query/handlers.ts +70 -5
- package/src/sdk/index.ts +12 -0
- package/src/sdk/prompt-status.ts +97 -0
- package/src/sdk/protocol/operation-inventory.generated.json +58 -0
- package/src/sdk/protocol/operation-registry.ts +18 -4
- package/src/sdk/session-directory.ts +5 -1
- package/src/sdk/session.ts +31 -5
- package/src/sdk/startup-capability.ts +9 -1
- 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 +292 -154
- package/src/session/blob-store.ts +25 -7
- package/src/session/btw-contract.ts +59 -0
- package/src/session/internal/managed-session-scope.ts +1163 -137
- package/src/session/internal/managed-session-storage.ts +397 -234
- package/src/session/internal/native-publish-outcome.ts +266 -0
- package/src/session/session-manager.ts +620 -403
- package/src/session/session-storage.ts +128 -11
- 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/task/gjc-command.ts +2 -2
- package/src/task/index.ts +121 -15
- package/src/task/receipt.ts +3 -0
- package/src/task/types.ts +34 -3
- package/src/tools/ask.ts +199 -89
- package/src/tools/ast-edit.ts +2 -2
- package/src/tools/bash-pty-selection.ts +2 -2
- package/src/tools/browser/screenshot-format.ts +6 -1
- package/src/tools/cron.ts +1 -1
- package/src/tools/fetch.ts +13 -131
- package/src/tools/image-gen.ts +476 -23
- package/src/tools/index.ts +4 -0
- package/src/tools/read.ts +2 -0
- package/src/tools/render-utils.ts +7 -1
- package/src/tools/skill-discovery.ts +39 -3
- package/src/tools/sqlite-reader.ts +63 -5
- package/src/utils/edit-mode.ts +2 -2
- package/src/utils/pasted-image-path.ts +15 -6
- package/src/utils/prompt-suggestion.ts +265 -0
- package/src/utils/shell-snapshot.ts +173 -63
- package/src/utils/sixel.ts +3 -3
- package/src/web/insane/bridge.ts +5 -1
- package/src/web/insane/url-guard.ts +140 -27
- package/src/web/scrapers/docs-rs.ts +30 -5
- 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,
|
|
@@ -36,7 +37,7 @@ import {
|
|
|
36
37
|
type TelegramDaemonControlDeps,
|
|
37
38
|
TelegramDaemonController,
|
|
38
39
|
} from "./telegram-daemon-control";
|
|
39
|
-
import { withTelegramSetupLease } from "./telegram-setup";
|
|
40
|
+
import { type TelegramSetupPreflight, withTelegramSetupLease } from "./telegram-setup";
|
|
40
41
|
|
|
41
42
|
export { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
|
|
42
43
|
|
|
@@ -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. */
|
|
@@ -141,7 +143,7 @@ export interface TelegramDaemonFs {
|
|
|
141
143
|
open(path: string, flags: string, mode?: number): Promise<{ close(): Promise<void> }>;
|
|
142
144
|
readdir(path: string): Promise<string[]>;
|
|
143
145
|
chmod(path: string, mode: number): Promise<void>;
|
|
144
|
-
stat?(path: string): Promise<{ mtimeMs: number }>;
|
|
146
|
+
stat?(path: string): Promise<{ mtimeMs: number; size?: number; dev?: number; ino?: number; ctimeMs?: number }>;
|
|
145
147
|
readEndpointFile?(path: string): Promise<NotificationEndpointFile>;
|
|
146
148
|
exactUnlink?(path: string, identity: NotificationEndpointFileIdentity): Promise<NotificationExactUnlinkResult>;
|
|
147
149
|
}
|
|
@@ -221,11 +223,45 @@ 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
|
|
227
232
|
// busy-loop the daemon.
|
|
228
233
|
const POLL_BACKOFF_MS = 1_000;
|
|
234
|
+
const AUTOMATIC_RELOAD_COOLDOWN_MS = 10 * 60 * 1_000;
|
|
235
|
+
// Default freshness-poll window a cooldown contender waits for a sibling's
|
|
236
|
+
// replacement daemon to publish a fresh ready owner before reloading itself.
|
|
237
|
+
const RELOAD_FRESHNESS_WAIT_MS = 15_000;
|
|
238
|
+
// Cooperative-then-forced termination + replacement-readiness bounds the reload
|
|
239
|
+
// controller can spend while holding the reservation lock (mirrors the control
|
|
240
|
+
// plane's graceful/kill defaults plus a readiness wait).
|
|
241
|
+
const RELOAD_CONTROLLER_GRACEFUL_MS = 8_000;
|
|
242
|
+
const RELOAD_CONTROLLER_KILL_MS = 3_000;
|
|
243
|
+
const RELOAD_RESERVATION_HEADROOM_MS = 10_000;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* File-lock options whose acquisition budget covers the full reload-reservation
|
|
247
|
+
* critical section: the in-lock freshness poll plus the controller's
|
|
248
|
+
* graceful+kill+readiness sequence, with headroom. A contender must be able to
|
|
249
|
+
* wait out a legitimate slow reload and then attach, never fail startup.
|
|
250
|
+
*/
|
|
251
|
+
export function reloadReservationLockOptions(input: {
|
|
252
|
+
freshnessWaitMs: number;
|
|
253
|
+
readinessTimeoutMs: number;
|
|
254
|
+
retryDelayMs?: number;
|
|
255
|
+
}): { staleMs: number; retries: number; retryDelayMs: number } {
|
|
256
|
+
const retryDelayMs = Math.max(input.retryDelayMs ?? 100, 1);
|
|
257
|
+
const criticalMs =
|
|
258
|
+
Math.max(input.freshnessWaitMs, 0) +
|
|
259
|
+
RELOAD_CONTROLLER_GRACEFUL_MS +
|
|
260
|
+
RELOAD_CONTROLLER_KILL_MS +
|
|
261
|
+
Math.max(input.readinessTimeoutMs, 0) +
|
|
262
|
+
RELOAD_RESERVATION_HEADROOM_MS;
|
|
263
|
+
return { staleMs: 10_000, retries: Math.max(1, Math.ceil(criticalMs / retryDelayMs)), retryDelayMs };
|
|
264
|
+
}
|
|
229
265
|
// Telegram clears a chat action after ~5s; refresh slightly sooner to keep the
|
|
230
266
|
// typing indicator alive while the agent is busy.
|
|
231
267
|
const TYPING_REFRESH_INTERVAL_MS = 4_000;
|
|
@@ -245,8 +281,13 @@ const BTW_USAGE_TEXT = "Usage: /btw <question>";
|
|
|
245
281
|
const BTW_CAPACITY_TEXT = "Too many /btw questions are pending. Wait for one to finish and try again.";
|
|
246
282
|
export const BTW_QUESTION_MAX_UNICODE_SCALARS = 4_096;
|
|
247
283
|
export const BTW_QUESTION_MAX_UTF8_BYTES = 16_384;
|
|
284
|
+
const TELEGRAM_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
|
|
285
|
+
const TELEGRAM_SESSION_ATTACHMENT_MAX_COUNT = 20;
|
|
286
|
+
const TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024;
|
|
287
|
+
const TELEGRAM_ATTACHMENT_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
248
288
|
const BTW_QUESTION_LIMIT_TEXT = "Question must be at most 4096 Unicode scalar values and 16384 UTF-8 bytes.";
|
|
249
289
|
type ParsedBtwCommand = { kind: "question"; question: string } | { kind: "ignored" };
|
|
290
|
+
type TelegramFileDownload = { bytes: Buffer } | { failure: "download_failed" | "too_large" };
|
|
250
291
|
class ThreadedModeCapabilityRefusal extends Error {}
|
|
251
292
|
|
|
252
293
|
/** Only an explicit Bot API capability refusal may enable flat private-chat delivery. */
|
|
@@ -497,9 +538,17 @@ type LegacyOwnershipLockMetadata = {
|
|
|
497
538
|
incarnation?: string;
|
|
498
539
|
startedAt: number;
|
|
499
540
|
};
|
|
541
|
+
type V010OwnershipLockMetadata = {
|
|
542
|
+
size: 0;
|
|
543
|
+
mtimeMs?: number;
|
|
544
|
+
dev?: number;
|
|
545
|
+
ino?: number;
|
|
546
|
+
ctimeMs?: number;
|
|
547
|
+
};
|
|
500
548
|
type OwnershipLockRead =
|
|
501
549
|
| { kind: "missing" }
|
|
502
550
|
| { kind: "malformed"; raw: string; mtimeMs?: number }
|
|
551
|
+
| { kind: "v010"; metadata: V010OwnershipLockMetadata }
|
|
503
552
|
| { kind: "legacy"; metadata: LegacyOwnershipLockMetadata }
|
|
504
553
|
| { kind: "valid"; metadata: OwnershipLockMetadata };
|
|
505
554
|
|
|
@@ -512,6 +561,20 @@ export async function readOwnershipLock(fsImpl: TelegramDaemonFs, file: string):
|
|
|
512
561
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" };
|
|
513
562
|
throw error;
|
|
514
563
|
}
|
|
564
|
+
if (raw.length === 0) {
|
|
565
|
+
const stat = await fsImpl.stat?.(file).catch(() => undefined);
|
|
566
|
+
if (stat?.size !== 0) return { kind: "malformed", raw, mtimeMs: stat?.mtimeMs };
|
|
567
|
+
return {
|
|
568
|
+
kind: "v010",
|
|
569
|
+
metadata: {
|
|
570
|
+
size: 0,
|
|
571
|
+
...(stat?.mtimeMs === undefined ? {} : { mtimeMs: stat.mtimeMs }),
|
|
572
|
+
...(stat?.dev === undefined ? {} : { dev: stat.dev }),
|
|
573
|
+
...(stat?.ino === undefined ? {} : { ino: stat.ino }),
|
|
574
|
+
...(stat?.ctimeMs === undefined ? {} : { ctimeMs: stat.ctimeMs }),
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
|
515
578
|
try {
|
|
516
579
|
const value = JSON.parse(raw) as Partial<OwnershipLockMetadata>;
|
|
517
580
|
const pid = value.pid;
|
|
@@ -554,6 +617,7 @@ export function liveOwnershipLockDecision(input: {
|
|
|
554
617
|
| { acquired: false; attached: false; blocked: true }
|
|
555
618
|
| { acquired: false; attached: false; provisional: true }
|
|
556
619
|
| undefined {
|
|
620
|
+
if (input.lock.kind === "v010") return { acquired: false, attached: false, blocked: true };
|
|
557
621
|
if (input.lock.kind !== "valid" && input.lock.kind !== "legacy") return undefined;
|
|
558
622
|
if (!input.pidAlive(input.lock.metadata.pid)) return undefined;
|
|
559
623
|
if (input.lock.kind === "legacy") {
|
|
@@ -575,8 +639,9 @@ function ownershipLockMatches(left: OwnershipLockRead, right: OwnershipLockRead)
|
|
|
575
639
|
if (left.kind === "missing") return true;
|
|
576
640
|
if (left.kind === "malformed" && right.kind === "malformed")
|
|
577
641
|
return left.raw === right.raw && left.mtimeMs !== undefined && left.mtimeMs === right.mtimeMs;
|
|
578
|
-
if ((left.kind
|
|
579
|
-
return
|
|
642
|
+
if ((left.kind === "legacy" && right.kind === "legacy") || (left.kind === "v010" && right.kind === "v010"))
|
|
643
|
+
return JSON.stringify(left.metadata) === JSON.stringify(right.metadata);
|
|
644
|
+
if (left.kind !== "valid" || right.kind !== "valid") return false;
|
|
580
645
|
return JSON.stringify(left.metadata) === JSON.stringify(right.metadata);
|
|
581
646
|
}
|
|
582
647
|
|
|
@@ -591,8 +656,27 @@ function ownershipLockMatchesState(lock: OwnershipLockRead, state: DaemonState |
|
|
|
591
656
|
);
|
|
592
657
|
}
|
|
593
658
|
|
|
594
|
-
function ownershipLockMatchesStoppedState(
|
|
595
|
-
|
|
659
|
+
function ownershipLockMatchesStoppedState(
|
|
660
|
+
lock: OwnershipLockRead,
|
|
661
|
+
state: unknown,
|
|
662
|
+
pidAlive: (pid: number) => boolean,
|
|
663
|
+
): boolean {
|
|
664
|
+
if (isExplicitlyStoppedDaemonState(state)) return ownershipLockMatchesState(lock, state);
|
|
665
|
+
if (!isLegacyStoppedDaemonState(state)) return false;
|
|
666
|
+
const legacyState = state as Pick<DaemonState, "pid" | "startedAt" | "generation"> & { stoppedAt: number };
|
|
667
|
+
if (lock.kind === "v010")
|
|
668
|
+
return (
|
|
669
|
+
legacyState.generation === 3 &&
|
|
670
|
+
!pidAlive(legacyState.pid) &&
|
|
671
|
+
lock.metadata.mtimeMs !== undefined &&
|
|
672
|
+
lock.metadata.mtimeMs <= legacyState.stoppedAt
|
|
673
|
+
);
|
|
674
|
+
if (lock.kind !== "legacy") return false;
|
|
675
|
+
return (
|
|
676
|
+
lock.metadata.pid === legacyState.pid &&
|
|
677
|
+
lock.metadata.startedAt <= legacyState.startedAt &&
|
|
678
|
+
legacyState.startedAt <= legacyState.stoppedAt
|
|
679
|
+
);
|
|
596
680
|
}
|
|
597
681
|
|
|
598
682
|
async function transitionLockIsHeldByCaller(input: {
|
|
@@ -607,6 +691,37 @@ function ownershipLockMatchesMetadata(lock: OwnershipLockRead, metadata: Ownersh
|
|
|
607
691
|
return lock.kind === "valid" && JSON.stringify(lock.metadata) === JSON.stringify(metadata);
|
|
608
692
|
}
|
|
609
693
|
|
|
694
|
+
/**
|
|
695
|
+
* Accept an exact unlink only when it either completed or returned typed
|
|
696
|
+
* retained authority — a concrete detached quarantine plus a proven-absent
|
|
697
|
+
* canonical pathname. Anything else stays fail-closed.
|
|
698
|
+
*/
|
|
699
|
+
async function exactUnlinkAcceptedWithRetainedEvidence(
|
|
700
|
+
fsImpl: TelegramDaemonFs,
|
|
701
|
+
file: string,
|
|
702
|
+
identity: NotificationEndpointFileIdentity,
|
|
703
|
+
): Promise<boolean> {
|
|
704
|
+
const removed = await fsImpl.exactUnlink!(file, identity);
|
|
705
|
+
if (removed.ok) return true;
|
|
706
|
+
return (
|
|
707
|
+
removed.code === "cleanup_pending" &&
|
|
708
|
+
typeof removed.detachedPath === "string" &&
|
|
709
|
+
removed.detachedPath.length > 0 &&
|
|
710
|
+
(await fsImpl.readEndpointFile!(file).catch(() => undefined)) === undefined
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function unlinkOwnershipLockExactly(
|
|
715
|
+
fsImpl: TelegramDaemonFs,
|
|
716
|
+
file: string,
|
|
717
|
+
expected: OwnershipLockRead,
|
|
718
|
+
): Promise<boolean> {
|
|
719
|
+
if (expected.kind === "missing" || !fsImpl.readEndpointFile || !fsImpl.exactUnlink) return false;
|
|
720
|
+
const endpoint = await fsImpl.readEndpointFile(file).catch(() => undefined);
|
|
721
|
+
if (!endpoint || !ownershipLockMatches(expected, await readOwnershipLock(fsImpl, file))) return false;
|
|
722
|
+
return await exactUnlinkAcceptedWithRetainedEvidence(fsImpl, file, endpoint.identity);
|
|
723
|
+
}
|
|
724
|
+
|
|
610
725
|
/**
|
|
611
726
|
* Replace an acquisition's lease only after proving the old lease is unchanged.
|
|
612
727
|
* The transition fence serializes lifecycle writers; the exact read-back prevents
|
|
@@ -670,6 +785,7 @@ async function ownershipLockIsReclaimable(input: {
|
|
|
670
785
|
pidIncarnation: (pid: number) => string | undefined;
|
|
671
786
|
}): Promise<boolean> {
|
|
672
787
|
if (input.lock.kind === "missing") return true;
|
|
788
|
+
if (input.lock.kind === "v010") return false;
|
|
673
789
|
if (input.lock.kind === "malformed") {
|
|
674
790
|
if (!input.fs.stat) return false;
|
|
675
791
|
const stat = await input.fs.stat(input.path).catch(() => undefined);
|
|
@@ -853,8 +969,174 @@ function notificationRootForCwd(cwd: string): string {
|
|
|
853
969
|
return path.join(cwd, ".gjc", "state");
|
|
854
970
|
}
|
|
855
971
|
|
|
972
|
+
/**
|
|
973
|
+
* Leak artifact prefixes left when native exact-unlink cleanup is retained
|
|
974
|
+
* (#2956). Nothing previously reaped these; they accumulate in the notifications
|
|
975
|
+
* directory across ownership transitions.
|
|
976
|
+
*/
|
|
977
|
+
export const NOTIFICATION_LEAK_ARTIFACT_PREFIXES = [
|
|
978
|
+
".gjc-delete-daemon-transition-",
|
|
979
|
+
".gjc-exact-unlink-placeholder-",
|
|
980
|
+
".gjc-delete-notification-endpoint-",
|
|
981
|
+
] as const;
|
|
982
|
+
|
|
983
|
+
/** Grace window before a leak artifact is reaped (covers in-flight unlinks). */
|
|
984
|
+
export const NOTIFICATION_LEAK_ARTIFACT_GRACE_MS = 5 * 60_000;
|
|
985
|
+
|
|
986
|
+
/** True when a path is permanently gone (not a transient I/O blip). */
|
|
987
|
+
export function isPermanentMissingPathError(error: unknown): boolean {
|
|
988
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
989
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export function isNotificationLeakArtifactName(name: string): boolean {
|
|
993
|
+
return NOTIFICATION_LEAK_ARTIFACT_PREFIXES.some(prefix => name.startsWith(prefix));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
type NotificationRootsRegistry = {
|
|
997
|
+
version?: number;
|
|
998
|
+
roots?: string[];
|
|
999
|
+
managedRoots?: string[];
|
|
1000
|
+
sessions?: Record<string, string>;
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Drop permanently missing scan roots from the durable registry under the roots
|
|
1005
|
+
* lock. Session map entries that pointed only at those roots are removed too.
|
|
1006
|
+
*/
|
|
1007
|
+
export async function pruneMissingNotificationRoots(input: {
|
|
1008
|
+
settings: Settings;
|
|
1009
|
+
fs?: TelegramDaemonFs;
|
|
1010
|
+
/** When set, only these roots are considered; otherwise every registered root is probed. */
|
|
1011
|
+
candidates?: readonly string[];
|
|
1012
|
+
}): Promise<{ pruned: string[]; remaining: number }> {
|
|
1013
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
1014
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
1015
|
+
await ensureDir(fsImpl, paths.dir);
|
|
1016
|
+
const pruned: string[] = [];
|
|
1017
|
+
let remaining = 0;
|
|
1018
|
+
await withFileLock(
|
|
1019
|
+
paths.roots,
|
|
1020
|
+
async () => {
|
|
1021
|
+
const current = (await readJson<NotificationRootsRegistry>(fsImpl, paths.roots)) ?? {};
|
|
1022
|
+
const roots = [...(current.roots ?? [])];
|
|
1023
|
+
const managedRoots = new Set(current.managedRoots ?? []);
|
|
1024
|
+
const sessions = { ...(current.sessions ?? {}) };
|
|
1025
|
+
const candidateSet = input.candidates ? new Set(input.candidates) : undefined;
|
|
1026
|
+
const survivors: string[] = [];
|
|
1027
|
+
for (const root of roots) {
|
|
1028
|
+
if (candidateSet && !candidateSet.has(root)) {
|
|
1029
|
+
survivors.push(root);
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
const sdkDir = path.join(root, "sdk");
|
|
1033
|
+
try {
|
|
1034
|
+
await fsImpl.readdir(sdkDir);
|
|
1035
|
+
survivors.push(root);
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
if (!isPermanentMissingPathError(error)) {
|
|
1038
|
+
// Transient unreadable: keep for the next pass.
|
|
1039
|
+
survivors.push(root);
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
// Also accept a missing sdk dir when the root itself is still a directory
|
|
1043
|
+
// (empty registration) — still permanently useless for scans.
|
|
1044
|
+
try {
|
|
1045
|
+
await fsImpl.readdir(root);
|
|
1046
|
+
} catch (rootError) {
|
|
1047
|
+
if (!isPermanentMissingPathError(rootError)) {
|
|
1048
|
+
survivors.push(root);
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
pruned.push(root);
|
|
1053
|
+
managedRoots.delete(root);
|
|
1054
|
+
for (const [sessionId, mapped] of Object.entries(sessions)) {
|
|
1055
|
+
if (mapped === root) delete sessions[sessionId];
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
remaining = survivors.length;
|
|
1060
|
+
if (pruned.length === 0) return;
|
|
1061
|
+
await writeJsonAtomic(fsImpl, paths.roots, {
|
|
1062
|
+
version: 1,
|
|
1063
|
+
roots: Array.from(new Set(survivors)).sort(),
|
|
1064
|
+
managedRoots: Array.from(managedRoots).sort(),
|
|
1065
|
+
sessions,
|
|
1066
|
+
});
|
|
1067
|
+
},
|
|
1068
|
+
{ staleMs: 10_000 },
|
|
1069
|
+
);
|
|
1070
|
+
return { pruned, remaining };
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Reap retained exact-unlink / ownership-transition quarantine files older than
|
|
1075
|
+
* the grace window from the notifications directory.
|
|
1076
|
+
*/
|
|
1077
|
+
export async function reapStaleNotificationArtifacts(input: {
|
|
1078
|
+
settings: Settings;
|
|
1079
|
+
fs?: TelegramDaemonFs;
|
|
1080
|
+
now?: () => number;
|
|
1081
|
+
graceMs?: number;
|
|
1082
|
+
}): Promise<{ removed: string[]; skipped: number }> {
|
|
1083
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
1084
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
1085
|
+
await ensureDir(fsImpl, paths.dir);
|
|
1086
|
+
const now = input.now?.() ?? Date.now();
|
|
1087
|
+
const graceMs = input.graceMs ?? NOTIFICATION_LEAK_ARTIFACT_GRACE_MS;
|
|
1088
|
+
const removed: string[] = [];
|
|
1089
|
+
let skipped = 0;
|
|
1090
|
+
let names: string[];
|
|
1091
|
+
try {
|
|
1092
|
+
names = await fsImpl.readdir(paths.dir);
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
if (isPermanentMissingPathError(error)) return { removed, skipped };
|
|
1095
|
+
throw error;
|
|
1096
|
+
}
|
|
1097
|
+
for (const name of names) {
|
|
1098
|
+
if (!isNotificationLeakArtifactName(name)) continue;
|
|
1099
|
+
const file = path.join(paths.dir, name);
|
|
1100
|
+
try {
|
|
1101
|
+
const stat = fsImpl.stat ? await fsImpl.stat(file) : undefined;
|
|
1102
|
+
const age = stat ? now - stat.mtimeMs : Number.POSITIVE_INFINITY;
|
|
1103
|
+
if (Number.isFinite(age) && age < graceMs) {
|
|
1104
|
+
skipped += 1;
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
await fsImpl.unlink(file);
|
|
1108
|
+
removed.push(file);
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
if (isPermanentMissingPathError(error)) continue;
|
|
1111
|
+
// Best-effort: a busy file must not fail daemon ownership.
|
|
1112
|
+
skipped += 1;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return { removed, skipped };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Startup / ownership self-heal: prune dead roots and reap leak artifacts so
|
|
1120
|
+
* `gjc daemon reload` recovers a degraded install without manual surgery (#2956).
|
|
1121
|
+
*/
|
|
1122
|
+
export async function healTelegramDaemonNotificationState(input: {
|
|
1123
|
+
settings: Settings;
|
|
1124
|
+
fs?: TelegramDaemonFs;
|
|
1125
|
+
now?: () => number;
|
|
1126
|
+
graceMs?: number;
|
|
1127
|
+
}): Promise<{ prunedRoots: string[]; removedArtifacts: string[] }> {
|
|
1128
|
+
const prune = await pruneMissingNotificationRoots(input);
|
|
1129
|
+
const reap = await reapStaleNotificationArtifacts(input);
|
|
1130
|
+
if (prune.pruned.length > 0 || reap.removed.length > 0) {
|
|
1131
|
+
logger.warn(
|
|
1132
|
+
`notifications: self-heal pruned ${prune.pruned.length} dead root(s), reaped ${reap.removed.length} leak artifact(s)`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
return { prunedRoots: prune.pruned, removedArtifacts: reap.removed };
|
|
1136
|
+
}
|
|
1137
|
+
|
|
856
1138
|
function validBotToken(token: unknown): token is string {
|
|
857
|
-
return typeof token === "string" && token.length > 0;
|
|
1139
|
+
return typeof token === "string" && token.trim().length > 0;
|
|
858
1140
|
}
|
|
859
1141
|
|
|
860
1142
|
function isExplicitlyStoppedDaemonState(state: unknown): state is DaemonState {
|
|
@@ -942,24 +1224,98 @@ export function hasSafeDaemonStateShape(state: unknown): state is DaemonState {
|
|
|
942
1224
|
);
|
|
943
1225
|
}
|
|
944
1226
|
|
|
945
|
-
|
|
1227
|
+
const V010_PARENT_STATE_KEYS = [
|
|
1228
|
+
"pid",
|
|
1229
|
+
"ownerId",
|
|
1230
|
+
"tokenFingerprint",
|
|
1231
|
+
"chatId",
|
|
1232
|
+
"startedAt",
|
|
1233
|
+
"heartbeatAt",
|
|
1234
|
+
"roots",
|
|
1235
|
+
"version",
|
|
1236
|
+
] as const;
|
|
1237
|
+
const V010_GENERATION_3_PARENT_STATE_KEYS = [...V010_PARENT_STATE_KEYS, "generation"] as const;
|
|
1238
|
+
|
|
1239
|
+
type ParentDaemonStateBase = Omit<
|
|
1240
|
+
DaemonState,
|
|
1241
|
+
"incarnation" | "acquisitionId" | "ownershipPhase" | "generation" | "launcherPid"
|
|
1242
|
+
> & {
|
|
946
1243
|
incarnation?: undefined;
|
|
947
1244
|
acquisitionId?: undefined;
|
|
948
1245
|
ownershipPhase?: undefined;
|
|
1246
|
+
generation?: unknown;
|
|
1247
|
+
launcherPid?: undefined;
|
|
1248
|
+
};
|
|
1249
|
+
type GenerationAbsentParentDaemonState = Omit<ParentDaemonStateBase, "generation"> & {
|
|
949
1250
|
generation?: undefined;
|
|
950
1251
|
};
|
|
1252
|
+
type Generation3ReleaseDaemonState = Omit<ParentDaemonStateBase, "generation"> & {
|
|
1253
|
+
generation: 3;
|
|
1254
|
+
};
|
|
1255
|
+
export type LegacyParentDaemonState = GenerationAbsentParentDaemonState | Generation3ReleaseDaemonState;
|
|
951
1256
|
|
|
952
1257
|
interface LegacyMigrationAttestation {
|
|
1258
|
+
stateDigest: string;
|
|
1259
|
+
confirmed?: true;
|
|
1260
|
+
lock?: V010OwnershipLockMetadata;
|
|
953
1261
|
pid: number;
|
|
954
1262
|
incarnation: string;
|
|
955
1263
|
heartbeatAt: number;
|
|
956
1264
|
observedAt: number;
|
|
1265
|
+
tokenFingerprint: string;
|
|
1266
|
+
chatId: string;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function historicalStateSerializer(state: LegacyParentDaemonState): string {
|
|
1270
|
+
const historicalState = {
|
|
1271
|
+
pid: state.pid,
|
|
1272
|
+
ownerId: state.ownerId,
|
|
1273
|
+
tokenFingerprint: state.tokenFingerprint,
|
|
1274
|
+
chatId: state.chatId,
|
|
1275
|
+
startedAt: state.startedAt,
|
|
1276
|
+
heartbeatAt: state.heartbeatAt,
|
|
1277
|
+
roots: state.roots,
|
|
1278
|
+
version: state.version,
|
|
1279
|
+
...(state.generation === undefined ? {} : { generation: 3 }),
|
|
1280
|
+
};
|
|
1281
|
+
return `${JSON.stringify(historicalState, null, 2)}\n`;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function legacyParentStateDigest(state: LegacyParentDaemonState): string {
|
|
1285
|
+
const { heartbeatAt: _heartbeatAt, ...immutableState } = state;
|
|
1286
|
+
return crypto
|
|
1287
|
+
.createHash("sha256")
|
|
1288
|
+
.update(historicalStateSerializer({ ...immutableState, heartbeatAt: 0 }))
|
|
1289
|
+
.digest("hex");
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function hasExactParentStateKeys(state: object, keys: readonly string[]): boolean {
|
|
1293
|
+
const actual = Object.keys(state);
|
|
1294
|
+
return actual.length === keys.length && actual.every((key, index) => key === keys[index]);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function isGenerationAbsentParentDaemonState(state: unknown): state is GenerationAbsentParentDaemonState {
|
|
1298
|
+
return hasParentDaemonStateShape(state) && hasExactParentStateKeys(state, V010_PARENT_STATE_KEYS);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function isGeneration3ReleaseDaemonState(state: unknown): state is Generation3ReleaseDaemonState {
|
|
1302
|
+
return (
|
|
1303
|
+
hasParentDaemonStateShape(state) &&
|
|
1304
|
+
state.generation === 3 &&
|
|
1305
|
+
hasExactParentStateKeys(state, V010_GENERATION_3_PARENT_STATE_KEYS)
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function isParentDaemonState(state: unknown): state is LegacyParentDaemonState {
|
|
1310
|
+
return isGenerationAbsentParentDaemonState(state) || isGeneration3ReleaseDaemonState(state);
|
|
957
1311
|
}
|
|
958
1312
|
|
|
959
|
-
function
|
|
960
|
-
const candidate = state as Partial<
|
|
1313
|
+
function hasParentDaemonStateShape(state: unknown): state is ParentDaemonStateBase {
|
|
1314
|
+
const candidate = state as Partial<ParentDaemonStateBase> | undefined;
|
|
961
1315
|
return Boolean(
|
|
962
1316
|
candidate &&
|
|
1317
|
+
typeof candidate === "object" &&
|
|
1318
|
+
!Array.isArray(candidate) &&
|
|
963
1319
|
Number.isSafeInteger(candidate.pid) &&
|
|
964
1320
|
(candidate.pid ?? 0) > 0 &&
|
|
965
1321
|
typeof candidate.ownerId === "string" &&
|
|
@@ -974,11 +1330,70 @@ function isLegacyParentDaemonState(state: unknown): state is LegacyParentDaemonS
|
|
|
974
1330
|
candidate.incarnation === undefined &&
|
|
975
1331
|
candidate.acquisitionId === undefined &&
|
|
976
1332
|
candidate.ownershipPhase === undefined &&
|
|
977
|
-
candidate.
|
|
978
|
-
candidate.stoppedAt === undefined
|
|
1333
|
+
candidate.launcherPid === undefined &&
|
|
1334
|
+
candidate.stoppedAt === undefined &&
|
|
1335
|
+
(candidate.generation === undefined || candidate.generation === 3),
|
|
979
1336
|
);
|
|
980
1337
|
}
|
|
981
1338
|
|
|
1339
|
+
const isLegacyParentDaemonState = isParentDaemonState;
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* A live v0.10-shaped record is authority only in its exact historical form.
|
|
1343
|
+
* Parsed JSON alone cannot establish that form: field order and serialization
|
|
1344
|
+
* bytes are part of the migration attestation.
|
|
1345
|
+
*/
|
|
1346
|
+
async function isLiveNoncanonicalParentState(input: {
|
|
1347
|
+
fs: TelegramDaemonFs;
|
|
1348
|
+
statePath: string;
|
|
1349
|
+
state: unknown;
|
|
1350
|
+
pidAlive: (pid: number) => boolean;
|
|
1351
|
+
pidIncarnation: (pid: number) => string | undefined;
|
|
1352
|
+
tokenFingerprint: string;
|
|
1353
|
+
chatId: string;
|
|
1354
|
+
}): Promise<boolean> {
|
|
1355
|
+
const candidate = input.state as Partial<ParentDaemonStateBase> | undefined;
|
|
1356
|
+
if (
|
|
1357
|
+
!candidate ||
|
|
1358
|
+
typeof candidate !== "object" ||
|
|
1359
|
+
Array.isArray(candidate) ||
|
|
1360
|
+
!validDaemonPid(candidate.pid) ||
|
|
1361
|
+
typeof candidate.ownerId !== "string" ||
|
|
1362
|
+
candidate.ownerId.length === 0 ||
|
|
1363
|
+
typeof candidate.tokenFingerprint !== "string" ||
|
|
1364
|
+
typeof candidate.chatId !== "string" ||
|
|
1365
|
+
!Number.isSafeInteger(candidate.startedAt) ||
|
|
1366
|
+
!Number.isSafeInteger(candidate.heartbeatAt) ||
|
|
1367
|
+
!Array.isArray(candidate.roots) ||
|
|
1368
|
+
!candidate.roots.every(root => typeof root === "string") ||
|
|
1369
|
+
candidate.version !== DAEMON_VERSION ||
|
|
1370
|
+
!input.pidAlive(candidate.pid)
|
|
1371
|
+
)
|
|
1372
|
+
return false;
|
|
1373
|
+
if (
|
|
1374
|
+
isStoppedDaemonState(candidate) ||
|
|
1375
|
+
(candidate.incarnation !== undefined &&
|
|
1376
|
+
(candidate.acquisitionId !== undefined || candidate.ownershipPhase !== undefined))
|
|
1377
|
+
)
|
|
1378
|
+
return false;
|
|
1379
|
+
if (
|
|
1380
|
+
candidate.incarnation !== undefined &&
|
|
1381
|
+
isProcessIncarnation(input.pidIncarnation(candidate.pid)) &&
|
|
1382
|
+
input.pidIncarnation(candidate.pid) !== candidate.incarnation &&
|
|
1383
|
+
!(candidate.tokenFingerprint === input.tokenFingerprint && candidate.chatId === input.chatId)
|
|
1384
|
+
)
|
|
1385
|
+
return false;
|
|
1386
|
+
if (!isParentDaemonState(candidate)) return true;
|
|
1387
|
+
try {
|
|
1388
|
+
return (await input.fs.readFile(input.statePath, "utf8")) !== historicalStateSerializer(candidate);
|
|
1389
|
+
} catch {
|
|
1390
|
+
return false;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
function legacyOwnershipLockMatchesHandoffState(lock: OwnershipLockRead, state: unknown): boolean {
|
|
1394
|
+
return lock.kind === "v010" && isGeneration3ReleaseDaemonState(state);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
982
1397
|
function legacyMigrationAttestationPath(statePath: string): string {
|
|
983
1398
|
return `${statePath}.legacy-migration.json`;
|
|
984
1399
|
}
|
|
@@ -986,6 +1401,7 @@ function legacyMigrationAttestationPath(statePath: string): string {
|
|
|
986
1401
|
async function legacyParentHandoffDecision(input: {
|
|
987
1402
|
fs: TelegramDaemonFs;
|
|
988
1403
|
statePath: string;
|
|
1404
|
+
lockPath: string;
|
|
989
1405
|
state: LegacyParentDaemonState;
|
|
990
1406
|
now: number;
|
|
991
1407
|
pidAlive: (pid: number) => boolean;
|
|
@@ -993,7 +1409,15 @@ async function legacyParentHandoffDecision(input: {
|
|
|
993
1409
|
tokenFingerprint: string;
|
|
994
1410
|
chatId: string;
|
|
995
1411
|
}): Promise<
|
|
996
|
-
|
|
1412
|
+
| {
|
|
1413
|
+
acquired: false;
|
|
1414
|
+
attached: false;
|
|
1415
|
+
provisional?: boolean;
|
|
1416
|
+
reloadRequired?: boolean;
|
|
1417
|
+
legacyReloadRequired?: boolean;
|
|
1418
|
+
blocked?: boolean;
|
|
1419
|
+
}
|
|
1420
|
+
| undefined
|
|
997
1421
|
> {
|
|
998
1422
|
const { state } = input;
|
|
999
1423
|
if (!input.pidAlive(state.pid)) return undefined;
|
|
@@ -1001,33 +1425,133 @@ async function legacyParentHandoffDecision(input: {
|
|
|
1001
1425
|
return { acquired: false, attached: false, blocked: true };
|
|
1002
1426
|
const incarnation = input.pidIncarnation(state.pid);
|
|
1003
1427
|
if (!isProcessIncarnation(incarnation)) return { acquired: false, attached: false, blocked: true };
|
|
1004
|
-
|
|
1005
|
-
|
|
1428
|
+
let stateBytes: string;
|
|
1429
|
+
try {
|
|
1430
|
+
stateBytes = await input.fs.readFile(input.statePath, "utf8");
|
|
1431
|
+
} catch {
|
|
1432
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1433
|
+
}
|
|
1434
|
+
if (stateBytes !== historicalStateSerializer(state)) return { acquired: false, attached: false, blocked: true };
|
|
1435
|
+
const lock = await readOwnershipLock(input.fs, input.lockPath);
|
|
1436
|
+
const attestationLock =
|
|
1437
|
+
state.generation === undefined && lock.kind === "missing"
|
|
1438
|
+
? undefined
|
|
1439
|
+
: lock.kind === "v010" && legacyOwnershipLockMatchesHandoffState(lock, state)
|
|
1440
|
+
? lock.metadata
|
|
1441
|
+
: null;
|
|
1442
|
+
if (attestationLock === null) return { acquired: false, attached: false, blocked: true };
|
|
1443
|
+
const previous = await readJson<LegacyMigrationAttestation>(
|
|
1444
|
+
input.fs,
|
|
1445
|
+
legacyMigrationAttestationPath(input.statePath),
|
|
1446
|
+
);
|
|
1447
|
+
const stateDigest = legacyParentStateDigest(state);
|
|
1006
1448
|
const attested = Boolean(
|
|
1007
1449
|
previous &&
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
Number.isSafeInteger(previous.heartbeatAt) &&
|
|
1011
|
-
Number.isSafeInteger(previous.observedAt) &&
|
|
1450
|
+
previous.stateDigest === stateDigest &&
|
|
1451
|
+
JSON.stringify(previous.lock) === JSON.stringify(attestationLock) &&
|
|
1012
1452
|
previous.pid === state.pid &&
|
|
1013
1453
|
previous.incarnation === incarnation &&
|
|
1454
|
+
previous.tokenFingerprint === input.tokenFingerprint &&
|
|
1455
|
+
previous.chatId === input.chatId &&
|
|
1014
1456
|
state.heartbeatAt > previous.heartbeatAt &&
|
|
1015
1457
|
input.now - previous.observedAt <= HEARTBEAT_TTL_MS,
|
|
1016
1458
|
);
|
|
1017
|
-
await writeJsonAtomic(input.fs,
|
|
1459
|
+
await writeJsonAtomic(input.fs, legacyMigrationAttestationPath(input.statePath), {
|
|
1460
|
+
stateDigest,
|
|
1461
|
+
...(attested ? { confirmed: true as const } : {}),
|
|
1462
|
+
...(attestationLock === undefined ? {} : { lock: attestationLock }),
|
|
1018
1463
|
pid: state.pid,
|
|
1019
1464
|
incarnation,
|
|
1020
1465
|
heartbeatAt: state.heartbeatAt,
|
|
1021
1466
|
observedAt: input.now,
|
|
1467
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1468
|
+
chatId: input.chatId,
|
|
1022
1469
|
} satisfies LegacyMigrationAttestation);
|
|
1023
|
-
return
|
|
1024
|
-
|
|
1025
|
-
|
|
1470
|
+
if (!attested) return { acquired: false, attached: false, provisional: true };
|
|
1471
|
+
return { acquired: false, attached: false, reloadRequired: true, legacyReloadRequired: true };
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
export interface AttestedLegacyDaemonOwner {
|
|
1475
|
+
state: LegacyParentDaemonState;
|
|
1476
|
+
incarnation: string;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/** Revalidate the exact two-observation legacy proof immediately before signaling. */
|
|
1480
|
+
export async function readAttestedLegacyDaemonOwner(input: {
|
|
1481
|
+
settings: Settings;
|
|
1482
|
+
fs?: TelegramDaemonFs;
|
|
1483
|
+
now?: () => number;
|
|
1484
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1485
|
+
tokenFingerprint: string;
|
|
1486
|
+
chatId: string;
|
|
1487
|
+
}): Promise<AttestedLegacyDaemonOwner | undefined> {
|
|
1488
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
1489
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
1490
|
+
const state = await readJson<unknown>(fsImpl, paths.state);
|
|
1491
|
+
if (!isGeneration3ReleaseDaemonState(state)) return undefined;
|
|
1492
|
+
let stateBytes: string;
|
|
1493
|
+
try {
|
|
1494
|
+
stateBytes = await fsImpl.readFile(paths.state, "utf8");
|
|
1495
|
+
} catch {
|
|
1496
|
+
return undefined;
|
|
1497
|
+
}
|
|
1498
|
+
if (stateBytes !== historicalStateSerializer(state)) return undefined;
|
|
1499
|
+
const lock = await readOwnershipLock(fsImpl, paths.lock);
|
|
1500
|
+
if (lock.kind !== "v010") return undefined;
|
|
1501
|
+
const attestation = await readJson<LegacyMigrationAttestation>(fsImpl, legacyMigrationAttestationPath(paths.state));
|
|
1502
|
+
const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(state.pid);
|
|
1503
|
+
const now = (input.now ?? Date.now)();
|
|
1504
|
+
if (
|
|
1505
|
+
attestation?.confirmed !== true ||
|
|
1506
|
+
!isProcessIncarnation(incarnation) ||
|
|
1507
|
+
attestation.stateDigest !== legacyParentStateDigest(state) ||
|
|
1508
|
+
JSON.stringify(attestation.lock) !== JSON.stringify(lock.metadata) ||
|
|
1509
|
+
attestation.pid !== state.pid ||
|
|
1510
|
+
attestation.incarnation !== incarnation ||
|
|
1511
|
+
attestation.tokenFingerprint !== input.tokenFingerprint ||
|
|
1512
|
+
attestation.chatId !== input.chatId ||
|
|
1513
|
+
attestation.heartbeatAt > state.heartbeatAt ||
|
|
1514
|
+
now < attestation.observedAt ||
|
|
1515
|
+
now - attestation.observedAt > HEARTBEAT_TTL_MS
|
|
1516
|
+
)
|
|
1517
|
+
return undefined;
|
|
1518
|
+
return { state, incarnation };
|
|
1026
1519
|
}
|
|
1027
1520
|
|
|
1028
1521
|
function isRecognizedLegacyGeneration(generation: number | undefined): boolean {
|
|
1029
|
-
return
|
|
1030
|
-
|
|
1522
|
+
return generation === undefined || generation === 3;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* A predecessor is reloadable only when it has the complete modern ownership
|
|
1527
|
+
* proof. Generation is absent from the first fully-provenanced modern records,
|
|
1528
|
+
* so it is an incompatible predecessor too; parent-format records remain
|
|
1529
|
+
* excluded because they lack this ownership proof.
|
|
1530
|
+
*/
|
|
1531
|
+
function hasFullModernOwnerProvenance(
|
|
1532
|
+
state: DaemonState | undefined,
|
|
1533
|
+
pidIncarnation?: (pid: number) => string | undefined,
|
|
1534
|
+
): boolean {
|
|
1535
|
+
return Boolean(
|
|
1536
|
+
hasSafeDaemonStateShape(state) &&
|
|
1537
|
+
state.generation !== 3 &&
|
|
1538
|
+
state.ownershipPhase === "ready" &&
|
|
1539
|
+
typeof state.acquisitionId === "string" &&
|
|
1540
|
+
state.acquisitionId.length > 0 &&
|
|
1541
|
+
ownerProvenanceMatches(state, pidIncarnation),
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function isFullModernPredecessor(
|
|
1546
|
+
state: DaemonState | undefined,
|
|
1547
|
+
pidIncarnation?: (pid: number) => string | undefined,
|
|
1548
|
+
): boolean {
|
|
1549
|
+
return Boolean(
|
|
1550
|
+
hasFullModernOwnerProvenance(state, pidIncarnation) &&
|
|
1551
|
+
(state?.generation === undefined ||
|
|
1552
|
+
(Number.isSafeInteger(state?.generation) &&
|
|
1553
|
+
(state?.generation as number) > 0 &&
|
|
1554
|
+
(state?.generation as number) < DAEMON_GENERATION)),
|
|
1031
1555
|
);
|
|
1032
1556
|
}
|
|
1033
1557
|
|
|
@@ -1050,7 +1574,7 @@ function classifyForeignLiveOwner(input: {
|
|
|
1050
1574
|
if (!state || !validDaemonPid(state.pid) || !input.pidAlive(state.pid)) return undefined;
|
|
1051
1575
|
// A stopped tombstone with a canonical acquisition is not a live owner.
|
|
1052
1576
|
if (isStoppedDaemonState(state) || isLegacyParentDaemonState(state)) return undefined;
|
|
1053
|
-
const currentIncarnation = input.pidIncarnation
|
|
1577
|
+
const currentIncarnation = input.pidIncarnation ? input.pidIncarnation(state.pid) : defaultPidIncarnation(state.pid);
|
|
1054
1578
|
if (
|
|
1055
1579
|
!hasSafeDaemonStateShape(state) ||
|
|
1056
1580
|
!isProcessIncarnation(state.incarnation) ||
|
|
@@ -1069,18 +1593,23 @@ export function isPhysicalMatchingOwner(input: {
|
|
|
1069
1593
|
pidAlive: (pid: number) => boolean;
|
|
1070
1594
|
pidIncarnation?: (pid: number) => string | undefined;
|
|
1071
1595
|
}): boolean {
|
|
1072
|
-
const
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1596
|
+
const persistedState: unknown = input.state;
|
|
1597
|
+
const legacyParentState = isLegacyParentDaemonState(persistedState) ? persistedState : undefined;
|
|
1598
|
+
const modernState = hasSafeDaemonStateShape(persistedState) ? persistedState : undefined;
|
|
1599
|
+
const state = modernState ?? legacyParentState;
|
|
1600
|
+
if (
|
|
1601
|
+
!state ||
|
|
1602
|
+
state.stoppedAt !== undefined ||
|
|
1603
|
+
!ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) ||
|
|
1604
|
+
!input.pidAlive(state.pid)
|
|
1605
|
+
)
|
|
1606
|
+
return false;
|
|
1607
|
+
return legacyParentState
|
|
1608
|
+
? isProcessIncarnation((input.pidIncarnation ?? defaultPidIncarnation)(legacyParentState.pid))
|
|
1609
|
+
: modernState !== undefined && ownerProvenanceMatches(modernState, input.pidIncarnation);
|
|
1081
1610
|
}
|
|
1082
1611
|
|
|
1083
|
-
/** True only for a
|
|
1612
|
+
/** True only for a fully-provenanced modern owner outside the generation-3 parent schema. */
|
|
1084
1613
|
export function isSignalableMatchingOwner(input: {
|
|
1085
1614
|
state: DaemonState | undefined;
|
|
1086
1615
|
tokenFingerprint: string;
|
|
@@ -1089,14 +1618,7 @@ export function isSignalableMatchingOwner(input: {
|
|
|
1089
1618
|
pidIncarnation?: (pid: number) => string | undefined;
|
|
1090
1619
|
}): boolean {
|
|
1091
1620
|
const { state } = input;
|
|
1092
|
-
return Boolean(
|
|
1093
|
-
isPhysicalMatchingOwner(input) &&
|
|
1094
|
-
(isRecognizedLegacyGeneration(state?.generation) ||
|
|
1095
|
-
(state?.generation === DAEMON_GENERATION &&
|
|
1096
|
-
state.ownershipPhase === "ready" &&
|
|
1097
|
-
typeof state.acquisitionId === "string" &&
|
|
1098
|
-
state.acquisitionId.length > 0)),
|
|
1099
|
-
);
|
|
1621
|
+
return Boolean(isPhysicalMatchingOwner(input) && hasFullModernOwnerProvenance(state, input.pidIncarnation));
|
|
1100
1622
|
}
|
|
1101
1623
|
|
|
1102
1624
|
export function isFreshLiveOwner(input: {
|
|
@@ -1163,6 +1685,7 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1163
1685
|
provisional?: boolean;
|
|
1164
1686
|
reason?: "identity_mismatch";
|
|
1165
1687
|
reloadRequired?: boolean;
|
|
1688
|
+
legacyReloadRequired?: boolean;
|
|
1166
1689
|
}> {
|
|
1167
1690
|
const fsImpl = input.fs ?? nodeFs;
|
|
1168
1691
|
const now = input.now ?? Date.now;
|
|
@@ -1199,6 +1722,7 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1199
1722
|
}
|
|
1200
1723
|
if (!state || state.stoppedAt !== undefined || !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
|
|
1201
1724
|
return undefined;
|
|
1725
|
+
if (state.generation === 3) return { acquired: false, attached: false, blocked: true };
|
|
1202
1726
|
// Unavailable provenance is ambiguous and must remain fail-closed. A
|
|
1203
1727
|
// different authoritative incarnation proves PID reuse, so allow only the
|
|
1204
1728
|
// transition-locked path below to reclaim the stale owner artifacts.
|
|
@@ -1221,24 +1745,33 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1221
1745
|
)
|
|
1222
1746
|
return { acquired: false, attached: true };
|
|
1223
1747
|
// A physical owner that cannot prove current compatibility is never safe to
|
|
1224
|
-
// attach.
|
|
1225
|
-
//
|
|
1748
|
+
// attach. A reload handoff additionally requires a fresh heartbeat: a stale
|
|
1749
|
+
// record must remain blocked rather than authorizing a signal to its PID.
|
|
1226
1750
|
if (
|
|
1227
|
-
|
|
1228
|
-
!isFreshLiveOwner({
|
|
1751
|
+
isFreshLiveOwner({
|
|
1229
1752
|
state,
|
|
1230
1753
|
now: now(),
|
|
1231
1754
|
tokenFingerprint: input.tokenFingerprint,
|
|
1232
1755
|
chatId: input.chatId,
|
|
1233
1756
|
pidAlive,
|
|
1234
1757
|
pidIncarnation,
|
|
1235
|
-
})
|
|
1236
|
-
|
|
1758
|
+
}) &&
|
|
1759
|
+
(state.version !== DAEMON_VERSION || isFullModernPredecessor(state, pidIncarnation))
|
|
1237
1760
|
)
|
|
1238
1761
|
return { acquired: false, attached: false, reloadRequired: true };
|
|
1239
1762
|
return { acquired: false, attached: false, provisional: true };
|
|
1240
1763
|
};
|
|
1241
1764
|
const existing = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1765
|
+
if (
|
|
1766
|
+
existing &&
|
|
1767
|
+
validDaemonPid(existing.pid) &&
|
|
1768
|
+
!ownerIdentityMatches(existing, input.tokenFingerprint, input.chatId) &&
|
|
1769
|
+
(!hasSafeDaemonStateShape(existing) ||
|
|
1770
|
+
!isProcessIncarnation(existing.incarnation) ||
|
|
1771
|
+
!isProcessIncarnation(pidIncarnation(existing.pid))) &&
|
|
1772
|
+
pidAlive(existing.pid)
|
|
1773
|
+
)
|
|
1774
|
+
return { acquired: false, attached: false, blocked: true };
|
|
1242
1775
|
const foreignOwner = classifyForeignLiveOwner({
|
|
1243
1776
|
state: existing,
|
|
1244
1777
|
tokenFingerprint: input.tokenFingerprint,
|
|
@@ -1251,12 +1784,48 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1251
1784
|
? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
|
|
1252
1785
|
: { acquired: false, attached: false, blocked: true };
|
|
1253
1786
|
}
|
|
1787
|
+
if (
|
|
1788
|
+
await isLiveNoncanonicalParentState({
|
|
1789
|
+
fs: fsImpl,
|
|
1790
|
+
statePath: paths.state,
|
|
1791
|
+
state: existing,
|
|
1792
|
+
pidAlive,
|
|
1793
|
+
pidIncarnation,
|
|
1794
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1795
|
+
chatId: input.chatId,
|
|
1796
|
+
})
|
|
1797
|
+
)
|
|
1798
|
+
return { acquired: false, attached: false, blocked: true };
|
|
1254
1799
|
|
|
1255
1800
|
const transition = await acquireTransitionLock({ fs: fsImpl, path: paths.steal, pidAlive, pidIncarnation });
|
|
1256
1801
|
if (!transition) return { acquired: false, attached: false, provisional: true };
|
|
1257
1802
|
try {
|
|
1258
1803
|
const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1259
1804
|
const recheckedLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
1805
|
+
const recheckedForeignOwner = classifyForeignLiveOwner({
|
|
1806
|
+
state: rechecked,
|
|
1807
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1808
|
+
chatId: input.chatId,
|
|
1809
|
+
pidAlive,
|
|
1810
|
+
pidIncarnation,
|
|
1811
|
+
});
|
|
1812
|
+
if (recheckedForeignOwner) {
|
|
1813
|
+
return recheckedForeignOwner === "identity_mismatch"
|
|
1814
|
+
? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
|
|
1815
|
+
: { acquired: false, attached: false, blocked: true };
|
|
1816
|
+
}
|
|
1817
|
+
if (
|
|
1818
|
+
await isLiveNoncanonicalParentState({
|
|
1819
|
+
fs: fsImpl,
|
|
1820
|
+
statePath: paths.state,
|
|
1821
|
+
state: rechecked,
|
|
1822
|
+
pidAlive,
|
|
1823
|
+
pidIncarnation,
|
|
1824
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1825
|
+
chatId: input.chatId,
|
|
1826
|
+
})
|
|
1827
|
+
)
|
|
1828
|
+
return { acquired: false, attached: false, blocked: true };
|
|
1260
1829
|
const recheckedDecision = isLegacyParentDaemonState(rechecked) ? undefined : attachDecision(rechecked);
|
|
1261
1830
|
if (
|
|
1262
1831
|
recheckedDecision &&
|
|
@@ -1270,13 +1839,19 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1270
1839
|
// A newer initializer may have replaced the pathname while that tombstone
|
|
1271
1840
|
// remained, and must receive the same liveness/freshness protection as any
|
|
1272
1841
|
// other live reservation.
|
|
1273
|
-
const stoppedLockMatches = ownershipLockMatchesStoppedState(recheckedLock, rechecked);
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1842
|
+
const stoppedLockMatches = ownershipLockMatchesStoppedState(recheckedLock, rechecked, pidAlive);
|
|
1843
|
+
// A v0.10 parent owns a legacy { pid, startedAt } lock. An exact
|
|
1844
|
+
// state/lock pair is authority only to attest or retry handoff of that
|
|
1845
|
+
// owner; it is never attached or unlinked here.
|
|
1846
|
+
const legacyHandoffLockMatches = legacyOwnershipLockMatchesHandoffState(recheckedLock, rechecked);
|
|
1847
|
+
const lockDecision =
|
|
1848
|
+
stoppedLockMatches || legacyHandoffLockMatches
|
|
1849
|
+
? undefined
|
|
1850
|
+
: liveOwnershipLockDecision({ lock: recheckedLock, pidAlive, pidIncarnation });
|
|
1277
1851
|
if (lockDecision) return lockDecision;
|
|
1278
1852
|
if (
|
|
1279
1853
|
!stoppedLockMatches &&
|
|
1854
|
+
!legacyHandoffLockMatches &&
|
|
1280
1855
|
!(await ownershipLockIsReclaimable({
|
|
1281
1856
|
fs: fsImpl,
|
|
1282
1857
|
path: paths.lock,
|
|
@@ -1291,6 +1866,7 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1291
1866
|
const legacyDecision = await legacyParentHandoffDecision({
|
|
1292
1867
|
fs: fsImpl,
|
|
1293
1868
|
statePath: paths.state,
|
|
1869
|
+
lockPath: paths.lock,
|
|
1294
1870
|
state: rechecked,
|
|
1295
1871
|
now: now(),
|
|
1296
1872
|
pidAlive,
|
|
@@ -1302,18 +1878,6 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1302
1878
|
} else if (recheckedDecision) {
|
|
1303
1879
|
return recheckedDecision;
|
|
1304
1880
|
}
|
|
1305
|
-
const recheckedForeignOwner = classifyForeignLiveOwner({
|
|
1306
|
-
state: rechecked,
|
|
1307
|
-
tokenFingerprint: input.tokenFingerprint,
|
|
1308
|
-
chatId: input.chatId,
|
|
1309
|
-
pidAlive,
|
|
1310
|
-
pidIncarnation,
|
|
1311
|
-
});
|
|
1312
|
-
if (recheckedForeignOwner) {
|
|
1313
|
-
return recheckedForeignOwner === "identity_mismatch"
|
|
1314
|
-
? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
|
|
1315
|
-
: { acquired: false, attached: false, blocked: true };
|
|
1316
|
-
}
|
|
1317
1881
|
if (
|
|
1318
1882
|
hasSafeDaemonStateShape(rechecked) &&
|
|
1319
1883
|
rechecked.stoppedAt === undefined &&
|
|
@@ -1327,7 +1891,8 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1327
1891
|
if (currentLock.kind !== "missing") {
|
|
1328
1892
|
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
|
|
1329
1893
|
return { acquired: false, attached: false, provisional: true };
|
|
1330
|
-
await fsImpl
|
|
1894
|
+
if (!(await unlinkOwnershipLockExactly(fsImpl, paths.lock, currentLock)))
|
|
1895
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1331
1896
|
}
|
|
1332
1897
|
const ownershipLock: OwnershipLockMetadata = {
|
|
1333
1898
|
pid,
|
|
@@ -1563,14 +2128,15 @@ export async function retireProvisionalDaemonOwnership(input: {
|
|
|
1563
2128
|
)
|
|
1564
2129
|
return false;
|
|
1565
2130
|
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
|
|
2131
|
+
const lock = await readOwnershipLock(fsImpl, paths.lock);
|
|
2132
|
+
if (!ownershipLockMatchesState(lock, state)) return false;
|
|
1566
2133
|
await writeJsonAtomic(fsImpl, paths.state, {
|
|
1567
2134
|
...state,
|
|
1568
2135
|
ownershipPhase: "retired",
|
|
1569
2136
|
stoppedAt: (input.now ?? Date.now)(),
|
|
1570
2137
|
});
|
|
1571
|
-
if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
|
|
1572
|
-
|
|
1573
|
-
return true;
|
|
2138
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
|
|
2139
|
+
return await unlinkOwnershipLockExactly(fsImpl, paths.lock, lock);
|
|
1574
2140
|
} finally {
|
|
1575
2141
|
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1576
2142
|
}
|
|
@@ -1727,6 +2293,8 @@ export async function confirmTelegramDaemonSpawn(input: {
|
|
|
1727
2293
|
sleep?: (ms: number) => Promise<void>;
|
|
1728
2294
|
waitStepMs?: number;
|
|
1729
2295
|
timeoutMs?: number;
|
|
2296
|
+
/** Retain an unproven no-PID child lease during a successor handoff. */
|
|
2297
|
+
preserveOnUnprovenChildExit?: boolean;
|
|
1730
2298
|
}): Promise<boolean> {
|
|
1731
2299
|
if (input.spawned.result !== "owner_spawned") return true;
|
|
1732
2300
|
const childPid = input.spawned.acquisition.pid;
|
|
@@ -1748,6 +2316,14 @@ export async function confirmTelegramDaemonSpawn(input: {
|
|
|
1748
2316
|
timeoutMs: input.timeoutMs,
|
|
1749
2317
|
});
|
|
1750
2318
|
if (ready) return true;
|
|
2319
|
+
// An identified live child remains fenced. A no-PID launch retains its lease
|
|
2320
|
+
// only when a successor handoff explicitly requests that protection; ordinary
|
|
2321
|
+
// confirmation keeps the historical cleanup behavior.
|
|
2322
|
+
if (
|
|
2323
|
+
(hasExactChildPid && (input.pidAlive ?? defaultPidAlive)(childPid as number)) ||
|
|
2324
|
+
(!hasExactChildPid && input.preserveOnUnprovenChildExit)
|
|
2325
|
+
)
|
|
2326
|
+
return false;
|
|
1751
2327
|
const launcherPid = input.spawned.acquisition.launcherPid ?? input.pid;
|
|
1752
2328
|
const retired = await retireProvisionalDaemonOwnership({
|
|
1753
2329
|
settings: input.settings,
|
|
@@ -1829,9 +2405,11 @@ export async function releaseDaemonOwnership(input: {
|
|
|
1829
2405
|
)
|
|
1830
2406
|
return;
|
|
1831
2407
|
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return;
|
|
2408
|
+
const lock = await readOwnershipLock(fsImpl, paths.lock);
|
|
2409
|
+
if (!ownershipLockMatchesState(lock, state)) return;
|
|
1832
2410
|
await writeJsonAtomic(fsImpl, paths.state, { ...state, stoppedAt: (input.now ?? Date.now)() });
|
|
1833
2411
|
if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
|
|
1834
|
-
await fsImpl
|
|
2412
|
+
await unlinkOwnershipLockExactly(fsImpl, paths.lock, lock);
|
|
1835
2413
|
} finally {
|
|
1836
2414
|
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1837
2415
|
}
|
|
@@ -1854,12 +2432,63 @@ export async function readDaemonRoots(
|
|
|
1854
2432
|
return roots?.roots ?? [];
|
|
1855
2433
|
}
|
|
1856
2434
|
|
|
2435
|
+
/** Injectable readers for {@link resolveTelegramSetupPreflight}, defaulting to the real OS/state probes. */
|
|
2436
|
+
export interface ResolveTelegramSetupPreflightDeps {
|
|
2437
|
+
readDaemonState?: (settings: Settings) => Promise<DaemonState | undefined>;
|
|
2438
|
+
pidAlive?: (pid: number) => boolean;
|
|
2439
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
/**
|
|
2443
|
+
* Build the Telegram setup preflight from persisted daemon state. The daemon is
|
|
2444
|
+
* reported live ONLY when its PID is alive AND its current process incarnation
|
|
2445
|
+
* still matches the persisted incarnation. Skipping the incarnation check makes
|
|
2446
|
+
* a stale state file whose PID has been recycled by an unrelated process
|
|
2447
|
+
* masquerade as a live owner, which wrongly blocks discovery pairing. Both the
|
|
2448
|
+
* `notify setup` CLI and the /settings Notifications tab share this resolver so
|
|
2449
|
+
* pairing behaves identically on both surfaces.
|
|
2450
|
+
*/
|
|
2451
|
+
export async function resolveTelegramSetupPreflight(
|
|
2452
|
+
settings: Settings,
|
|
2453
|
+
deps: ResolveTelegramSetupPreflightDeps = {},
|
|
2454
|
+
): Promise<TelegramSetupPreflight> {
|
|
2455
|
+
const storedChatId = getNotificationConfig(settings).chatId;
|
|
2456
|
+
const pidAlive = deps.pidAlive ?? defaultPidAlive;
|
|
2457
|
+
const pidIncarnation = deps.pidIncarnation ?? defaultPidIncarnation;
|
|
2458
|
+
try {
|
|
2459
|
+
const state = await (deps.readDaemonState ?? readDaemonState)(settings);
|
|
2460
|
+
if (!state) return { storedChatId };
|
|
2461
|
+
const validPid = Number.isSafeInteger(state.pid) && state.pid > 0;
|
|
2462
|
+
if (!validPid || !pidAlive(state.pid)) return { storedChatId };
|
|
2463
|
+
const persistedIncarnation = state.incarnation;
|
|
2464
|
+
const currentIncarnation = pidIncarnation(state.pid);
|
|
2465
|
+
if (
|
|
2466
|
+
!isProcessIncarnation(persistedIncarnation) ||
|
|
2467
|
+
!isProcessIncarnation(currentIncarnation) ||
|
|
2468
|
+
persistedIncarnation !== currentIncarnation
|
|
2469
|
+
)
|
|
2470
|
+
return { storedChatId };
|
|
2471
|
+
return {
|
|
2472
|
+
storedChatId,
|
|
2473
|
+
daemon: {
|
|
2474
|
+
live: true,
|
|
2475
|
+
tokenFingerprint: typeof state.tokenFingerprint === "string" ? state.tokenFingerprint : undefined,
|
|
2476
|
+
chatId: typeof state.chatId === "string" ? state.chatId : undefined,
|
|
2477
|
+
},
|
|
2478
|
+
};
|
|
2479
|
+
} catch {
|
|
2480
|
+
// A state read failure is not proof of a live daemon; proceed normally. The
|
|
2481
|
+
// daemon's own 409 handling remains the backstop against poller contention.
|
|
2482
|
+
return { storedChatId };
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
|
|
1857
2486
|
function defaultPidAlive(pid: number): boolean {
|
|
1858
2487
|
try {
|
|
1859
2488
|
process.kill(pid, 0);
|
|
1860
2489
|
return true;
|
|
1861
|
-
} catch {
|
|
1862
|
-
return
|
|
2490
|
+
} catch (error) {
|
|
2491
|
+
return (error as NodeJS.ErrnoException).code !== "ESRCH";
|
|
1863
2492
|
}
|
|
1864
2493
|
}
|
|
1865
2494
|
|
|
@@ -1918,7 +2547,13 @@ export interface TelegramSpawnAcquisition {
|
|
|
1918
2547
|
|
|
1919
2548
|
export type TelegramSpawnOwnerResult =
|
|
1920
2549
|
| { result: "owner_spawned"; acquisition: TelegramSpawnAcquisition; runtime: DaemonRuntimeInfo; warnings: string[] }
|
|
1921
|
-
| {
|
|
2550
|
+
| {
|
|
2551
|
+
result: "attached";
|
|
2552
|
+
runtime: DaemonRuntimeInfo;
|
|
2553
|
+
warnings: string[];
|
|
2554
|
+
reloadRequired?: boolean;
|
|
2555
|
+
legacyReloadRequired?: boolean;
|
|
2556
|
+
}
|
|
1922
2557
|
| { result: "blocked"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean };
|
|
1923
2558
|
|
|
1924
2559
|
/**
|
|
@@ -2001,6 +2636,7 @@ export async function spawnTelegramDaemonOwner(
|
|
|
2001
2636
|
runtime: buildTelegramDaemonSpawnArgs({ execPath, ownerId: "", agentDir }).runtime,
|
|
2002
2637
|
warnings: [],
|
|
2003
2638
|
reloadRequired: ownership.reloadRequired,
|
|
2639
|
+
legacyReloadRequired: ownership.legacyReloadRequired,
|
|
2004
2640
|
};
|
|
2005
2641
|
}
|
|
2006
2642
|
const launcherPid = deps.pid ?? process.pid;
|
|
@@ -2044,35 +2680,49 @@ export async function spawnTelegramDaemonOwner(
|
|
|
2044
2680
|
}
|
|
2045
2681
|
|
|
2046
2682
|
/**
|
|
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.
|
|
2683
|
+
* Owner-bound reclamation of a confirmed-dead daemon owner, mirroring the
|
|
2684
|
+
* daemon step of `gjc notify recovery`. It returns a structured, actionable
|
|
2685
|
+
* result and removes only identity-verified dead-owner artifacts while holding
|
|
2686
|
+
* the transition fence; live, successor, unknown, or unreadable evidence is
|
|
2687
|
+
* retained.
|
|
2055
2688
|
*/
|
|
2056
|
-
|
|
2689
|
+
export type DeadOwnerRecoveryResult =
|
|
2690
|
+
| { recovered: true; reason: "cleared" }
|
|
2691
|
+
| {
|
|
2692
|
+
recovered: false;
|
|
2693
|
+
reason:
|
|
2694
|
+
| "not-confirmed-dead"
|
|
2695
|
+
| "unsafe-lock"
|
|
2696
|
+
| "transition-contended"
|
|
2697
|
+
| "owner-superseded"
|
|
2698
|
+
| "unsafe-endpoint"
|
|
2699
|
+
| "endpoint-changed"
|
|
2700
|
+
| "endpoint-directory-unreadable"
|
|
2701
|
+
| "lock-changed";
|
|
2702
|
+
};
|
|
2703
|
+
|
|
2704
|
+
/** Preflight cleanup for a confirmed-dead owner, with identity-bound removal. */
|
|
2705
|
+
export async function reclaimDeadDaemonOwner(input: {
|
|
2057
2706
|
settings: Settings;
|
|
2707
|
+
endpointDir?: string;
|
|
2058
2708
|
fs?: TelegramDaemonFs;
|
|
2059
2709
|
now?: () => number;
|
|
2060
2710
|
pidAlive?: (pid: number) => boolean;
|
|
2061
2711
|
pidIncarnation?: (pid: number) => string | undefined;
|
|
2062
|
-
}): Promise<
|
|
2712
|
+
}): Promise<DeadOwnerRecoveryResult> {
|
|
2063
2713
|
const fsImpl = input.fs ?? nodeFs;
|
|
2064
2714
|
const now = input.now ?? Date.now;
|
|
2065
2715
|
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
2066
2716
|
const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
|
|
2067
2717
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
2068
2718
|
const state = await readDaemonState(input.settings, fsImpl);
|
|
2069
|
-
if (!state || pidAlive(state.pid))
|
|
2719
|
+
if (!state || !hasSafeDaemonStateShape(state) || pidAlive(state.pid))
|
|
2720
|
+
return { recovered: false, reason: "not-confirmed-dead" };
|
|
2070
2721
|
const lock = await readOwnershipLock(fsImpl, paths.lock);
|
|
2071
|
-
if (lock.kind === "missing") return false;
|
|
2072
2722
|
if (
|
|
2073
2723
|
!(await ownershipLockIsReclaimable({ fs: fsImpl, path: paths.lock, lock, now: now(), pidAlive, pidIncarnation }))
|
|
2074
2724
|
)
|
|
2075
|
-
return false;
|
|
2725
|
+
return { recovered: false, reason: "unsafe-lock" };
|
|
2076
2726
|
const transition = await acquireDaemonTransitionLock({
|
|
2077
2727
|
fs: fsImpl,
|
|
2078
2728
|
path: paths.steal,
|
|
@@ -2080,18 +2730,79 @@ async function reclaimDeadDaemonOwner(input: {
|
|
|
2080
2730
|
pidAlive: defaultPidAlive,
|
|
2081
2731
|
pidIncarnation: defaultPidIncarnation,
|
|
2082
2732
|
});
|
|
2083
|
-
|
|
2733
|
+
const readEndpointFile = fsImpl.readEndpointFile;
|
|
2734
|
+
if (!transition || !readEndpointFile || !fsImpl.exactUnlink)
|
|
2735
|
+
return { recovered: false, reason: "transition-contended" };
|
|
2084
2736
|
try {
|
|
2085
2737
|
const current = await readDaemonState(input.settings, fsImpl);
|
|
2086
|
-
if (
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2738
|
+
if (
|
|
2739
|
+
!current ||
|
|
2740
|
+
!hasSafeDaemonStateShape(current) ||
|
|
2741
|
+
current.ownerId !== state.ownerId ||
|
|
2742
|
+
current.acquisitionId !== state.acquisitionId ||
|
|
2743
|
+
current.pid !== state.pid ||
|
|
2744
|
+
current.incarnation !== state.incarnation ||
|
|
2745
|
+
current.generation !== state.generation ||
|
|
2746
|
+
current.ownershipPhase !== state.ownershipPhase ||
|
|
2747
|
+
current.tokenFingerprint !== state.tokenFingerprint ||
|
|
2748
|
+
current.chatId !== state.chatId ||
|
|
2749
|
+
pidAlive(current.pid)
|
|
2750
|
+
)
|
|
2751
|
+
return { recovered: false, reason: "owner-superseded" };
|
|
2752
|
+
const currentLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
2753
|
+
if (
|
|
2754
|
+
!ownershipLockMatches(lock, currentLock) ||
|
|
2755
|
+
!(await ownershipLockIsReclaimable({
|
|
2756
|
+
fs: fsImpl,
|
|
2757
|
+
path: paths.lock,
|
|
2758
|
+
lock: currentLock,
|
|
2759
|
+
now: now(),
|
|
2760
|
+
pidAlive,
|
|
2761
|
+
pidIncarnation,
|
|
2762
|
+
})) ||
|
|
2763
|
+
!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
|
|
2764
|
+
)
|
|
2765
|
+
return { recovered: false, reason: "unsafe-lock" };
|
|
2766
|
+
const endpoints: Array<{ file: string; identity: NotificationEndpointFileIdentity }> = [];
|
|
2767
|
+
if (input.endpointDir) {
|
|
2768
|
+
let names: string[];
|
|
2769
|
+
try {
|
|
2770
|
+
names = await fsImpl.readdir(input.endpointDir);
|
|
2771
|
+
} catch (error) {
|
|
2772
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") names = [];
|
|
2773
|
+
else return { recovered: false, reason: "endpoint-directory-unreadable" };
|
|
2774
|
+
}
|
|
2775
|
+
for (const name of names) {
|
|
2776
|
+
if (!name.endsWith(".json")) continue;
|
|
2777
|
+
const file = path.join(input.endpointDir, name);
|
|
2778
|
+
const endpoint = await classifyNotificationEndpoint(
|
|
2779
|
+
{ readEndpointFile: fsImpl.readEndpointFile! },
|
|
2780
|
+
file,
|
|
2781
|
+
pidAlive,
|
|
2782
|
+
);
|
|
2783
|
+
if (endpoint.kind === "non-endpoint") continue;
|
|
2784
|
+
if (endpoint.kind !== "endpoint" || endpoint.liveness !== "dead")
|
|
2785
|
+
return { recovered: false, reason: "unsafe-endpoint" };
|
|
2786
|
+
endpoints.push({ file, identity: endpoint.identity });
|
|
2787
|
+
}
|
|
2094
2788
|
}
|
|
2789
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
|
|
2790
|
+
return { recovered: false, reason: "transition-contended" };
|
|
2791
|
+
for (const endpoint of endpoints)
|
|
2792
|
+
if (!(await exactUnlinkAcceptedWithRetainedEvidence(fsImpl, endpoint.file, endpoint.identity)))
|
|
2793
|
+
return { recovered: false, reason: "endpoint-changed" };
|
|
2794
|
+
if (currentLock.kind === "missing") return { recovered: true, reason: "cleared" };
|
|
2795
|
+
const exactLock = await fsImpl.readEndpointFile!(paths.lock).catch(() => undefined);
|
|
2796
|
+
const exactCurrentLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
2797
|
+
if (
|
|
2798
|
+
!exactLock ||
|
|
2799
|
+
!ownershipLockMatches(currentLock, exactCurrentLock) ||
|
|
2800
|
+
!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
|
|
2801
|
+
)
|
|
2802
|
+
return { recovered: false, reason: "lock-changed" };
|
|
2803
|
+
return (await exactUnlinkAcceptedWithRetainedEvidence(fsImpl, paths.lock, exactLock.identity))
|
|
2804
|
+
? { recovered: true, reason: "cleared" }
|
|
2805
|
+
: { recovered: false, reason: "lock-changed" };
|
|
2095
2806
|
} finally {
|
|
2096
2807
|
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
2097
2808
|
}
|
|
@@ -2109,6 +2820,31 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
2109
2820
|
if (!isTelegramConfigured(cfg)) return "disabled";
|
|
2110
2821
|
const root = notificationRootForCwd(input.cwd);
|
|
2111
2822
|
const fp = tokenFingerprint(cfg.botToken);
|
|
2823
|
+
// A live v0.10 parent has no stable process authority on Windows. Never turn an
|
|
2824
|
+
// unproven cooperative handoff into destructive cleanup or a replacement spawn.
|
|
2825
|
+
if ((deps.platform ?? process.platform) === "win32") {
|
|
2826
|
+
const parentState: unknown = await readDaemonState(input.settings, deps.fs);
|
|
2827
|
+
if (isParentDaemonState(parentState) && (deps.pidAlive ?? defaultPidAlive)(parentState.pid))
|
|
2828
|
+
return "blocked_identity";
|
|
2829
|
+
}
|
|
2830
|
+
// Windows can retain dead launcher metadata without an ownership lock; reclaim
|
|
2831
|
+
// its dead discovery records before the replacement can publish a new owner.
|
|
2832
|
+
if ((deps.platform ?? process.platform) === "win32" && !deps.fs) {
|
|
2833
|
+
const preflight = await reclaimDeadDaemonOwner({
|
|
2834
|
+
settings: input.settings,
|
|
2835
|
+
endpointDir: path.join(root, "sdk"),
|
|
2836
|
+
fs: deps.fs,
|
|
2837
|
+
now: deps.now,
|
|
2838
|
+
pidAlive: deps.pidAlive,
|
|
2839
|
+
pidIncarnation: deps.pidIncarnation,
|
|
2840
|
+
});
|
|
2841
|
+
if (!preflight.recovered && preflight.reason !== "not-confirmed-dead") {
|
|
2842
|
+
logger.warn(
|
|
2843
|
+
`notifications: startup recovery unsafe (${preflight.reason}); run \`gjc notify recovery\` for diagnostics`,
|
|
2844
|
+
);
|
|
2845
|
+
return "blocked_identity";
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2112
2848
|
let spawned = await withTelegramSetupLease(
|
|
2113
2849
|
cfg.botToken,
|
|
2114
2850
|
async () =>
|
|
@@ -2139,7 +2875,11 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
2139
2875
|
waitStepMs: deps.waitStepMs,
|
|
2140
2876
|
timeoutMs: deps.readinessTimeoutMs,
|
|
2141
2877
|
});
|
|
2142
|
-
|
|
2878
|
+
// A legacy parent cannot satisfy the current ready-state shape, but the
|
|
2879
|
+
// bounded wait gives its heartbeat a second observation window. Retry the
|
|
2880
|
+
// acquisition once so legacyParentHandoffDecision can consume that
|
|
2881
|
+
// attestation before startup treats the owner as blocked.
|
|
2882
|
+
if (ready || isLegacyParentDaemonState(provisional)) {
|
|
2143
2883
|
spawned = await withTelegramSetupLease(
|
|
2144
2884
|
cfg.botToken,
|
|
2145
2885
|
async () =>
|
|
@@ -2150,39 +2890,108 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
2150
2890
|
);
|
|
2151
2891
|
}
|
|
2152
2892
|
}
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
(await reclaimDeadDaemonOwner({
|
|
2893
|
+
let recoveryReason: Extract<DeadOwnerRecoveryResult, { recovered: false }>["reason"] | undefined;
|
|
2894
|
+
if (spawned.result === "blocked" && !spawned.warnings[0]?.includes("provisional")) {
|
|
2895
|
+
const recovery = await reclaimDeadDaemonOwner({
|
|
2157
2896
|
settings: input.settings,
|
|
2897
|
+
endpointDir: path.join(root, "sdk"),
|
|
2158
2898
|
fs: deps.fs,
|
|
2159
2899
|
now: deps.now,
|
|
2160
2900
|
pidAlive: deps.pidAlive,
|
|
2161
2901
|
pidIncarnation: deps.pidIncarnation,
|
|
2162
|
-
})
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
),
|
|
2174
|
-
);
|
|
2902
|
+
});
|
|
2903
|
+
if (recovery.recovered) {
|
|
2904
|
+
spawned = await withTelegramSetupLease(
|
|
2905
|
+
cfg.botToken,
|
|
2906
|
+
async () =>
|
|
2907
|
+
await spawnTelegramDaemonOwner(
|
|
2908
|
+
{ settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
|
|
2909
|
+
deps,
|
|
2910
|
+
),
|
|
2911
|
+
);
|
|
2912
|
+
} else recoveryReason = recovery.reason;
|
|
2175
2913
|
}
|
|
2176
2914
|
if (spawned.result === "blocked") {
|
|
2177
|
-
logger.warn(
|
|
2915
|
+
logger.warn(
|
|
2916
|
+
`notifications: failed to ensure Telegram daemon: ${recoveryReason ? `stale recovery ${recoveryReason}; run \`gjc notify recovery\`` : spawned.warnings.join("; ")}`,
|
|
2917
|
+
);
|
|
2178
2918
|
return "blocked_identity";
|
|
2179
2919
|
}
|
|
2180
2920
|
if (spawned.result === "attached" && spawned.reloadRequired) {
|
|
2181
2921
|
const previous = await readNotificationRootRegistration({ ...input, fs: deps.fs });
|
|
2182
2922
|
await registerNotificationRoot({ ...input, fs: deps.fs });
|
|
2183
|
-
const
|
|
2184
|
-
const
|
|
2185
|
-
|
|
2923
|
+
const fsImpl = deps.fs ?? nodeFs;
|
|
2924
|
+
const now = deps.now ?? Date.now;
|
|
2925
|
+
const pidAlive = deps.pidAlive ?? defaultPidAlive;
|
|
2926
|
+
const pidIncarnation = deps.pidIncarnation ?? defaultPidIncarnation;
|
|
2927
|
+
const reloadAttemptPath = path.join(
|
|
2928
|
+
daemonPaths(input.settings.getAgentDir()).dir,
|
|
2929
|
+
"telegram-daemon.reload-attempt.json",
|
|
2930
|
+
);
|
|
2931
|
+
await ensureDir(fsImpl, daemonPaths(input.settings.getAgentDir()).dir);
|
|
2932
|
+
const reloadWaitStepMs = Math.max(deps.waitStepMs ?? 100, 1);
|
|
2933
|
+
const reloadFreshnessWaitMs = Math.max(deps.readinessTimeoutMs ?? RELOAD_FRESHNESS_WAIT_MS, reloadWaitStepMs);
|
|
2934
|
+
const reloadReadinessTimeoutMs = deps.readinessTimeoutMs ?? RELOAD_FRESHNESS_WAIT_MS;
|
|
2935
|
+
const reloadResult = await withFileLock(
|
|
2936
|
+
reloadAttemptPath,
|
|
2937
|
+
async () => {
|
|
2938
|
+
const currentState = await readDaemonState(input.settings, fsImpl);
|
|
2939
|
+
const previousAttempt = await readJson<{
|
|
2940
|
+
lastReloadAt?: number;
|
|
2941
|
+
ownerId?: string;
|
|
2942
|
+
targetGeneration?: number;
|
|
2943
|
+
}>(fsImpl, reloadAttemptPath).catch(() => undefined);
|
|
2944
|
+
const reloadNow = now();
|
|
2945
|
+
const cooldownApplies =
|
|
2946
|
+
previousAttempt?.targetGeneration === DAEMON_GENERATION &&
|
|
2947
|
+
typeof previousAttempt.lastReloadAt === "number" &&
|
|
2948
|
+
Number.isFinite(previousAttempt.lastReloadAt) &&
|
|
2949
|
+
reloadNow - previousAttempt.lastReloadAt < AUTOMATIC_RELOAD_COOLDOWN_MS;
|
|
2950
|
+
if (cooldownApplies) {
|
|
2951
|
+
// A sibling ensure may have just finished its reload. Its replacement
|
|
2952
|
+
// daemon heartbeats only after startup, so wait briefly for the fresh
|
|
2953
|
+
// ready owner instead of issuing a duplicate reload in the gap.
|
|
2954
|
+
const sleep = deps.sleep ?? Bun.sleep;
|
|
2955
|
+
const waitStepMs = reloadWaitStepMs;
|
|
2956
|
+
const waitBudgetMs = reloadFreshnessWaitMs;
|
|
2957
|
+
const deadline = reloadNow + waitBudgetMs;
|
|
2958
|
+
// Iteration-capped so a frozen `now` cannot spin forever.
|
|
2959
|
+
const maxWaits = Math.ceil(waitBudgetMs / waitStepMs);
|
|
2960
|
+
for (let waits = 0; waits <= maxWaits; waits++) {
|
|
2961
|
+
const state = await readDaemonState(input.settings, fsImpl);
|
|
2962
|
+
const t = now();
|
|
2963
|
+
if (
|
|
2964
|
+
isFreshLiveOwner({
|
|
2965
|
+
state,
|
|
2966
|
+
now: t,
|
|
2967
|
+
tokenFingerprint: fp,
|
|
2968
|
+
chatId: cfg.chatId,
|
|
2969
|
+
pidAlive,
|
|
2970
|
+
pidIncarnation,
|
|
2971
|
+
})
|
|
2972
|
+
)
|
|
2973
|
+
return "attached" as const;
|
|
2974
|
+
if (t >= deadline) break;
|
|
2975
|
+
await sleep(waitStepMs);
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
await writeJsonAtomic(fsImpl, reloadAttemptPath, {
|
|
2979
|
+
lastReloadAt: reloadNow,
|
|
2980
|
+
ownerId: currentState?.ownerId ?? "",
|
|
2981
|
+
targetGeneration: DAEMON_GENERATION,
|
|
2982
|
+
});
|
|
2983
|
+
const controller = new TelegramDaemonController(input.settings, telegramControllerDeps(deps));
|
|
2984
|
+
const upgrade = await controller.reloadForGenerationUpgrade({}, spawned.legacyReloadRequired === true);
|
|
2985
|
+
return upgrade.outcome === "ready" ? ("reloaded" as const) : upgrade;
|
|
2986
|
+
},
|
|
2987
|
+
reloadReservationLockOptions({
|
|
2988
|
+
freshnessWaitMs: reloadFreshnessWaitMs,
|
|
2989
|
+
readinessTimeoutMs: reloadReadinessTimeoutMs,
|
|
2990
|
+
retryDelayMs: reloadWaitStepMs,
|
|
2991
|
+
}),
|
|
2992
|
+
);
|
|
2993
|
+
if (reloadResult === "attached") return "attached";
|
|
2994
|
+
if (reloadResult !== "reloaded") {
|
|
2186
2995
|
await restoreNotificationRootRegistration({
|
|
2187
2996
|
settings: input.settings,
|
|
2188
2997
|
sessionId: input.sessionId,
|
|
@@ -2190,7 +2999,7 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
2190
2999
|
previous,
|
|
2191
3000
|
fs: deps.fs,
|
|
2192
3001
|
});
|
|
2193
|
-
throw new Error(`Unable to replace stale Telegram daemon: ${
|
|
3002
|
+
throw new Error(`Unable to replace stale Telegram daemon: ${reloadResult.operation.message}`);
|
|
2194
3003
|
}
|
|
2195
3004
|
return "reloaded";
|
|
2196
3005
|
}
|
|
@@ -2243,12 +3052,28 @@ export async function ensureTelegramDaemonRunning(
|
|
|
2243
3052
|
}
|
|
2244
3053
|
|
|
2245
3054
|
function telegramControllerDeps(deps: TelegramDaemonDeps): TelegramDaemonControlDeps {
|
|
3055
|
+
// The legacy signal seam has no authority metadata of its own. Pair it with
|
|
3056
|
+
// the captured process incarnation so the controller retains its identity
|
|
3057
|
+
// fence; Windows continues through its hard-authority refusal path.
|
|
3058
|
+
const cooperativeSignalReference =
|
|
3059
|
+
deps.sendSignal && (deps.platform ?? process.platform) !== "win32"
|
|
3060
|
+
? (pid: number): DaemonProcessReference | undefined => {
|
|
3061
|
+
const incarnation = deps.pidIncarnation?.(pid);
|
|
3062
|
+
if (!isProcessIncarnation(incarnation)) return undefined;
|
|
3063
|
+
return {
|
|
3064
|
+
incarnation,
|
|
3065
|
+
termination: "cooperative",
|
|
3066
|
+
signalRoot: signal => deps.sendSignal!(pid, signal),
|
|
3067
|
+
};
|
|
3068
|
+
}
|
|
3069
|
+
: undefined;
|
|
2246
3070
|
return {
|
|
2247
3071
|
fs: deps.fs,
|
|
2248
3072
|
now: deps.now,
|
|
2249
3073
|
pidAlive: deps.pidAlive,
|
|
2250
|
-
processReference: deps.processReference,
|
|
3074
|
+
processReference: cooperativeSignalReference ?? deps.processReference,
|
|
2251
3075
|
pidIncarnation: deps.pidIncarnation,
|
|
3076
|
+
platform: deps.platform,
|
|
2252
3077
|
spawn: deps.spawn,
|
|
2253
3078
|
execPath: deps.execPath,
|
|
2254
3079
|
ownerPid: deps.pid,
|
|
@@ -2521,7 +3346,10 @@ export class TelegramUpdatePoller {
|
|
|
2521
3346
|
/** Mutable dispatch state shared by session frames and inbound Telegram updates. */
|
|
2522
3347
|
export class TelegramEventDispatchState {
|
|
2523
3348
|
readonly busy = new Set<string>();
|
|
2524
|
-
readonly inboundReactions = new Map<
|
|
3349
|
+
readonly inboundReactions = new Map<
|
|
3350
|
+
number,
|
|
3351
|
+
{ messageId: number; socketLease?: { session: SessionSocket; token: number; logicalSessionId: string } }
|
|
3352
|
+
>();
|
|
2525
3353
|
readonly seenUpdateIds = new Set<number>();
|
|
2526
3354
|
}
|
|
2527
3355
|
|
|
@@ -2610,11 +3438,16 @@ interface SessionSocket {
|
|
|
2610
3438
|
sessionId: string;
|
|
2611
3439
|
/** Current logical session carried by valid threaded frames on this transport. */
|
|
2612
3440
|
logicalSessionId: string;
|
|
3441
|
+
/** Endpoint metadata proved this logical id belongs to this discovery record. */
|
|
3442
|
+
logicalSessionIdTrusted: boolean;
|
|
2613
3443
|
token: string;
|
|
2614
3444
|
endpointKey: string;
|
|
2615
3445
|
endpointDigest: string;
|
|
2616
3446
|
hostGeneration: number;
|
|
2617
3447
|
ws: WebSocket;
|
|
3448
|
+
/** Timestamp (via opts.now) at which this socket began connecting. */
|
|
3449
|
+
connectingSince: number;
|
|
3450
|
+
|
|
2618
3451
|
pending: Map<string, { sessionId: string; actionId: string }>;
|
|
2619
3452
|
/** True once the server advertised the `client_ping_pong` capability. */
|
|
2620
3453
|
capable: boolean;
|
|
@@ -2630,11 +3463,20 @@ interface SessionSocket {
|
|
|
2630
3463
|
/** Queues live frames until startup replay is applied. */
|
|
2631
3464
|
replayPending: boolean;
|
|
2632
3465
|
replayQueue: Record<string, unknown>[];
|
|
3466
|
+
/** Trusted recovery may not route until its durable endpoint lease is committed. */
|
|
3467
|
+
recoveryLease?: {
|
|
3468
|
+
state: "pending" | "authorized" | "rejected";
|
|
3469
|
+
logicalSessionId: string;
|
|
3470
|
+
binding: TopicEndpointBinding;
|
|
3471
|
+
token: number;
|
|
3472
|
+
};
|
|
2633
3473
|
}
|
|
2634
3474
|
|
|
2635
3475
|
interface ModelChoiceRoute {
|
|
2636
3476
|
/** Exact transport socket that rendered this choice. */
|
|
2637
3477
|
session: SessionSocket;
|
|
3478
|
+
/** Immutable owner lease captured when this choice was rendered. */
|
|
3479
|
+
socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
2638
3480
|
/** Logical session current when this choice was rendered. */
|
|
2639
3481
|
sessionId: string;
|
|
2640
3482
|
selector: string;
|
|
@@ -2665,7 +3507,8 @@ interface ToolActivityOwner {
|
|
|
2665
3507
|
interface PendingThreadedFrame {
|
|
2666
3508
|
send: ThreadedSend;
|
|
2667
3509
|
msg: Record<string, unknown>;
|
|
2668
|
-
|
|
3510
|
+
logicalSessionId: string;
|
|
3511
|
+
socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
2669
3512
|
toolActivity?: ToolActivityOwner;
|
|
2670
3513
|
}
|
|
2671
3514
|
|
|
@@ -2690,6 +3533,8 @@ interface SelectedAckQueueItem {
|
|
|
2690
3533
|
requestId: string;
|
|
2691
3534
|
commitKey: string;
|
|
2692
3535
|
session: SessionSocket;
|
|
3536
|
+
/** Immutable owner lease captured when this acknowledgement was admitted. */
|
|
3537
|
+
socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
2693
3538
|
state: "queued" | "dispatching" | "sending";
|
|
2694
3539
|
controller?: AbortController;
|
|
2695
3540
|
followers: Array<{ pendingKey: string; requestId: string; commitKey: string }>;
|
|
@@ -2698,6 +3543,8 @@ interface SelectedAckQueueItem {
|
|
|
2698
3543
|
interface TelegramQueuePayload {
|
|
2699
3544
|
send: ThreadedSend;
|
|
2700
3545
|
topicLease?: TopicAuthorityLease;
|
|
3546
|
+
/** Immutable owner lease captured when the work was admitted. */
|
|
3547
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
2701
3548
|
selectedAck?: SelectedAckQueueItem;
|
|
2702
3549
|
btwDelivery?: BtwQueuedDelivery;
|
|
2703
3550
|
toolActivity?: ToolActivityOwner;
|
|
@@ -2708,6 +3555,10 @@ interface PendingBtwTurn {
|
|
|
2708
3555
|
transportSessionId: string;
|
|
2709
3556
|
/** Logical session id supplied to the session endpoint. */
|
|
2710
3557
|
logicalSessionId: string;
|
|
3558
|
+
/** Immutable committed owner lease captured when the inbound turn was admitted. */
|
|
3559
|
+
socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
3560
|
+
/** Lease token to which this request was most recently dispatched. */
|
|
3561
|
+
dispatchedSocketLeaseToken?: number;
|
|
2711
3562
|
endpointDigest: string;
|
|
2712
3563
|
generation: number;
|
|
2713
3564
|
question: string;
|
|
@@ -2810,12 +3661,22 @@ export class TelegramNotificationDaemon {
|
|
|
2810
3661
|
private readonly pollConflictBackoff = new OperatorBackoffPolicy({ initialMs: 500, maxMs: 5_000 });
|
|
2811
3662
|
private readonly loopBackoff = new OperatorBackoffPolicy({ initialMs: 250, maxMs: 4_000 });
|
|
2812
3663
|
private running = false;
|
|
3664
|
+
/** Once set, a concurrent startup await can never restore a running daemon. */
|
|
3665
|
+
private stopRequested = false;
|
|
3666
|
+
|
|
2813
3667
|
private readonly fsImpl: TelegramDaemonFs;
|
|
2814
3668
|
private readonly botApi: BotApi;
|
|
2815
3669
|
private readonly effects = new TelegramEffectSupervisor();
|
|
2816
3670
|
private readonly topics = new TopicRegistry();
|
|
2817
3671
|
/** Serializes registry snapshots so an older atomic write cannot overwrite newer rename state. */
|
|
3672
|
+
/** Legacy sockets have no durable token, so explicit teardown is their revocation fence. */
|
|
3673
|
+
private readonly droppedSessions = new WeakSet<SessionSocket>();
|
|
2818
3674
|
private topicsPersistQueue: Promise<void> = Promise.resolve();
|
|
3675
|
+
/** Serializes recovery compare/write/publish claims so competing endpoint migrations cannot durably diverge. */
|
|
3676
|
+
private recoveryBindingClaimQueue: Promise<void> = Promise.resolve();
|
|
3677
|
+
/** Durable compensation fences retry under supervision until persistence succeeds. */
|
|
3678
|
+
private readonly compensationFenceRetries = new Map<string, Promise<void>>();
|
|
3679
|
+
|
|
2819
3680
|
/** Daemon edit attempts that can race an accepted user service message. */
|
|
2820
3681
|
private readonly daemonRenameAttempts = new Map<string, number>();
|
|
2821
3682
|
private readonly selectedAckPending = new Map<string, SelectedAckQueueItem>();
|
|
@@ -2837,10 +3698,17 @@ export class TelegramNotificationDaemon {
|
|
|
2837
3698
|
private readonly draftStream = new DraftStreamState();
|
|
2838
3699
|
/** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
|
|
2839
3700
|
private readonly topicOwnerByIdentity = new Map<string, string>();
|
|
3701
|
+
/** Ephemeral legacy topic owners retained across a same-socket config rekey. */
|
|
3702
|
+
private readonly legacyTopicOwners = new Map<string, SessionSocket>();
|
|
3703
|
+
/** Preserved initiator topics must not route through a rekeyed transport. */
|
|
3704
|
+
private readonly preservedInitiatorTopics = new Set<string>();
|
|
2840
3705
|
/** Non-identity frames held until identity creates the correct thread. */
|
|
2841
3706
|
private readonly pendingThreadedFrames = new Map<string, PendingThreadedFrame[]>();
|
|
2842
|
-
/**
|
|
2843
|
-
private readonly closedEndpointKeys = new Map<string,
|
|
3707
|
+
/** Durable endpoint leases for sessions that already sent an authorized session_closed. */
|
|
3708
|
+
private readonly closedEndpointKeys = new Map<string, TopicEndpointBinding>();
|
|
3709
|
+
/** Exactly one authorized transport may route each recovered logical session. */
|
|
3710
|
+
private readonly logicalSessionOwners = new Map<string, SessionSocket>();
|
|
3711
|
+
private nextSocketLeaseToken = 1;
|
|
2844
3712
|
/** True once the daemon has nudged the user to enable Threaded Mode. */
|
|
2845
3713
|
private threadedFallbackNoticeSent = false;
|
|
2846
3714
|
/** Sessions whose identity header was already sent flat (Threaded Mode off). */
|
|
@@ -2854,7 +3722,10 @@ export class TelegramNotificationDaemon {
|
|
|
2854
3722
|
return this.dispatchState.busy;
|
|
2855
3723
|
}
|
|
2856
3724
|
/** Inbound update id → originating Telegram message, for delivery reactions. */
|
|
2857
|
-
private get inboundReactions(): Map<
|
|
3725
|
+
private get inboundReactions(): Map<
|
|
3726
|
+
number,
|
|
3727
|
+
{ messageId: number; socketLease?: { session: SessionSocket; token: number; logicalSessionId: string } }
|
|
3728
|
+
> {
|
|
2858
3729
|
return this.dispatchState.inboundReactions;
|
|
2859
3730
|
}
|
|
2860
3731
|
/**
|
|
@@ -2890,7 +3761,7 @@ export class TelegramNotificationDaemon {
|
|
|
2890
3761
|
this.selectedAckPending.delete(item.pendingKey);
|
|
2891
3762
|
for (const follower of item.followers) this.selectedAckPending.delete(follower.pendingKey);
|
|
2892
3763
|
this.cacheSelectedAck(item.cacheKey, outcome);
|
|
2893
|
-
if (item.session.ws.readyState === WebSocket.OPEN) {
|
|
3764
|
+
if (this.#leaseTokenAllows(item.socketLease) && item.session.ws.readyState === WebSocket.OPEN) {
|
|
2894
3765
|
for (const result of [{ requestId: item.requestId, commitKey: item.commitKey }, ...item.followers]) {
|
|
2895
3766
|
item.session.ws.send(
|
|
2896
3767
|
JSON.stringify({
|
|
@@ -2910,6 +3781,8 @@ export class TelegramNotificationDaemon {
|
|
|
2910
3781
|
* ~25s getUpdates timeout. Safe to call from a signal handler.
|
|
2911
3782
|
*/
|
|
2912
3783
|
requestStop(_reason?: "reload" | "stop" | "signal"): void {
|
|
3784
|
+
this.stopRequested = true;
|
|
3785
|
+
|
|
2913
3786
|
const toolShutdown = this.beginToolActivityShutdown();
|
|
2914
3787
|
void toolShutdown
|
|
2915
3788
|
.finally(() => {
|
|
@@ -3231,7 +4104,7 @@ export class TelegramNotificationDaemon {
|
|
|
3231
4104
|
|
|
3232
4105
|
const frame = this.buildLifecycleFrame(parsed, updateId ?? Date.now());
|
|
3233
4106
|
const response = await this.submitLifecycleFrame(frame);
|
|
3234
|
-
await reply(this.formatLifecycleResponse(response));
|
|
4107
|
+
await reply(this.formatLifecycleResponse(response, verb));
|
|
3235
4108
|
return true;
|
|
3236
4109
|
}
|
|
3237
4110
|
|
|
@@ -3247,8 +4120,8 @@ export class TelegramNotificationDaemon {
|
|
|
3247
4120
|
}
|
|
3248
4121
|
|
|
3249
4122
|
/** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
|
|
3250
|
-
private formatLifecycleResponse(r: SessionLifecycleResponse): string {
|
|
3251
|
-
return formatLifecycleOutcome(r);
|
|
4123
|
+
private formatLifecycleResponse(r: SessionLifecycleResponse, verb: LifecycleCommandVerb): string {
|
|
4124
|
+
return formatLifecycleOutcome(r, verb);
|
|
3252
4125
|
}
|
|
3253
4126
|
|
|
3254
4127
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
@@ -3330,10 +4203,14 @@ export class TelegramNotificationDaemon {
|
|
|
3330
4203
|
finishImmediately({ status: "failed", reason: "route_missing" });
|
|
3331
4204
|
return;
|
|
3332
4205
|
}
|
|
3333
|
-
const
|
|
4206
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
4207
|
+
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
4208
|
+
if (!socketLease) return;
|
|
4209
|
+
const topicLease = this.topicAuthorityLeaseFromRegistry(logicalSessionId);
|
|
3334
4210
|
if (
|
|
3335
|
-
this.topics.get(
|
|
3336
|
-
(
|
|
4211
|
+
this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
|
|
4212
|
+
this.topics.get(logicalSessionId)?.bindingMalformed ||
|
|
4213
|
+
(mode === "recovery" && (!topicLease || msg.sessionId !== logicalSessionId))
|
|
3337
4214
|
) {
|
|
3338
4215
|
finishImmediately({ status: "failed", reason: "route_missing" });
|
|
3339
4216
|
return;
|
|
@@ -3359,13 +4236,14 @@ export class TelegramNotificationDaemon {
|
|
|
3359
4236
|
requestId,
|
|
3360
4237
|
commitKey,
|
|
3361
4238
|
session,
|
|
4239
|
+
socketLease,
|
|
3362
4240
|
state: "queued",
|
|
3363
4241
|
followers: [],
|
|
3364
4242
|
};
|
|
3365
4243
|
this.selectedAckPending.set(pendingKey, item);
|
|
3366
4244
|
this.submitPool({
|
|
3367
|
-
sessionId: session.sessionId,
|
|
3368
4245
|
lane: "ask",
|
|
4246
|
+
sessionId: logicalSessionId,
|
|
3369
4247
|
itemId: item.itemId,
|
|
3370
4248
|
deadlineAt,
|
|
3371
4249
|
payload: {
|
|
@@ -3442,34 +4320,117 @@ export class TelegramNotificationDaemon {
|
|
|
3442
4320
|
name: "activity",
|
|
3443
4321
|
matches: msg => msg.type === "activity",
|
|
3444
4322
|
handle: async (session, msg) => {
|
|
4323
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
3445
4324
|
if (msg.state === "busy") {
|
|
3446
|
-
this.busy.add(
|
|
3447
|
-
await this.sendTyping(session
|
|
4325
|
+
this.busy.add(logicalSessionId);
|
|
4326
|
+
await this.sendTyping(logicalSessionId, this.#socketLease(session, logicalSessionId));
|
|
3448
4327
|
} else {
|
|
3449
|
-
this.busy.delete(
|
|
4328
|
+
this.busy.delete(logicalSessionId);
|
|
3450
4329
|
}
|
|
3451
4330
|
},
|
|
3452
4331
|
})
|
|
3453
4332
|
.add({
|
|
3454
4333
|
name: "inbound_ack",
|
|
3455
4334
|
matches: msg => msg.type === "inbound_ack" && typeof msg.updateId === "number",
|
|
3456
|
-
handle: async (
|
|
4335
|
+
handle: async (session, msg) => {
|
|
3457
4336
|
const target = this.inboundReactions.get(msg.updateId as number);
|
|
3458
4337
|
if (target && msg.state === "consumed") {
|
|
3459
4338
|
this.inboundReactions.delete(msg.updateId as number);
|
|
3460
|
-
await this.setReaction(
|
|
4339
|
+
await this.setReaction(
|
|
4340
|
+
target.messageId,
|
|
4341
|
+
CONSUMED_REACTION,
|
|
4342
|
+
target.socketLease ?? this.#socketLease(session),
|
|
4343
|
+
);
|
|
3461
4344
|
}
|
|
3462
4345
|
},
|
|
3463
4346
|
})
|
|
3464
4347
|
.add({
|
|
3465
4348
|
name: "session_closed",
|
|
3466
4349
|
matches: msg => msg.type === "session_closed",
|
|
3467
|
-
handle: async session => {
|
|
3468
|
-
this
|
|
3469
|
-
|
|
4350
|
+
handle: async (session, msg) => {
|
|
4351
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
4352
|
+
if (
|
|
4353
|
+
(this.sessions.has(session.sessionId) && this.sessions.get(session.sessionId) !== session) ||
|
|
4354
|
+
typeof msg.sessionId !== "string" ||
|
|
4355
|
+
msg.sessionId !== logicalSessionId ||
|
|
4356
|
+
(session.logicalSessionIdTrusted && (!this.#leaseAllows(session) || session.hostGeneration < 1))
|
|
4357
|
+
)
|
|
4358
|
+
return;
|
|
4359
|
+
const socketLease = session.logicalSessionIdTrusted
|
|
4360
|
+
? this.#socketLease(session, logicalSessionId)
|
|
4361
|
+
: undefined;
|
|
4362
|
+
if (session.logicalSessionIdTrusted && !socketLease) return;
|
|
4363
|
+
this.busy.delete(logicalSessionId);
|
|
3470
4364
|
await this.#terminalizeBtwTurnsForSession(session, true);
|
|
3471
|
-
|
|
3472
|
-
this
|
|
4365
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
4366
|
+
await this.#withRecoveryBindingClaim(async () => {
|
|
4367
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
4368
|
+
|
|
4369
|
+
const closedBinding = this.#endpointBinding(session);
|
|
4370
|
+
const closeTopicAuthority = this.topics.captureDeleteAuthority(logicalSessionId);
|
|
4371
|
+
const previousClosedBinding = this.closedEndpointKeys.get(session.sessionId);
|
|
4372
|
+
await this.#persistTopicMutation(
|
|
4373
|
+
() => {
|
|
4374
|
+
this.closedEndpointKeys.set(session.sessionId, closedBinding);
|
|
4375
|
+
this.topics.beginDelete(logicalSessionId);
|
|
4376
|
+
},
|
|
4377
|
+
() => {
|
|
4378
|
+
this.topics.restoreDeleteAuthority(closeTopicAuthority);
|
|
4379
|
+
if (previousClosedBinding === undefined) this.closedEndpointKeys.delete(session.sessionId);
|
|
4380
|
+
else if (this.closedEndpointKeys.get(session.sessionId) === closedBinding)
|
|
4381
|
+
this.closedEndpointKeys.set(session.sessionId, previousClosedBinding);
|
|
4382
|
+
},
|
|
4383
|
+
);
|
|
4384
|
+
if (
|
|
4385
|
+
socketLease &&
|
|
4386
|
+
(this.sessions.get(session.sessionId) !== session ||
|
|
4387
|
+
session.recoveryLease?.token !== socketLease.token ||
|
|
4388
|
+
session.recoveryLease.state !== "authorized" ||
|
|
4389
|
+
this.logicalSessionOwners.get(logicalSessionId) !== session)
|
|
4390
|
+
) {
|
|
4391
|
+
// A replacement won after the close fence committed. Restore the exact
|
|
4392
|
+
// pre-close authority and remove the predecessor tombstone together.
|
|
4393
|
+
const restoreCloseAuthority = (): Promise<boolean> =>
|
|
4394
|
+
this.#persistTopicMutation(
|
|
4395
|
+
() => {
|
|
4396
|
+
const restored = this.topics.restoreDeleteAuthority(closeTopicAuthority);
|
|
4397
|
+
if (!restored) throw new Error("close authority changed before compensation");
|
|
4398
|
+
this.closedEndpointKeys.delete(session.sessionId);
|
|
4399
|
+
return true;
|
|
4400
|
+
},
|
|
4401
|
+
() => {
|
|
4402
|
+
this.topics.restoreDeleteFence(closeTopicAuthority);
|
|
4403
|
+
this.closedEndpointKeys.set(session.sessionId, closedBinding);
|
|
4404
|
+
},
|
|
4405
|
+
);
|
|
4406
|
+
try {
|
|
4407
|
+
await restoreCloseAuthority();
|
|
4408
|
+
} catch {
|
|
4409
|
+
await restoreCloseAuthority();
|
|
4410
|
+
}
|
|
4411
|
+
return;
|
|
4412
|
+
}
|
|
4413
|
+
const deleteOutcome = await this.deleteTopic(logicalSessionId, socketLease, true);
|
|
4414
|
+
if (
|
|
4415
|
+
deleteOutcome === "pre_dispatch_cancelled" &&
|
|
4416
|
+
socketLease &&
|
|
4417
|
+
!this.#deleteLeaseAllows(socketLease)
|
|
4418
|
+
) {
|
|
4419
|
+
await this.#persistTopicMutation(
|
|
4420
|
+
() => {
|
|
4421
|
+
if (!this.topics.restoreDeleteAuthority(closeTopicAuthority))
|
|
4422
|
+
throw new Error("close authority changed before compensation");
|
|
4423
|
+
this.closedEndpointKeys.delete(session.sessionId);
|
|
4424
|
+
},
|
|
4425
|
+
() => {
|
|
4426
|
+
this.topics.restoreDeleteFence(closeTopicAuthority);
|
|
4427
|
+
this.closedEndpointKeys.set(session.sessionId, closedBinding);
|
|
4428
|
+
},
|
|
4429
|
+
);
|
|
4430
|
+
return;
|
|
4431
|
+
}
|
|
4432
|
+
this.dropSession(session, "session_closed");
|
|
4433
|
+
});
|
|
3473
4434
|
},
|
|
3474
4435
|
});
|
|
3475
4436
|
}
|
|
@@ -3550,17 +4511,44 @@ export class TelegramNotificationDaemon {
|
|
|
3550
4511
|
return true;
|
|
3551
4512
|
}
|
|
3552
4513
|
|
|
4514
|
+
private async releaseSeenUpdateId(updateId: number): Promise<void> {
|
|
4515
|
+
if (!this.dispatchState.seenUpdateIds.has(updateId)) return;
|
|
4516
|
+
const candidate = new Set(this.dispatchState.seenUpdateIds);
|
|
4517
|
+
candidate.delete(updateId);
|
|
4518
|
+
try {
|
|
4519
|
+
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
4520
|
+
await ensureDir(this.fsImpl, paths.dir);
|
|
4521
|
+
await writeJsonAtomic(this.fsImpl, paths.seenUpdates, {
|
|
4522
|
+
version: 1,
|
|
4523
|
+
updateIds: [...candidate],
|
|
4524
|
+
});
|
|
4525
|
+
} catch {
|
|
4526
|
+
logger.warn("notifications: Telegram update state release failed");
|
|
4527
|
+
return;
|
|
4528
|
+
}
|
|
4529
|
+
this.dispatchState.seenUpdateIds.clear();
|
|
4530
|
+
for (const seenId of candidate) this.dispatchState.seenUpdateIds.add(seenId);
|
|
4531
|
+
}
|
|
4532
|
+
|
|
3553
4533
|
async scanRoots(): Promise<void> {
|
|
4534
|
+
await this.reconcilePendingTopicDeletes();
|
|
3554
4535
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
3555
4536
|
const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
|
|
3556
4537
|
const endpointSessionIds = new Set<string>();
|
|
4538
|
+
// Permanent absences prune; only transient I/O keeps orphan reconciliation
|
|
4539
|
+
// gated (so one deleted worktree cannot disable cleanup forever) (#2956).
|
|
3557
4540
|
let allRootsReadable = true;
|
|
4541
|
+
const permanentlyMissingRoots: string[] = [];
|
|
3558
4542
|
for (const root of rootState?.roots ?? []) {
|
|
3559
4543
|
const dir = path.join(root, "sdk");
|
|
3560
4544
|
let files: string[];
|
|
3561
4545
|
try {
|
|
3562
4546
|
files = await this.fsImpl.readdir(dir);
|
|
3563
|
-
} catch {
|
|
4547
|
+
} catch (error) {
|
|
4548
|
+
if (isPermanentMissingPathError(error)) {
|
|
4549
|
+
permanentlyMissingRoots.push(root);
|
|
4550
|
+
continue;
|
|
4551
|
+
}
|
|
3564
4552
|
allRootsReadable = false;
|
|
3565
4553
|
continue;
|
|
3566
4554
|
}
|
|
@@ -3573,6 +4561,11 @@ export class TelegramNotificationDaemon {
|
|
|
3573
4561
|
// A hard-killed owner can leave both its endpoint file and socket map
|
|
3574
4562
|
// entry behind; skipping the read in that case permanently preserves
|
|
3575
4563
|
// the stale Telegram topic.
|
|
4564
|
+
const owner = this.logicalSessionOwners.get(sessionId);
|
|
4565
|
+
if (owner && this.#leaseAllows(owner, sessionId)) {
|
|
4566
|
+
if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
|
|
4567
|
+
continue;
|
|
4568
|
+
}
|
|
3576
4569
|
const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
|
|
3577
4570
|
if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) {
|
|
3578
4571
|
const connected = this.sessions.get(sessionId);
|
|
@@ -3581,22 +4574,57 @@ export class TelegramNotificationDaemon {
|
|
|
3581
4574
|
await this.observeOrphanedTopic(sessionId);
|
|
3582
4575
|
continue;
|
|
3583
4576
|
}
|
|
4577
|
+
|
|
3584
4578
|
if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
|
|
3585
4579
|
const endpointKey = endpointGenerationKey(endpoint.url, endpoint.token);
|
|
3586
4580
|
const connected = this.sessions.get(sessionId);
|
|
3587
4581
|
if (connected) {
|
|
3588
|
-
if (
|
|
4582
|
+
if (
|
|
4583
|
+
connected.endpointKey !== endpointKey ||
|
|
4584
|
+
(connected.ws.readyState === WebSocket.CONNECTING &&
|
|
4585
|
+
this.runtime.now() - connected.connectingSince >= CONNECTING_RECONNECT_MS)
|
|
4586
|
+
)
|
|
3589
4587
|
this.connectSession(sessionId, endpoint.url, endpoint.token);
|
|
3590
4588
|
continue;
|
|
3591
4589
|
}
|
|
3592
|
-
|
|
3593
|
-
|
|
4590
|
+
const closed = this.closedEndpointKeys.get(sessionId);
|
|
4591
|
+
if (closed?.endpointKey === endpointKey) continue;
|
|
4592
|
+
if (closed) {
|
|
4593
|
+
this.closedEndpointKeys.delete(sessionId);
|
|
4594
|
+
await this.persistTopics();
|
|
4595
|
+
}
|
|
3594
4596
|
this.connectSession(sessionId, endpoint.url, endpoint.token);
|
|
3595
4597
|
} catch {}
|
|
3596
4598
|
}
|
|
3597
4599
|
}
|
|
4600
|
+
if (permanentlyMissingRoots.length > 0) {
|
|
4601
|
+
try {
|
|
4602
|
+
await pruneMissingNotificationRoots({
|
|
4603
|
+
settings: this.opts.settings,
|
|
4604
|
+
fs: this.fsImpl,
|
|
4605
|
+
candidates: permanentlyMissingRoots,
|
|
4606
|
+
});
|
|
4607
|
+
} catch (error) {
|
|
4608
|
+
logger.warn(`notifications: dead-root prune failed: ${sanitizeDiagnostic(String(error))}`);
|
|
4609
|
+
}
|
|
4610
|
+
}
|
|
4611
|
+
// Best-effort periodic reap of retained exact-unlink quarantines (#2956).
|
|
4612
|
+
try {
|
|
4613
|
+
await reapStaleNotificationArtifacts({
|
|
4614
|
+
settings: this.opts.settings,
|
|
4615
|
+
fs: this.fsImpl,
|
|
4616
|
+
now: this.opts.now,
|
|
4617
|
+
});
|
|
4618
|
+
} catch (error) {
|
|
4619
|
+
logger.warn(`notifications: leak-artifact reap failed: ${sanitizeDiagnostic(String(error))}`);
|
|
4620
|
+
}
|
|
3598
4621
|
if (allRootsReadable) {
|
|
3599
4622
|
for (const sessionId of this.topics.sessionIds()) {
|
|
4623
|
+
const owner = this.logicalSessionOwners.get(sessionId);
|
|
4624
|
+
if (owner && this.#leaseAllows(owner)) {
|
|
4625
|
+
if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
|
|
4626
|
+
continue;
|
|
4627
|
+
}
|
|
3600
4628
|
if (!this.sessions.has(sessionId) && !endpointSessionIds.has(sessionId)) {
|
|
3601
4629
|
this.#terminalizeBtwTurnsForTransportSession(sessionId);
|
|
3602
4630
|
await this.observeOrphanedTopic(sessionId);
|
|
@@ -3610,7 +4638,6 @@ export class TelegramNotificationDaemon {
|
|
|
3610
4638
|
const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
|
|
3611
4639
|
const endpointKey = endpointGenerationKey(url, token);
|
|
3612
4640
|
const endpointDigest = endpointAuthorityDigest(url, token);
|
|
3613
|
-
this.closedEndpointKeys.delete(sessionId);
|
|
3614
4641
|
const existing = this.sessions.get(sessionId);
|
|
3615
4642
|
if (existing) {
|
|
3616
4643
|
this.dropSession(
|
|
@@ -3625,12 +4652,13 @@ export class TelegramNotificationDaemon {
|
|
|
3625
4652
|
const session: SessionSocket = {
|
|
3626
4653
|
sessionId,
|
|
3627
4654
|
logicalSessionId: sessionId,
|
|
3628
|
-
|
|
4655
|
+
logicalSessionIdTrusted: false,
|
|
3629
4656
|
token,
|
|
3630
4657
|
endpointKey,
|
|
3631
4658
|
endpointDigest,
|
|
3632
4659
|
hostGeneration: 0,
|
|
3633
4660
|
ws,
|
|
4661
|
+
connectingSince: this.runtime.now(),
|
|
3634
4662
|
pending: new Map(),
|
|
3635
4663
|
capable: false,
|
|
3636
4664
|
ephemeralCapable: false,
|
|
@@ -3642,13 +4670,20 @@ export class TelegramNotificationDaemon {
|
|
|
3642
4670
|
replayQueue: [],
|
|
3643
4671
|
};
|
|
3644
4672
|
this.sessions.set(sessionId, session);
|
|
4673
|
+
if (this.topics.get(sessionId)) this.preservedInitiatorTopics.add(sessionId);
|
|
4674
|
+
|
|
3645
4675
|
// Bidirectional capability advertisement: announce client_ping_pong once the
|
|
3646
4676
|
// socket is open. Sent on "open" only — a real WHATWG WebSocket cannot send
|
|
3647
4677
|
// while CONNECTING — and liveness starts only after a capable ServerHello.
|
|
3648
4678
|
ws.addEventListener("open", () => {
|
|
4679
|
+
if (this.sessions.get(sessionId) !== session) return;
|
|
3649
4680
|
session.replayPending = true;
|
|
3650
4681
|
session.replayQueue = [];
|
|
3651
|
-
|
|
4682
|
+
// Cursors are endpoint-authority scoped. Reusing a prior host's cursor can
|
|
4683
|
+
// skip its identity event when a fresh host restarts at generation 1.
|
|
4684
|
+
const persistedTopic = this.topics.get(sessionId);
|
|
4685
|
+
const replayCursor =
|
|
4686
|
+
persistedTopic?.endpointDigest === session.endpointDigest ? this.topics.replayCursor(sessionId) : undefined;
|
|
3652
4687
|
if (session.ws.readyState === WebSocket.OPEN) {
|
|
3653
4688
|
try {
|
|
3654
4689
|
session.ws.send(
|
|
@@ -3676,12 +4711,17 @@ export class TelegramNotificationDaemon {
|
|
|
3676
4711
|
);
|
|
3677
4712
|
} catch {}
|
|
3678
4713
|
}
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
4714
|
+
void (async () => {
|
|
4715
|
+
if (this.#logicalSessionId(session) !== sessionId) return;
|
|
4716
|
+
const topic = this.topics.get(sessionId);
|
|
4717
|
+
if (!topic || topic.authorityState === "delete_pending" || topic.bindingMalformed) return;
|
|
4718
|
+
const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
|
|
4719
|
+
if (topicLease?.topicId === topic.topicId) await this.flushPendingThreadedFrames(sessionId, topicLease);
|
|
4720
|
+
})().catch(err =>
|
|
4721
|
+
logger.warn(
|
|
4722
|
+
`notifications: Telegram topic reattach flush failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
|
|
4723
|
+
),
|
|
4724
|
+
);
|
|
3685
4725
|
});
|
|
3686
4726
|
ws.addEventListener("message", ev => {
|
|
3687
4727
|
// Identity guard: a delayed frame from a superseded socket must not act
|
|
@@ -3856,7 +4896,12 @@ export class TelegramNotificationDaemon {
|
|
|
3856
4896
|
}
|
|
3857
4897
|
|
|
3858
4898
|
private dropSession(session: SessionSocket, reason: string): void {
|
|
4899
|
+
// A dropped socket must lose authority before any teardown-triggered asynchronous
|
|
4900
|
+
// work can observe it. Its immutable lease token remains captured by queued work,
|
|
4901
|
+
// but is no longer usable.
|
|
4902
|
+
if (session.recoveryLease) session.recoveryLease = { ...session.recoveryLease, state: "rejected" };
|
|
3859
4903
|
const isCurrentSession = this.sessions.get(session.sessionId) === session;
|
|
4904
|
+
if (isCurrentSession) this.droppedSessions.add(session);
|
|
3860
4905
|
if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
|
|
3861
4906
|
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
3862
4907
|
if (session.pingTimer) {
|
|
@@ -3874,6 +4919,12 @@ export class TelegramNotificationDaemon {
|
|
|
3874
4919
|
if (isCurrentSession) {
|
|
3875
4920
|
this.#clearModelChoiceAliasesForSocket(session);
|
|
3876
4921
|
this.sessions.delete(session.sessionId);
|
|
4922
|
+
for (const [topicSessionId, owner] of this.legacyTopicOwners) {
|
|
4923
|
+
if (owner === session) this.legacyTopicOwners.delete(topicSessionId);
|
|
4924
|
+
}
|
|
4925
|
+
for (const [logicalSessionId, owner] of this.logicalSessionOwners) {
|
|
4926
|
+
if (owner === session) this.logicalSessionOwners.delete(logicalSessionId);
|
|
4927
|
+
}
|
|
3877
4928
|
}
|
|
3878
4929
|
|
|
3879
4930
|
for (const item of new Set(this.selectedAckPending.values())) {
|
|
@@ -3951,6 +5002,7 @@ export class TelegramNotificationDaemon {
|
|
|
3951
5002
|
void this.#terminalizeBtwTurn(requestId, pending).catch(() => undefined);
|
|
3952
5003
|
}
|
|
3953
5004
|
}
|
|
5005
|
+
|
|
3954
5006
|
#resumeBtwTurnsForSession(session: SessionSocket): void {
|
|
3955
5007
|
if (!session.ephemeralCapable || session.hostGeneration < 1 || session.ws.readyState !== WebSocket.OPEN) return;
|
|
3956
5008
|
const logicalSessionId = this.#logicalSessionId(session);
|
|
@@ -3961,6 +5013,7 @@ export class TelegramNotificationDaemon {
|
|
|
3961
5013
|
continue;
|
|
3962
5014
|
}
|
|
3963
5015
|
if (
|
|
5016
|
+
!this.#leaseTokenAllows(pending.socketLease) ||
|
|
3964
5017
|
pending.transportSessionId !== session.sessionId ||
|
|
3965
5018
|
pending.logicalSessionId !== logicalSessionId ||
|
|
3966
5019
|
pending.endpointDigest !== session.endpointDigest ||
|
|
@@ -3971,6 +5024,9 @@ export class TelegramNotificationDaemon {
|
|
|
3971
5024
|
}
|
|
3972
5025
|
}
|
|
3973
5026
|
#sendPendingBtwTurn(session: SessionSocket, requestId: string, pending: PendingBtwTurn): boolean {
|
|
5027
|
+
if (!this.#leaseTokenAllows(pending.socketLease) || this.sessions.get(session.sessionId) !== session)
|
|
5028
|
+
return false;
|
|
5029
|
+
if (pending.dispatchedSocketLeaseToken === pending.socketLease.token) return true;
|
|
3974
5030
|
try {
|
|
3975
5031
|
session.ws.send(
|
|
3976
5032
|
JSON.stringify({
|
|
@@ -3984,6 +5040,7 @@ export class TelegramNotificationDaemon {
|
|
|
3984
5040
|
messageId: pending.messageId,
|
|
3985
5041
|
}),
|
|
3986
5042
|
);
|
|
5043
|
+
pending.dispatchedSocketLeaseToken = pending.socketLease.token;
|
|
3987
5044
|
return true;
|
|
3988
5045
|
} catch {
|
|
3989
5046
|
return false;
|
|
@@ -4041,8 +5098,14 @@ export class TelegramNotificationDaemon {
|
|
|
4041
5098
|
parseMode?: typeof TELEGRAM_PARSE_MODE;
|
|
4042
5099
|
allowWhileStopping?: boolean;
|
|
4043
5100
|
signal?: AbortSignal;
|
|
5101
|
+
isAuthoritative?: () => boolean;
|
|
4044
5102
|
}): Promise<unknown> {
|
|
4045
|
-
if (
|
|
5103
|
+
if (
|
|
5104
|
+
(!input.allowWhileStopping && this.#stoppingBtw) ||
|
|
5105
|
+
this.#btwDeliveryAbort.signal.aborted ||
|
|
5106
|
+
(input.isAuthoritative && !input.isAuthoritative())
|
|
5107
|
+
)
|
|
5108
|
+
return undefined;
|
|
4046
5109
|
const signals = [this.#btwDeliveryAbort.signal, AbortSignal.timeout(30_000)];
|
|
4047
5110
|
if (input.signal) signals.unshift(input.signal);
|
|
4048
5111
|
return this.botApi.call(
|
|
@@ -4175,12 +5238,149 @@ export class TelegramNotificationDaemon {
|
|
|
4175
5238
|
return session.logicalSessionId ?? session.sessionId;
|
|
4176
5239
|
}
|
|
4177
5240
|
|
|
5241
|
+
#leaseAllows(session: SessionSocket, logicalSessionId = this.#logicalSessionId(session)): boolean {
|
|
5242
|
+
if (this.droppedSessions.has(session)) return false;
|
|
5243
|
+
const closedBinding = this.closedEndpointKeys.get(session.sessionId);
|
|
5244
|
+
if (
|
|
5245
|
+
closedBinding &&
|
|
5246
|
+
typeof session.endpointKey === "string" &&
|
|
5247
|
+
typeof session.endpointDigest === "string" &&
|
|
5248
|
+
closedBinding.endpointKey === session.endpointKey &&
|
|
5249
|
+
closedBinding.endpointDigest === session.endpointDigest &&
|
|
5250
|
+
closedBinding.endpointGeneration === session.hostGeneration
|
|
5251
|
+
)
|
|
5252
|
+
return false;
|
|
5253
|
+
if (!session.logicalSessionIdTrusted) return true;
|
|
5254
|
+
// Trusted leases require both the current transport socket and exact logical owner.
|
|
5255
|
+
if (this.sessions.get(session.sessionId) !== session) return false;
|
|
5256
|
+
const lease = session.recoveryLease;
|
|
5257
|
+
if (lease?.state !== "authorized" || lease.logicalSessionId !== logicalSessionId) return false;
|
|
5258
|
+
if (this.logicalSessionOwners.get(logicalSessionId) !== session) return false;
|
|
5259
|
+
const record = this.topics.get(logicalSessionId);
|
|
5260
|
+
return (
|
|
5261
|
+
lease.binding.endpointKey === session.endpointKey &&
|
|
5262
|
+
lease.binding.endpointDigest === session.endpointDigest &&
|
|
5263
|
+
lease.binding.endpointGeneration === session.hostGeneration &&
|
|
5264
|
+
(!record ||
|
|
5265
|
+
(record.authorityState !== "delete_pending" &&
|
|
5266
|
+
!record.bindingMalformed &&
|
|
5267
|
+
record.chatId === lease.binding.chatId &&
|
|
5268
|
+
record.endpointKey === lease.binding.endpointKey &&
|
|
5269
|
+
record.endpointDigest === lease.binding.endpointDigest &&
|
|
5270
|
+
record.endpointGeneration === lease.binding.endpointGeneration))
|
|
5271
|
+
);
|
|
5272
|
+
}
|
|
5273
|
+
|
|
5274
|
+
#leaseTokenAllows(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
|
|
5275
|
+
return socketLease.token === 0
|
|
5276
|
+
? !socketLease.session.logicalSessionIdTrusted && !this.droppedSessions.has(socketLease.session)
|
|
5277
|
+
: this.sessions.get(socketLease.session.sessionId) === socketLease.session &&
|
|
5278
|
+
socketLease.session.recoveryLease?.token === socketLease.token &&
|
|
5279
|
+
this.#leaseAllows(socketLease.session, socketLease.logicalSessionId);
|
|
5280
|
+
}
|
|
5281
|
+
|
|
5282
|
+
#deleteLeaseAllows(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
|
|
5283
|
+
if (socketLease.token === 0)
|
|
5284
|
+
return (
|
|
5285
|
+
this.sessions.get(socketLease.session.sessionId) === socketLease.session &&
|
|
5286
|
+
!this.droppedSessions.has(socketLease.session)
|
|
5287
|
+
);
|
|
5288
|
+
return (
|
|
5289
|
+
this.sessions.get(socketLease.session.sessionId) === socketLease.session &&
|
|
5290
|
+
socketLease.session.recoveryLease?.token === socketLease.token &&
|
|
5291
|
+
socketLease.session.recoveryLease.state === "authorized" &&
|
|
5292
|
+
socketLease.session.recoveryLease.logicalSessionId === socketLease.logicalSessionId &&
|
|
5293
|
+
this.logicalSessionOwners.get(socketLease.logicalSessionId) === socketLease.session
|
|
5294
|
+
);
|
|
5295
|
+
}
|
|
5296
|
+
|
|
5297
|
+
/**
|
|
5298
|
+
* An eager transport-topic create may complete after authenticated replay
|
|
5299
|
+
* authorizes that exact transport as its own logical session. Preserve that
|
|
5300
|
+
* handoff, but never revive a replaced socket or a rekeyed transport.
|
|
5301
|
+
*/
|
|
5302
|
+
#isEagerCreationHandoff(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
|
|
5303
|
+
const { session, logicalSessionId } = socketLease;
|
|
5304
|
+
const lease = session.recoveryLease;
|
|
5305
|
+
return (
|
|
5306
|
+
socketLease.token === 0 &&
|
|
5307
|
+
logicalSessionId === session.sessionId &&
|
|
5308
|
+
session.logicalSessionIdTrusted &&
|
|
5309
|
+
this.sessions.get(session.sessionId) === session &&
|
|
5310
|
+
lease?.state === "authorized" &&
|
|
5311
|
+
lease.logicalSessionId === logicalSessionId &&
|
|
5312
|
+
this.logicalSessionOwners.get(logicalSessionId) === session &&
|
|
5313
|
+
lease.binding.chatId === String(this.opts.chatId) &&
|
|
5314
|
+
lease.binding.endpointKey === session.endpointKey &&
|
|
5315
|
+
lease.binding.endpointDigest === session.endpointDigest &&
|
|
5316
|
+
lease.binding.endpointGeneration === session.hostGeneration
|
|
5317
|
+
);
|
|
5318
|
+
}
|
|
5319
|
+
|
|
5320
|
+
#creationLeaseAllows(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): boolean {
|
|
5321
|
+
return this.#leaseTokenAllows(socketLease) || this.#isEagerCreationHandoff(socketLease);
|
|
5322
|
+
}
|
|
5323
|
+
|
|
5324
|
+
async #awaitCreationLeaseAuthority(socketLease: {
|
|
5325
|
+
session: SessionSocket;
|
|
5326
|
+
token: number;
|
|
5327
|
+
logicalSessionId: string;
|
|
5328
|
+
}): Promise<boolean> {
|
|
5329
|
+
if (this.#creationLeaseAllows(socketLease)) return true;
|
|
5330
|
+
if (
|
|
5331
|
+
socketLease.token === 0 &&
|
|
5332
|
+
socketLease.session.logicalSessionIdTrusted &&
|
|
5333
|
+
socketLease.session.recoveryLease?.state === "pending" &&
|
|
5334
|
+
this.sessions.get(socketLease.session.sessionId) === socketLease.session
|
|
5335
|
+
)
|
|
5336
|
+
await this.recoveryBindingClaimQueue;
|
|
5337
|
+
return this.#creationLeaseAllows(socketLease);
|
|
5338
|
+
}
|
|
5339
|
+
#socketLease(
|
|
5340
|
+
session: SessionSocket,
|
|
5341
|
+
logicalSessionId = this.#logicalSessionId(session),
|
|
5342
|
+
): { session: SessionSocket; token: number; logicalSessionId: string } | undefined {
|
|
5343
|
+
const lease = session.recoveryLease;
|
|
5344
|
+
return lease && this.#leaseAllows(session, logicalSessionId)
|
|
5345
|
+
? { session, token: lease.token, logicalSessionId }
|
|
5346
|
+
: session.logicalSessionIdTrusted
|
|
5347
|
+
? undefined
|
|
5348
|
+
: { session, token: 0, logicalSessionId };
|
|
5349
|
+
}
|
|
5350
|
+
|
|
5351
|
+
#authorizeLease(session: SessionSocket, logicalSessionId: string, binding: TopicEndpointBinding): void {
|
|
5352
|
+
const previousSessionId = this.#logicalSessionId(session);
|
|
5353
|
+
if (previousSessionId !== logicalSessionId && this.logicalSessionOwners.get(previousSessionId) === session)
|
|
5354
|
+
this.logicalSessionOwners.delete(previousSessionId);
|
|
5355
|
+
const previousOwner = this.logicalSessionOwners.get(logicalSessionId);
|
|
5356
|
+
if (previousOwner && previousOwner !== session && previousOwner.recoveryLease)
|
|
5357
|
+
previousOwner.recoveryLease = { ...previousOwner.recoveryLease, state: "rejected" };
|
|
5358
|
+
this.logicalSessionOwners.set(logicalSessionId, session);
|
|
5359
|
+
this.preservedInitiatorTopics.delete(logicalSessionId);
|
|
5360
|
+
session.logicalSessionId = logicalSessionId;
|
|
5361
|
+
session.recoveryLease = { state: "authorized", logicalSessionId, binding, token: this.nextSocketLeaseToken++ };
|
|
5362
|
+
}
|
|
5363
|
+
|
|
4178
5364
|
#clearModelChoiceAliases(sessionId: string): void {
|
|
4179
5365
|
for (const [alias, route] of this.#modelChoiceAliases) {
|
|
4180
5366
|
if (route.sessionId === sessionId) this.#modelChoiceAliases.delete(alias);
|
|
4181
5367
|
}
|
|
4182
5368
|
}
|
|
4183
5369
|
|
|
5370
|
+
async #revokeAskAuthority(sessionId: string): Promise<void> {
|
|
5371
|
+
for (const [alias, route] of this.aliasTable.entries()) {
|
|
5372
|
+
if (route.sessionId === sessionId) this.aliasTable.delete(alias);
|
|
5373
|
+
}
|
|
5374
|
+
for (const session of this.sessions.values()) {
|
|
5375
|
+
if (session.sessionId === sessionId || this.#logicalSessionId(session) === sessionId) {
|
|
5376
|
+
for (const [actionId, pending] of session.pending) {
|
|
5377
|
+
if (pending.sessionId === sessionId) session.pending.delete(actionId);
|
|
5378
|
+
}
|
|
5379
|
+
}
|
|
5380
|
+
}
|
|
5381
|
+
await this.persistAliases();
|
|
5382
|
+
}
|
|
5383
|
+
|
|
4184
5384
|
#clearModelChoiceAliasesForSocket(session: SessionSocket): void {
|
|
4185
5385
|
for (const [alias, route] of this.#modelChoiceAliases) {
|
|
4186
5386
|
if (route.session === session) this.#modelChoiceAliases.delete(alias);
|
|
@@ -4224,8 +5424,8 @@ export class TelegramNotificationDaemon {
|
|
|
4224
5424
|
"reasoning_summary",
|
|
4225
5425
|
]);
|
|
4226
5426
|
|
|
4227
|
-
/** Rekey
|
|
4228
|
-
#updateLogicalSessionForThreadedFrame(session: SessionSocket, msg: Record<string, unknown>): void {
|
|
5427
|
+
/** Rekey only after authenticated replay, except legacy config updates which are transport-local. */
|
|
5428
|
+
async #updateLogicalSessionForThreadedFrame(session: SessionSocket, msg: Record<string, unknown>): Promise<void> {
|
|
4229
5429
|
if (
|
|
4230
5430
|
typeof msg.type !== "string" ||
|
|
4231
5431
|
!TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type) ||
|
|
@@ -4235,10 +5435,184 @@ export class TelegramNotificationDaemon {
|
|
|
4235
5435
|
(msg.type !== "config_update" && !renderThreadedFrame(msg))
|
|
4236
5436
|
)
|
|
4237
5437
|
return;
|
|
4238
|
-
|
|
4239
|
-
this.#
|
|
4240
|
-
|
|
4241
|
-
|
|
5438
|
+
if (!session.logicalSessionIdTrusted) return;
|
|
5439
|
+
await this.#recoverTopicBinding(session, msg.sessionId, msg.type === "config_update");
|
|
5440
|
+
}
|
|
5441
|
+
|
|
5442
|
+
#endpointBinding(session: SessionSocket): TopicEndpointBinding {
|
|
5443
|
+
return {
|
|
5444
|
+
chatId: String(this.opts.chatId),
|
|
5445
|
+
endpointKey: session.endpointKey,
|
|
5446
|
+
endpointDigest: session.endpointDigest,
|
|
5447
|
+
endpointGeneration: session.hostGeneration,
|
|
5448
|
+
};
|
|
5449
|
+
}
|
|
5450
|
+
|
|
5451
|
+
#endpointAuthority(binding: TopicEndpointBinding, excludedSession?: SessionSocket) {
|
|
5452
|
+
const tombstoned = [...this.closedEndpointKeys.values()].some(
|
|
5453
|
+
closed =>
|
|
5454
|
+
closed.chatId === binding.chatId &&
|
|
5455
|
+
closed.endpointKey === binding.endpointKey &&
|
|
5456
|
+
closed.endpointDigest === binding.endpointDigest,
|
|
5457
|
+
);
|
|
5458
|
+
if (tombstoned) return { state: "ambiguous" as const };
|
|
5459
|
+
const authority = this.topics.endpointAuthority(binding, excludedSession);
|
|
5460
|
+
const competingLiveClaim = [...this.sessions.values()].some(
|
|
5461
|
+
session =>
|
|
5462
|
+
session !== excludedSession &&
|
|
5463
|
+
session.endpointKey === binding.endpointKey &&
|
|
5464
|
+
session.endpointDigest === binding.endpointDigest,
|
|
5465
|
+
);
|
|
5466
|
+
const competingRecoveryClaim = [...this.sessions.values()].some(
|
|
5467
|
+
session =>
|
|
5468
|
+
session !== excludedSession &&
|
|
5469
|
+
session.recoveryLease?.state === "pending" &&
|
|
5470
|
+
session.recoveryLease.binding.chatId === binding.chatId &&
|
|
5471
|
+
session.recoveryLease.binding.endpointKey === binding.endpointKey &&
|
|
5472
|
+
session.recoveryLease.binding.endpointDigest === binding.endpointDigest,
|
|
5473
|
+
);
|
|
5474
|
+
return competingLiveClaim || competingRecoveryClaim ? { state: "ambiguous" as const } : authority;
|
|
5475
|
+
}
|
|
5476
|
+
|
|
5477
|
+
#ownsLiveOpenEndpoint(session: SessionSocket, binding: TopicEndpointBinding): boolean {
|
|
5478
|
+
return (
|
|
5479
|
+
this.sessions.get(session.sessionId) === session &&
|
|
5480
|
+
session.ws.readyState === WebSocket.OPEN &&
|
|
5481
|
+
binding.endpointKey === session.endpointKey &&
|
|
5482
|
+
binding.endpointDigest === session.endpointDigest &&
|
|
5483
|
+
binding.endpointGeneration === session.hostGeneration
|
|
5484
|
+
);
|
|
5485
|
+
}
|
|
5486
|
+
|
|
5487
|
+
#activeEndpointKeysFor(logicalSessionId: string, claimant: SessionSocket): Set<string> {
|
|
5488
|
+
const keys = new Set<string>();
|
|
5489
|
+
for (const session of this.sessions.values()) {
|
|
5490
|
+
if (
|
|
5491
|
+
session !== claimant &&
|
|
5492
|
+
this.#logicalSessionId(session) === logicalSessionId &&
|
|
5493
|
+
this.#leaseAllows(session, logicalSessionId)
|
|
5494
|
+
)
|
|
5495
|
+
keys.add(session.endpointKey);
|
|
5496
|
+
}
|
|
5497
|
+
return keys;
|
|
5498
|
+
}
|
|
5499
|
+
|
|
5500
|
+
#withRecoveryBindingClaim<T>(claim: () => Promise<T>): Promise<T> {
|
|
5501
|
+
const pending = this.recoveryBindingClaimQueue.then(claim, claim);
|
|
5502
|
+
this.recoveryBindingClaimQueue = pending.then(
|
|
5503
|
+
() => undefined,
|
|
5504
|
+
() => undefined,
|
|
5505
|
+
);
|
|
5506
|
+
return pending;
|
|
5507
|
+
}
|
|
5508
|
+
|
|
5509
|
+
async #recoverTopicBinding(
|
|
5510
|
+
session: SessionSocket,
|
|
5511
|
+
candidateSessionId = this.#logicalSessionId(session),
|
|
5512
|
+
preserveTransportTopic = false,
|
|
5513
|
+
allowEndpointRotation = false,
|
|
5514
|
+
identitylessAdmission: "bootstrap" | "resume" | undefined = undefined,
|
|
5515
|
+
): Promise<boolean> {
|
|
5516
|
+
if (!session.logicalSessionIdTrusted) return false;
|
|
5517
|
+
const binding = this.#endpointBinding(session);
|
|
5518
|
+
const pendingToken = this.nextSocketLeaseToken++;
|
|
5519
|
+
session.recoveryLease = { state: "pending", logicalSessionId: candidateSessionId, binding, token: pendingToken };
|
|
5520
|
+
const claim = await this.#withRecoveryBindingClaim(async () => {
|
|
5521
|
+
const existing = this.topics.get(candidateSessionId);
|
|
5522
|
+
const hadDurableTopic = existing !== undefined;
|
|
5523
|
+
const previousBinding = existing
|
|
5524
|
+
? {
|
|
5525
|
+
chatId: existing.chatId,
|
|
5526
|
+
endpointKey: existing.endpointKey,
|
|
5527
|
+
endpointDigest: existing.endpointDigest,
|
|
5528
|
+
endpointGeneration: existing.endpointGeneration,
|
|
5529
|
+
endpointIncarnation: existing.endpointIncarnation,
|
|
5530
|
+
}
|
|
5531
|
+
: undefined;
|
|
5532
|
+
const outcome = await this.#persistTopicMutation(
|
|
5533
|
+
() =>
|
|
5534
|
+
this.topics.get(candidateSessionId)
|
|
5535
|
+
? this.topics.bindEndpoint(
|
|
5536
|
+
candidateSessionId,
|
|
5537
|
+
binding,
|
|
5538
|
+
this.#activeEndpointKeysFor(candidateSessionId, session),
|
|
5539
|
+
allowEndpointRotation,
|
|
5540
|
+
)
|
|
5541
|
+
: "unchanged",
|
|
5542
|
+
() => {
|
|
5543
|
+
if (previousBinding) this.topics.restoreEndpointBinding(candidateSessionId, binding, previousBinding);
|
|
5544
|
+
},
|
|
5545
|
+
).catch(() => "rejected" as const);
|
|
5546
|
+
if (outcome === "rejected") {
|
|
5547
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
5548
|
+
session.recoveryLease = {
|
|
5549
|
+
state: "rejected",
|
|
5550
|
+
logicalSessionId: candidateSessionId,
|
|
5551
|
+
binding,
|
|
5552
|
+
token: pendingToken,
|
|
5553
|
+
};
|
|
5554
|
+
return undefined;
|
|
5555
|
+
}
|
|
5556
|
+
const endpointAuthority = this.#endpointAuthority(binding, session);
|
|
5557
|
+
const identitylessAdmissionAllows =
|
|
5558
|
+
identitylessAdmission === undefined ||
|
|
5559
|
+
(identitylessAdmission === "bootstrap"
|
|
5560
|
+
? (endpointAuthority.state === "none" && !this.topics.get(candidateSessionId)) ||
|
|
5561
|
+
(endpointAuthority.state === "unique" &&
|
|
5562
|
+
endpointAuthority.sessionId === candidateSessionId &&
|
|
5563
|
+
this.topics.matchesEndpoint(candidateSessionId, binding))
|
|
5564
|
+
: endpointAuthority.state === "unique" &&
|
|
5565
|
+
endpointAuthority.sessionId === candidateSessionId &&
|
|
5566
|
+
this.topics.matchesEndpoint(candidateSessionId, binding));
|
|
5567
|
+
if (
|
|
5568
|
+
session.recoveryLease?.token !== pendingToken ||
|
|
5569
|
+
session.recoveryLease.state !== "pending" ||
|
|
5570
|
+
!this.#ownsLiveOpenEndpoint(session, binding) ||
|
|
5571
|
+
!identitylessAdmissionAllows
|
|
5572
|
+
) {
|
|
5573
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
5574
|
+
session.recoveryLease = {
|
|
5575
|
+
state: "rejected",
|
|
5576
|
+
logicalSessionId: candidateSessionId,
|
|
5577
|
+
binding,
|
|
5578
|
+
token: pendingToken,
|
|
5579
|
+
};
|
|
5580
|
+
return undefined;
|
|
5581
|
+
}
|
|
5582
|
+
const previousSessionId = this.#logicalSessionId(session);
|
|
5583
|
+
if (previousSessionId !== candidateSessionId) await this.#terminalizeBtwTurnsForSession(session, true);
|
|
5584
|
+
if (!this.#ownsLiveOpenEndpoint(session, binding)) {
|
|
5585
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
5586
|
+
session.recoveryLease = {
|
|
5587
|
+
state: "rejected",
|
|
5588
|
+
logicalSessionId: candidateSessionId,
|
|
5589
|
+
binding,
|
|
5590
|
+
token: pendingToken,
|
|
5591
|
+
};
|
|
5592
|
+
return undefined;
|
|
5593
|
+
}
|
|
5594
|
+
this.#authorizeLease(session, candidateSessionId, binding);
|
|
5595
|
+
return { previousSessionId, hadDurableTopic };
|
|
5596
|
+
});
|
|
5597
|
+
if (!claim) return false;
|
|
5598
|
+
const { previousSessionId } = claim;
|
|
5599
|
+
if (preserveTransportTopic && previousSessionId !== candidateSessionId) {
|
|
5600
|
+
this.legacyTopicOwners.set(previousSessionId, session);
|
|
5601
|
+
this.preservedInitiatorTopics.add(previousSessionId);
|
|
5602
|
+
}
|
|
5603
|
+
if (candidateSessionId === session.sessionId)
|
|
5604
|
+
void (async () => {
|
|
5605
|
+
const topic = this.topics.get(candidateSessionId);
|
|
5606
|
+
if (!topic || topic.authorityState === "delete_pending" || topic.bindingMalformed) return;
|
|
5607
|
+
const topicLease = this.topicAuthorityLeaseFromRegistry(candidateSessionId);
|
|
5608
|
+
if (topicLease?.topicId === topic.topicId)
|
|
5609
|
+
await this.flushPendingThreadedFrames(candidateSessionId, topicLease);
|
|
5610
|
+
})().catch(err =>
|
|
5611
|
+
logger.warn(
|
|
5612
|
+
`notifications: Telegram recovered topic reattach flush failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
|
|
5613
|
+
),
|
|
5614
|
+
);
|
|
5615
|
+
return true;
|
|
4242
5616
|
}
|
|
4243
5617
|
|
|
4244
5618
|
private topicNameFor(sessionId: string, msg: { title?: unknown; repo?: unknown; branch?: unknown }): string {
|
|
@@ -4320,13 +5694,6 @@ export class TelegramNotificationDaemon {
|
|
|
4320
5694
|
return undefined;
|
|
4321
5695
|
}
|
|
4322
5696
|
|
|
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
5697
|
private toolActivityOwner(session: SessionSocket, msg: Record<string, unknown>): ToolActivityOwner | undefined {
|
|
4331
5698
|
if (msg.type !== "tool_activity") return undefined;
|
|
4332
5699
|
const toolCallId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
|
|
@@ -4334,7 +5701,7 @@ export class TelegramNotificationDaemon {
|
|
|
4334
5701
|
const phase = typeof msg.phase === "string" ? msg.phase : undefined;
|
|
4335
5702
|
if (!toolCallId || !toolName || !phase) return undefined;
|
|
4336
5703
|
return {
|
|
4337
|
-
sessionId: session
|
|
5704
|
+
sessionId: this.#logicalSessionId(session),
|
|
4338
5705
|
toolCallId,
|
|
4339
5706
|
toolName,
|
|
4340
5707
|
endpointDigest: session.endpointDigest,
|
|
@@ -4345,7 +5712,8 @@ export class TelegramNotificationDaemon {
|
|
|
4345
5712
|
|
|
4346
5713
|
private toolActivityAuthorityIsCurrent(toolActivity: ToolActivityOwner): boolean {
|
|
4347
5714
|
if (this.revokedToolEndpoints.has(toolActivity.endpointDigest)) return false;
|
|
4348
|
-
const session =
|
|
5715
|
+
const session =
|
|
5716
|
+
this.logicalSessionOwners.get(toolActivity.sessionId) ?? this.sessions.get(toolActivity.sessionId);
|
|
4349
5717
|
if (toolActivity.endpointDigest === undefined) return session === undefined;
|
|
4350
5718
|
return session === toolActivity.session && session.endpointDigest === toolActivity.endpointDigest;
|
|
4351
5719
|
}
|
|
@@ -4354,12 +5722,18 @@ export class TelegramNotificationDaemon {
|
|
|
4354
5722
|
send: ThreadedSend,
|
|
4355
5723
|
topicLease: TopicAuthorityLease,
|
|
4356
5724
|
toolActivity?: ToolActivityOwner,
|
|
5725
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
4357
5726
|
): Promise<void> {
|
|
4358
5727
|
this.submitPool({
|
|
4359
5728
|
sessionId,
|
|
4360
5729
|
lane: send.lane,
|
|
4361
5730
|
coalesceKey: send.coalesceKey,
|
|
4362
|
-
payload: {
|
|
5731
|
+
payload: {
|
|
5732
|
+
send,
|
|
5733
|
+
topicLease,
|
|
5734
|
+
...(socketLease ? { socketLease } : {}),
|
|
5735
|
+
...(toolActivity ? { toolActivity } : {}),
|
|
5736
|
+
},
|
|
4363
5737
|
});
|
|
4364
5738
|
await this.flushPool();
|
|
4365
5739
|
}
|
|
@@ -4375,14 +5749,15 @@ export class TelegramNotificationDaemon {
|
|
|
4375
5749
|
|
|
4376
5750
|
private topicAuthorityLeaseFromRegistry(sessionId: string): TopicAuthorityLease | undefined {
|
|
4377
5751
|
const topic = this.topics.get(sessionId);
|
|
4378
|
-
if (!topic ||
|
|
5752
|
+
if (!topic || !this.topics.isActiveUnambiguous(sessionId) || topic.bindingMalformed) return undefined;
|
|
4379
5753
|
return { sessionId, topicId: topic.topicId, authorityEpoch: topic.authorityEpoch ?? 0 };
|
|
4380
5754
|
}
|
|
4381
5755
|
|
|
4382
5756
|
private topicLeaseIsCurrent(lease: TopicAuthorityLease): boolean {
|
|
4383
5757
|
const topic = this.topics.get(lease.sessionId);
|
|
4384
5758
|
return (
|
|
4385
|
-
|
|
5759
|
+
this.topics.isActiveUnambiguous(lease.sessionId) &&
|
|
5760
|
+
!topic?.bindingMalformed &&
|
|
4386
5761
|
topic?.topicId === lease.topicId &&
|
|
4387
5762
|
(topic.authorityEpoch ?? 0) === lease.authorityEpoch
|
|
4388
5763
|
);
|
|
@@ -4427,26 +5802,29 @@ export class TelegramNotificationDaemon {
|
|
|
4427
5802
|
msg: Record<string, unknown>,
|
|
4428
5803
|
toolActivity?: ToolActivityOwner,
|
|
4429
5804
|
): void {
|
|
4430
|
-
const
|
|
5805
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
5806
|
+
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
5807
|
+
if (!socketLease) return;
|
|
5808
|
+
const frames = this.pendingThreadedFrames.get(logicalSessionId) ?? [];
|
|
4431
5809
|
frames.push({
|
|
4432
5810
|
send,
|
|
4433
5811
|
msg,
|
|
4434
|
-
|
|
5812
|
+
logicalSessionId,
|
|
5813
|
+
socketLease,
|
|
4435
5814
|
...(toolActivity ? { toolActivity } : {}),
|
|
4436
5815
|
});
|
|
4437
5816
|
if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
|
|
4438
|
-
this.pendingThreadedFrames.set(
|
|
5817
|
+
this.pendingThreadedFrames.set(logicalSessionId, frames);
|
|
4439
5818
|
}
|
|
4440
5819
|
|
|
4441
5820
|
private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
|
|
4442
5821
|
const frames = this.pendingThreadedFrames.get(sessionId);
|
|
4443
5822
|
if (!frames || frames.length === 0) return;
|
|
4444
5823
|
this.pendingThreadedFrames.delete(sessionId);
|
|
4445
|
-
const session = this.sessions.get(sessionId);
|
|
4446
5824
|
for (const frame of frames) {
|
|
4447
|
-
if (
|
|
5825
|
+
if (frame.logicalSessionId !== sessionId || !this.#leaseTokenAllows(frame.socketLease)) continue;
|
|
4448
5826
|
if (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled === false) continue;
|
|
4449
|
-
await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity);
|
|
5827
|
+
await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, frame.socketLease);
|
|
4450
5828
|
}
|
|
4451
5829
|
}
|
|
4452
5830
|
|
|
@@ -4456,36 +5834,163 @@ export class TelegramNotificationDaemon {
|
|
|
4456
5834
|
* `undefined`; callers then flat-deliver to a private paired chat (with a
|
|
4457
5835
|
* one-time nudge) or drop fail-closed for a non-private chat.
|
|
4458
5836
|
*/
|
|
4459
|
-
private async ensureTopic(
|
|
5837
|
+
private async ensureTopic(
|
|
5838
|
+
sessionId: string,
|
|
5839
|
+
name: string,
|
|
5840
|
+
session?: SessionSocket,
|
|
5841
|
+
creationLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
5842
|
+
): Promise<string | undefined> {
|
|
4460
5843
|
if (!(await this.pairedChatIsPrivate())) return undefined;
|
|
5844
|
+
if (session && sessionId === session.sessionId && this.#logicalSessionId(session) !== sessionId) return undefined;
|
|
5845
|
+
const capturedCreationLease = creationLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
|
|
5846
|
+
if (session?.logicalSessionIdTrusted && !capturedCreationLease) return undefined;
|
|
4461
5847
|
const existing = this.topics.get(sessionId);
|
|
4462
|
-
if (existing?.authorityState === "delete_pending") return undefined;
|
|
5848
|
+
if (existing?.authorityState === "delete_pending" || existing?.bindingMalformed) return undefined;
|
|
4463
5849
|
if (existing) return existing.topicId;
|
|
5850
|
+
if (
|
|
5851
|
+
session &&
|
|
5852
|
+
sessionId !== session.sessionId &&
|
|
5853
|
+
(!session.logicalSessionIdTrusted || sessionId !== this.#logicalSessionId(session))
|
|
5854
|
+
)
|
|
5855
|
+
return undefined;
|
|
5856
|
+
if (capturedCreationLease && !this.#leaseTokenAllows(capturedCreationLease)) return undefined;
|
|
5857
|
+
const creationBinding = session ? this.#endpointBinding(session) : undefined;
|
|
5858
|
+
const creationLeaseEpoch = this.topics.authorityEpoch(sessionId);
|
|
5859
|
+
let acceptedTopicId: string | undefined;
|
|
5860
|
+
let acceptedTopicCompensated = false;
|
|
5861
|
+
let acceptedTopicDeleteAttempted = false;
|
|
4464
5862
|
try {
|
|
4465
5863
|
const rec = await this.topics.getOrCreateTopic(
|
|
4466
5864
|
sessionId,
|
|
4467
5865
|
async () => {
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
});
|
|
5866
|
+
if (capturedCreationLease && !this.#leaseTokenAllows(capturedCreationLease))
|
|
5867
|
+
throw new Error("topic authority was revoked during creation");
|
|
5868
|
+
const res = await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name });
|
|
4472
5869
|
if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
|
|
4473
5870
|
const tid = (res as { result?: { message_thread_id?: unknown } }).result?.message_thread_id;
|
|
4474
5871
|
if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0)
|
|
4475
5872
|
throw new Error("createForumTopic: invalid message_thread_id");
|
|
4476
|
-
|
|
5873
|
+
acceptedTopicId = String(tid);
|
|
5874
|
+
if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
|
|
5875
|
+
if (
|
|
5876
|
+
!this.topics.fenceAcceptedCreateForLease(
|
|
5877
|
+
sessionId,
|
|
5878
|
+
acceptedTopicId,
|
|
5879
|
+
creationLeaseEpoch,
|
|
5880
|
+
this.opts.now,
|
|
5881
|
+
name,
|
|
5882
|
+
creationBinding,
|
|
5883
|
+
)
|
|
5884
|
+
)
|
|
5885
|
+
throw new Error("topic authority was revoked during creation");
|
|
5886
|
+
try {
|
|
5887
|
+
await this.persistTopics();
|
|
5888
|
+
} finally {
|
|
5889
|
+
acceptedTopicDeleteAttempted = true;
|
|
5890
|
+
const deletion = await this.botApi.call("deleteForumTopic", {
|
|
5891
|
+
chat_id: this.opts.chatId,
|
|
5892
|
+
message_thread_id: tid,
|
|
5893
|
+
});
|
|
5894
|
+
acceptedTopicCompensated = topicDeleteSettled(deletion);
|
|
5895
|
+
|
|
5896
|
+
if (topicDeleteSettled(deletion)) {
|
|
5897
|
+
this.topics.settleDelete(sessionId, acceptedTopicId);
|
|
5898
|
+
await this.persistTopics();
|
|
5899
|
+
} else {
|
|
5900
|
+
this.#superviseCompensationFence(sessionId);
|
|
5901
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
5902
|
+
}
|
|
5903
|
+
}
|
|
5904
|
+
throw new Error("topic authority was revoked during creation");
|
|
5905
|
+
}
|
|
5906
|
+
return acceptedTopicId;
|
|
4477
5907
|
},
|
|
4478
5908
|
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
5909
|
name,
|
|
5910
|
+
creationBinding,
|
|
5911
|
+
() => this.persistTopics(),
|
|
5912
|
+
session,
|
|
4484
5913
|
);
|
|
4485
|
-
|
|
5914
|
+
// getOrCreateTopic deduplicates callers, so an accepted create can be
|
|
5915
|
+
// observed by a successor after its initiating socket was revoked. Check
|
|
5916
|
+
// the immutable lease again before exposing that record to frame delivery.
|
|
5917
|
+
if (capturedCreationLease && this.#isEagerCreationHandoff(capturedCreationLease)) {
|
|
5918
|
+
const binding = this.#endpointBinding(capturedCreationLease.session);
|
|
5919
|
+
if (
|
|
5920
|
+
this.topics.bindEndpoint(
|
|
5921
|
+
sessionId,
|
|
5922
|
+
binding,
|
|
5923
|
+
this.#activeEndpointKeysFor(sessionId, capturedCreationLease.session),
|
|
5924
|
+
true,
|
|
5925
|
+
) !== "unchanged"
|
|
5926
|
+
)
|
|
5927
|
+
await this.persistTopics();
|
|
5928
|
+
}
|
|
5929
|
+
if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
|
|
5930
|
+
if (
|
|
5931
|
+
this.topics.fenceAcceptedCreateForLease(
|
|
5932
|
+
sessionId,
|
|
5933
|
+
rec.topicId,
|
|
5934
|
+
creationLeaseEpoch,
|
|
5935
|
+
this.opts.now,
|
|
5936
|
+
name,
|
|
5937
|
+
creationBinding,
|
|
5938
|
+
)
|
|
5939
|
+
)
|
|
5940
|
+
await this.deleteTopic(sessionId, undefined, true);
|
|
5941
|
+
return undefined;
|
|
5942
|
+
}
|
|
4486
5943
|
return rec.topicId;
|
|
4487
5944
|
} catch (err) {
|
|
4488
5945
|
if (err instanceof ThreadedModeCapabilityRefusal) return undefined;
|
|
5946
|
+
if (
|
|
5947
|
+
acceptedTopicId &&
|
|
5948
|
+
!acceptedTopicCompensated &&
|
|
5949
|
+
!acceptedTopicDeleteAttempted &&
|
|
5950
|
+
this.topics.get(sessionId)?.authorityState !== "delete_pending"
|
|
5951
|
+
) {
|
|
5952
|
+
// A failed initial commit must never make compensation conditional on
|
|
5953
|
+
// successfully publishing its fence.
|
|
5954
|
+
if (
|
|
5955
|
+
this.topics.fenceAcceptedCreateForLease(
|
|
5956
|
+
sessionId,
|
|
5957
|
+
acceptedTopicId,
|
|
5958
|
+
creationLeaseEpoch,
|
|
5959
|
+
this.opts.now,
|
|
5960
|
+
name,
|
|
5961
|
+
creationBinding,
|
|
5962
|
+
)
|
|
5963
|
+
) {
|
|
5964
|
+
try {
|
|
5965
|
+
await this.#persistTopicsWithRetry();
|
|
5966
|
+
} catch {
|
|
5967
|
+
this.#superviseCompensationFence(sessionId);
|
|
5968
|
+
}
|
|
5969
|
+
|
|
5970
|
+
try {
|
|
5971
|
+
const deletion = await this.botApi.call("deleteForumTopic", {
|
|
5972
|
+
chat_id: this.opts.chatId,
|
|
5973
|
+
message_thread_id: Number(acceptedTopicId),
|
|
5974
|
+
});
|
|
5975
|
+
if (topicDeleteSettled(deletion)) {
|
|
5976
|
+
this.topics.settleDelete(sessionId, acceptedTopicId);
|
|
5977
|
+
await this.persistTopics();
|
|
5978
|
+
} else {
|
|
5979
|
+
this.#superviseCompensationFence(sessionId);
|
|
5980
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
5981
|
+
}
|
|
5982
|
+
} catch {
|
|
5983
|
+
this.#superviseCompensationFence(sessionId);
|
|
5984
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
5985
|
+
}
|
|
5986
|
+
}
|
|
5987
|
+
}
|
|
5988
|
+
if (acceptedTopicId && !acceptedTopicCompensated && acceptedTopicDeleteAttempted) {
|
|
5989
|
+
this.#superviseCompensationFence(sessionId);
|
|
5990
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
5991
|
+
}
|
|
5992
|
+
if (session && err instanceof Error && err.message === "topic authority was revoked during creation")
|
|
5993
|
+
return undefined;
|
|
4489
5994
|
logger.warn(
|
|
4490
5995
|
`notifications: Telegram topic creation failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
|
|
4491
5996
|
);
|
|
@@ -4499,24 +6004,39 @@ export class TelegramNotificationDaemon {
|
|
|
4499
6004
|
}
|
|
4500
6005
|
|
|
4501
6006
|
private async observeOrphanedTopic(sessionId: string): Promise<void> {
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
6007
|
+
await this.#withRecoveryBindingClaim(async () => {
|
|
6008
|
+
const owner = this.logicalSessionOwners.get(sessionId);
|
|
6009
|
+
if (owner && this.#leaseAllows(owner, sessionId)) {
|
|
6010
|
+
if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
|
|
6011
|
+
return;
|
|
6012
|
+
}
|
|
6013
|
+
if (this.topics.markOrphaned(sessionId, this.runtime.now())) await this.persistTopics();
|
|
6014
|
+
if (!this.topicPastOrphanGrace(sessionId)) return;
|
|
6015
|
+
const currentOwner = this.logicalSessionOwners.get(sessionId);
|
|
6016
|
+
if (currentOwner && this.#leaseAllows(currentOwner, sessionId)) return;
|
|
6017
|
+
await this.deleteTopic(sessionId);
|
|
6018
|
+
});
|
|
4505
6019
|
}
|
|
4506
6020
|
|
|
4507
6021
|
/** Best-effort delete of a session topic once its local notification endpoint shuts down. */
|
|
4508
|
-
private async deleteTopic(
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
6022
|
+
private async deleteTopic(
|
|
6023
|
+
sessionId: string,
|
|
6024
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
6025
|
+
deleteFenceAlreadyPublished = false,
|
|
6026
|
+
): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> {
|
|
6027
|
+
const deleteSnapshot = this.topics.captureDeleteAuthority(sessionId);
|
|
6028
|
+
let record = deleteFenceAlreadyPublished ? this.topics.get(sessionId) : this.topics.beginDelete(sessionId);
|
|
6029
|
+
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
4512
6030
|
await this.persistTopics();
|
|
6031
|
+
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
6032
|
+
await this.#revokeAskAuthority(sessionId);
|
|
6033
|
+
this.deleteMessageRoutes(sessionId);
|
|
6034
|
+
this.#clearModelChoiceAliases(sessionId);
|
|
4513
6035
|
if (!record) {
|
|
4514
6036
|
await this.topics.awaitInflight(sessionId);
|
|
4515
6037
|
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
6038
|
await this.persistTopics();
|
|
4519
|
-
if (!record) return;
|
|
6039
|
+
if (!record) return "settled";
|
|
4520
6040
|
}
|
|
4521
6041
|
const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
|
|
4522
6042
|
for (const item of removed) {
|
|
@@ -4526,11 +6046,12 @@ export class TelegramNotificationDaemon {
|
|
|
4526
6046
|
}
|
|
4527
6047
|
try {
|
|
4528
6048
|
await this.flushPool();
|
|
6049
|
+
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
4529
6050
|
const res = (await this.botApi.call("deleteForumTopic", {
|
|
4530
6051
|
chat_id: this.opts.chatId,
|
|
4531
6052
|
message_thread_id: Number(record.topicId),
|
|
4532
6053
|
})) as { ok?: boolean };
|
|
4533
|
-
if (!topicDeleteSettled(res)) return;
|
|
6054
|
+
if (!topicDeleteSettled(res)) return "post_dispatch_pending";
|
|
4534
6055
|
this.topics.settleDelete(sessionId, record.topicId);
|
|
4535
6056
|
for (const k of [...this.liveMessages.keys()])
|
|
4536
6057
|
if (k.startsWith(`${sessionId}:`)) {
|
|
@@ -4541,32 +6062,119 @@ export class TelegramNotificationDaemon {
|
|
|
4541
6062
|
if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
|
|
4542
6063
|
});
|
|
4543
6064
|
this.pendingThreadedFrames.delete(sessionId);
|
|
6065
|
+
try {
|
|
6066
|
+
await this.persistTopics();
|
|
6067
|
+
return "settled";
|
|
6068
|
+
} catch {
|
|
6069
|
+
this.topics.restoreDeleteFence(deleteSnapshot);
|
|
6070
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
6071
|
+
return "post_dispatch_pending";
|
|
6072
|
+
}
|
|
6073
|
+
} catch {
|
|
6074
|
+
// Once Telegram dispatch starts, retain the persisted deletion fence: the
|
|
6075
|
+
// remote result is ambiguous and may not restore stale routing authority.
|
|
6076
|
+
return "post_dispatch_pending";
|
|
6077
|
+
}
|
|
6078
|
+
}
|
|
6079
|
+
|
|
6080
|
+
/** Serialize a mutation and its durable snapshot so rollback precedes later writers. */
|
|
6081
|
+
#persistTopicMutation<T>(mutation: () => T, rollback: () => void): Promise<T> {
|
|
6082
|
+
const pending = this.topicsPersistQueue.then(async () => {
|
|
6083
|
+
const result = mutation();
|
|
6084
|
+
try {
|
|
6085
|
+
const snapshot = this.#topicStateForPersistence();
|
|
6086
|
+
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
6087
|
+
await ensureDir(this.fsImpl, paths.dir);
|
|
6088
|
+
await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), snapshot);
|
|
6089
|
+
return result;
|
|
6090
|
+
} catch (error) {
|
|
6091
|
+
rollback();
|
|
6092
|
+
throw error;
|
|
6093
|
+
}
|
|
6094
|
+
});
|
|
6095
|
+
this.topicsPersistQueue = pending.then(
|
|
6096
|
+
() => undefined,
|
|
6097
|
+
() => undefined,
|
|
6098
|
+
);
|
|
6099
|
+
return pending;
|
|
6100
|
+
}
|
|
6101
|
+
|
|
6102
|
+
async #persistTopicsWithRetry(): Promise<void> {
|
|
6103
|
+
try {
|
|
4544
6104
|
await this.persistTopics();
|
|
4545
6105
|
} catch {
|
|
4546
|
-
|
|
6106
|
+
await this.persistTopics();
|
|
4547
6107
|
}
|
|
4548
6108
|
}
|
|
4549
6109
|
|
|
6110
|
+
#superviseCompensationFence(sessionId: string): void {
|
|
6111
|
+
if (this.compensationFenceRetries.has(sessionId)) return;
|
|
6112
|
+
const retry = this.effects.track(
|
|
6113
|
+
(async () => {
|
|
6114
|
+
for (;;) {
|
|
6115
|
+
try {
|
|
6116
|
+
await this.persistTopics();
|
|
6117
|
+
return;
|
|
6118
|
+
} catch {
|
|
6119
|
+
await this.runtime.sleep(250);
|
|
6120
|
+
}
|
|
6121
|
+
}
|
|
6122
|
+
})(),
|
|
6123
|
+
);
|
|
6124
|
+
this.compensationFenceRetries.set(sessionId, retry);
|
|
6125
|
+
void retry.finally(() => this.compensationFenceRetries.delete(sessionId));
|
|
6126
|
+
}
|
|
6127
|
+
|
|
4550
6128
|
private persistTopics(): Promise<void> {
|
|
4551
6129
|
const pending = this.topicsPersistQueue.then(async () => {
|
|
6130
|
+
// Resolve implicit snapshots inside the serialization queue. Callers that
|
|
6131
|
+
// mutate the registry before waiting cannot overwrite a newer authority
|
|
6132
|
+
// binding with an invocation-time snapshot.
|
|
6133
|
+
const snapshot = this.#topicStateForPersistence();
|
|
4552
6134
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
4553
6135
|
await ensureDir(this.fsImpl, paths.dir);
|
|
4554
|
-
await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"),
|
|
6136
|
+
await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), snapshot);
|
|
4555
6137
|
});
|
|
4556
6138
|
this.topicsPersistQueue = pending.catch(() => undefined);
|
|
4557
6139
|
return pending;
|
|
4558
6140
|
}
|
|
4559
6141
|
|
|
6142
|
+
#topicStateForPersistence(): TopicRegistryState {
|
|
6143
|
+
return { ...this.topics.serialize(), closedEndpoints: Object.fromEntries(this.closedEndpointKeys) };
|
|
6144
|
+
}
|
|
6145
|
+
|
|
4560
6146
|
async loadTopics(): Promise<void> {
|
|
4561
6147
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
4562
6148
|
const raw = await readJson<TopicRegistryState>(this.fsImpl, path.join(paths.dir, "telegram-topics.json"));
|
|
4563
6149
|
// Restore the full serialized registry (topicId + identitySent + name) so a
|
|
4564
6150
|
// fresh daemon after reload does not resend identity headers or lose renames.
|
|
4565
|
-
if (raw && typeof raw === "object")
|
|
6151
|
+
if (raw && typeof raw === "object") {
|
|
6152
|
+
this.topics.load(raw);
|
|
6153
|
+
for (const [sessionId, binding] of Object.entries(raw.closedEndpoints ?? {})) {
|
|
6154
|
+
if (
|
|
6155
|
+
binding &&
|
|
6156
|
+
typeof binding.chatId === "string" &&
|
|
6157
|
+
typeof binding.endpointKey === "string" &&
|
|
6158
|
+
typeof binding.endpointDigest === "string" &&
|
|
6159
|
+
(binding.endpointGeneration === undefined ||
|
|
6160
|
+
(Number.isSafeInteger(binding.endpointGeneration) && binding.endpointGeneration >= 0))
|
|
6161
|
+
)
|
|
6162
|
+
this.closedEndpointKeys.set(sessionId, binding);
|
|
6163
|
+
}
|
|
6164
|
+
}
|
|
6165
|
+
}
|
|
6166
|
+
|
|
6167
|
+
/** Retry crash-interrupted topic deletes; only a definite Telegram result clears the durable fence. */
|
|
6168
|
+
private async reconcilePendingTopicDeletes(): Promise<void> {
|
|
6169
|
+
for (const sessionId of this.topics.deletePendingSessionIds()) await this.deleteTopic(sessionId);
|
|
4566
6170
|
}
|
|
4567
6171
|
|
|
4568
|
-
/** Download
|
|
4569
|
-
private async downloadTelegramFile(
|
|
6172
|
+
/** Download one Telegram file with the Bot API's 20 MiB ceiling and one end-to-end deadline. */
|
|
6173
|
+
private async downloadTelegramFile(
|
|
6174
|
+
filePath: string,
|
|
6175
|
+
maxBytes = TELEGRAM_ATTACHMENT_MAX_BYTES,
|
|
6176
|
+
deadlineController?: AbortController,
|
|
6177
|
+
): Promise<TelegramFileDownload> {
|
|
4570
6178
|
const apiBase = this.opts.apiBase ?? "https://api.telegram.org";
|
|
4571
6179
|
const fetchImpl = this.opts.fetchImpl ?? fetch;
|
|
4572
6180
|
// `filePath` is remote metadata from getFile; reject suspicious segments
|
|
@@ -4574,17 +6182,68 @@ export class TelegramNotificationDaemon {
|
|
|
4574
6182
|
// composing the download URL.
|
|
4575
6183
|
if (filePath.includes("..") || filePath.startsWith("/") || filePath.includes("\\")) {
|
|
4576
6184
|
logger.warn("notifications: rejecting suspicious Telegram file_path");
|
|
4577
|
-
return
|
|
6185
|
+
return { failure: "download_failed" };
|
|
4578
6186
|
}
|
|
4579
6187
|
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
|
4580
6188
|
const url = `${apiBase}/file/bot${this.opts.botToken}/${encodedPath}`;
|
|
6189
|
+
const controller = deadlineController ?? new AbortController();
|
|
6190
|
+
let cancelActiveReader: (() => Promise<void>) | undefined;
|
|
6191
|
+
const setTimeoutImpl = this.opts.setTimeoutImpl ?? setTimeout;
|
|
6192
|
+
const clearTimeoutImpl = this.opts.clearTimeoutImpl ?? clearTimeout;
|
|
6193
|
+
const timeout = deadlineController
|
|
6194
|
+
? undefined
|
|
6195
|
+
: setTimeoutImpl(
|
|
6196
|
+
() => controller.abort(new Error("Telegram attachment download timed out")),
|
|
6197
|
+
TELEGRAM_ATTACHMENT_DOWNLOAD_TIMEOUT_MS,
|
|
6198
|
+
);
|
|
6199
|
+
const cancelReader = () => void cancelActiveReader?.();
|
|
6200
|
+
controller.signal.addEventListener("abort", cancelReader, { once: true });
|
|
4581
6201
|
try {
|
|
4582
|
-
const res = await fetchImpl(url);
|
|
4583
|
-
if (!res.ok)
|
|
4584
|
-
|
|
6202
|
+
const res = await fetchImpl(url, { signal: controller.signal });
|
|
6203
|
+
if (!res.ok) {
|
|
6204
|
+
await res.body?.cancel().catch(() => undefined);
|
|
6205
|
+
controller.abort();
|
|
6206
|
+
return { failure: "download_failed" };
|
|
6207
|
+
}
|
|
6208
|
+
const declaredLength = res.headers.get("content-length");
|
|
6209
|
+
if (declaredLength !== null) {
|
|
6210
|
+
const declaredBytes = Number(declaredLength);
|
|
6211
|
+
if (!Number.isSafeInteger(declaredBytes) || declaredBytes < 0 || declaredBytes > maxBytes) {
|
|
6212
|
+
await res.body?.cancel().catch(() => undefined);
|
|
6213
|
+
controller.abort();
|
|
6214
|
+
return { failure: "too_large" };
|
|
6215
|
+
}
|
|
6216
|
+
}
|
|
6217
|
+
if (!res.body) {
|
|
6218
|
+
controller.abort();
|
|
6219
|
+
return { failure: "download_failed" };
|
|
6220
|
+
}
|
|
6221
|
+
const reader = res.body.getReader();
|
|
6222
|
+
cancelActiveReader = () => reader.cancel().catch(() => undefined);
|
|
6223
|
+
controller.signal.throwIfAborted();
|
|
6224
|
+
const chunks: Buffer[] = [];
|
|
6225
|
+
let total = 0;
|
|
6226
|
+
while (true) {
|
|
6227
|
+
const { done, value } = await reader.read();
|
|
6228
|
+
if (done) break;
|
|
6229
|
+
total += value.byteLength;
|
|
6230
|
+
if (total > maxBytes) {
|
|
6231
|
+
await reader.cancel().catch(() => undefined);
|
|
6232
|
+
controller.abort();
|
|
6233
|
+
return { failure: "too_large" };
|
|
6234
|
+
}
|
|
6235
|
+
chunks.push(Buffer.from(value));
|
|
6236
|
+
}
|
|
6237
|
+
controller.signal.throwIfAborted();
|
|
6238
|
+
return { bytes: Buffer.concat(chunks, total) };
|
|
4585
6239
|
} catch (e) {
|
|
6240
|
+
await cancelActiveReader?.();
|
|
6241
|
+
controller.abort();
|
|
4586
6242
|
logger.warn(`notifications: file download failed: ${sanitizeDiagnostic(String(e))}`);
|
|
4587
|
-
return
|
|
6243
|
+
return { failure: "download_failed" };
|
|
6244
|
+
} finally {
|
|
6245
|
+
controller.signal.removeEventListener("abort", cancelReader);
|
|
6246
|
+
if (timeout !== undefined) clearTimeoutImpl(timeout);
|
|
4588
6247
|
}
|
|
4589
6248
|
}
|
|
4590
6249
|
|
|
@@ -4594,6 +6253,8 @@ export class TelegramNotificationDaemon {
|
|
|
4594
6253
|
* removed when the daemon stops (see {@link cleanupAllAttachmentDirs}).
|
|
4595
6254
|
*/
|
|
4596
6255
|
private readonly attachmentDirs = new Map<string, string>();
|
|
6256
|
+
readonly #attachmentUsage = new Map<string, { count: number; bytes: number }>();
|
|
6257
|
+
readonly #attachmentChains = new Map<string, Promise<void>>();
|
|
4597
6258
|
|
|
4598
6259
|
/** Lazily create a private, unguessable 0700 temp dir for `sessionId`. */
|
|
4599
6260
|
private async ensureAttachmentDir(sessionId: string): Promise<string> {
|
|
@@ -4611,6 +6272,8 @@ export class TelegramNotificationDaemon {
|
|
|
4611
6272
|
private async cleanupAllAttachmentDirs(): Promise<void> {
|
|
4612
6273
|
const dirs = [...this.attachmentDirs.values()];
|
|
4613
6274
|
this.attachmentDirs.clear();
|
|
6275
|
+
this.#attachmentUsage.clear();
|
|
6276
|
+
this.#attachmentChains.clear();
|
|
4614
6277
|
await Promise.all(dirs.map(dir => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => undefined)));
|
|
4615
6278
|
}
|
|
4616
6279
|
|
|
@@ -4626,22 +6289,83 @@ export class TelegramNotificationDaemon {
|
|
|
4626
6289
|
private async resolveInboundAttachment(
|
|
4627
6290
|
att: InboundAttachment,
|
|
4628
6291
|
sessionId: string,
|
|
6292
|
+
): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
|
|
6293
|
+
const previous = this.#attachmentChains.get(sessionId) ?? Promise.resolve();
|
|
6294
|
+
const task = previous.then(() => this.resolveInboundAttachmentSerial(att, sessionId));
|
|
6295
|
+
const chain = task.then(
|
|
6296
|
+
() => undefined,
|
|
6297
|
+
() => undefined,
|
|
6298
|
+
);
|
|
6299
|
+
this.#attachmentChains.set(sessionId, chain);
|
|
6300
|
+
return task.finally(() => {
|
|
6301
|
+
if (this.#attachmentChains.get(sessionId) === chain) this.#attachmentChains.delete(sessionId);
|
|
6302
|
+
});
|
|
6303
|
+
}
|
|
6304
|
+
|
|
6305
|
+
private async resolveInboundAttachmentSerial(
|
|
6306
|
+
att: InboundAttachment,
|
|
6307
|
+
sessionId: string,
|
|
4629
6308
|
): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
|
|
4630
6309
|
const images: { data: string; mime?: string }[] = [];
|
|
4631
6310
|
const fileNotes: string[] = [];
|
|
4632
6311
|
const label = att.fileName ?? att.kind;
|
|
6312
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
4633
6313
|
try {
|
|
4634
|
-
const
|
|
4635
|
-
|
|
6314
|
+
const usage = this.#attachmentUsage.get(sessionId) ?? { count: 0, bytes: 0 };
|
|
6315
|
+
if (
|
|
6316
|
+
usage.count >= TELEGRAM_SESSION_ATTACHMENT_MAX_COUNT ||
|
|
6317
|
+
usage.bytes >= TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES
|
|
6318
|
+
) {
|
|
6319
|
+
fileNotes.push(`[attachment rejected: ${label}; session attachment limit reached]`);
|
|
6320
|
+
return { images, fileNotes };
|
|
6321
|
+
}
|
|
6322
|
+
const controller = new AbortController();
|
|
6323
|
+
timeout = (this.opts.setTimeoutImpl ?? setTimeout)(
|
|
6324
|
+
() => controller.abort(new Error("Telegram attachment download timed out")),
|
|
6325
|
+
TELEGRAM_ATTACHMENT_DOWNLOAD_TIMEOUT_MS,
|
|
6326
|
+
);
|
|
6327
|
+
const got = (await this.botApi.call("getFile", { file_id: att.fileId }, { signal: controller.signal })) as {
|
|
6328
|
+
result?: { file_path?: unknown; file_size?: unknown };
|
|
4636
6329
|
};
|
|
4637
6330
|
const filePath = typeof got?.result?.file_path === "string" ? got.result.file_path : undefined;
|
|
4638
6331
|
if (!filePath) {
|
|
4639
6332
|
fileNotes.push(`[attachment unavailable: ${label}]`);
|
|
4640
6333
|
return { images, fileNotes };
|
|
4641
6334
|
}
|
|
4642
|
-
const
|
|
4643
|
-
|
|
4644
|
-
|
|
6335
|
+
const declaredBytes = got.result?.file_size;
|
|
6336
|
+
const remainingBytes = TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES - usage.bytes;
|
|
6337
|
+
if (
|
|
6338
|
+
(typeof declaredBytes === "number" &&
|
|
6339
|
+
(!Number.isSafeInteger(declaredBytes) ||
|
|
6340
|
+
declaredBytes < 0 ||
|
|
6341
|
+
declaredBytes > TELEGRAM_ATTACHMENT_MAX_BYTES ||
|
|
6342
|
+
declaredBytes > remainingBytes)) ||
|
|
6343
|
+
remainingBytes <= 0
|
|
6344
|
+
) {
|
|
6345
|
+
fileNotes.push(`[attachment rejected: ${label}; size limit exceeded]`);
|
|
6346
|
+
return { images, fileNotes };
|
|
6347
|
+
}
|
|
6348
|
+
const downloaded = await this.downloadTelegramFile(
|
|
6349
|
+
filePath,
|
|
6350
|
+
Math.min(TELEGRAM_ATTACHMENT_MAX_BYTES, remainingBytes),
|
|
6351
|
+
controller,
|
|
6352
|
+
);
|
|
6353
|
+
if ("failure" in downloaded) {
|
|
6354
|
+
fileNotes.push(
|
|
6355
|
+
downloaded.failure === "too_large"
|
|
6356
|
+
? `[attachment rejected: ${label}; size limit exceeded]`
|
|
6357
|
+
: `[attachment download failed: ${label}]`,
|
|
6358
|
+
);
|
|
6359
|
+
return { images, fileNotes };
|
|
6360
|
+
}
|
|
6361
|
+
const bytes = downloaded.bytes;
|
|
6362
|
+
const accountedBytes = Math.max(bytes.byteLength, typeof declaredBytes === "number" ? declaredBytes : 0);
|
|
6363
|
+
const current = this.#attachmentUsage.get(sessionId) ?? { count: 0, bytes: 0 };
|
|
6364
|
+
if (
|
|
6365
|
+
current.count >= TELEGRAM_SESSION_ATTACHMENT_MAX_COUNT ||
|
|
6366
|
+
current.bytes + accountedBytes > TELEGRAM_SESSION_ATTACHMENT_MAX_BYTES
|
|
6367
|
+
) {
|
|
6368
|
+
fileNotes.push(`[attachment rejected: ${label}; session attachment limit reached]`);
|
|
4645
6369
|
return { images, fileNotes };
|
|
4646
6370
|
}
|
|
4647
6371
|
const isImage = att.kind === "photo" || (typeof att.mime === "string" && att.mime.startsWith("image/"));
|
|
@@ -4658,12 +6382,20 @@ export class TelegramNotificationDaemon {
|
|
|
4658
6382
|
// Unguessable, non-colliding name inside the private 0700 dir; the
|
|
4659
6383
|
// exclusive 0600 create (`wx`) refuses to follow a pre-existing file/symlink.
|
|
4660
6384
|
const dest = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safeBase}`);
|
|
4661
|
-
|
|
6385
|
+
try {
|
|
6386
|
+
await fs.promises.writeFile(dest, bytes, { flag: "wx", mode: 0o600 });
|
|
6387
|
+
} catch (e) {
|
|
6388
|
+
await fs.promises.unlink(dest).catch(() => undefined);
|
|
6389
|
+
throw e;
|
|
6390
|
+
}
|
|
4662
6391
|
fileNotes.push(`[user attached a file, saved to ${dest}${att.mime ? ` (${att.mime})` : ""}]`);
|
|
4663
6392
|
}
|
|
6393
|
+
this.#attachmentUsage.set(sessionId, { count: current.count + 1, bytes: current.bytes + accountedBytes });
|
|
4664
6394
|
} catch (e) {
|
|
4665
6395
|
logger.warn(`notifications: inbound attachment failed: ${String(e)}`);
|
|
4666
6396
|
fileNotes.push(`[attachment error: ${label}]`);
|
|
6397
|
+
} finally {
|
|
6398
|
+
if (timeout !== undefined) (this.opts.clearTimeoutImpl ?? clearTimeout)(timeout);
|
|
4667
6399
|
}
|
|
4668
6400
|
return { images, fileNotes };
|
|
4669
6401
|
}
|
|
@@ -4769,7 +6501,9 @@ export class TelegramNotificationDaemon {
|
|
|
4769
6501
|
const controller = new AbortController();
|
|
4770
6502
|
selectedAck.controller = controller;
|
|
4771
6503
|
const routeAvailable =
|
|
4772
|
-
(
|
|
6504
|
+
this.#leaseTokenAllows(selectedAck.socketLease) &&
|
|
6505
|
+
(await this.pairedChatIsPrivate()) &&
|
|
6506
|
+
(!topicLease || this.topicLeaseIsCurrent(topicLease));
|
|
4773
6507
|
if (this.selectedAckPending.get(selectedAck.pendingKey) !== selectedAck) {
|
|
4774
6508
|
this.pool.settle(item.itemId!, "removed");
|
|
4775
6509
|
continue;
|
|
@@ -4791,6 +6525,14 @@ export class TelegramNotificationDaemon {
|
|
|
4791
6525
|
Math.min(8_000, remaining),
|
|
4792
6526
|
);
|
|
4793
6527
|
try {
|
|
6528
|
+
if (
|
|
6529
|
+
!this.#leaseTokenAllows(selectedAck.socketLease) ||
|
|
6530
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6531
|
+
) {
|
|
6532
|
+
this.finishSelectedAck(selectedAck, { status: "failed", reason: "route_missing" });
|
|
6533
|
+
this.pool.settle(item.itemId!, "rejected");
|
|
6534
|
+
continue;
|
|
6535
|
+
}
|
|
4794
6536
|
const response = (await this.botApi.call(
|
|
4795
6537
|
"sendMessage",
|
|
4796
6538
|
{
|
|
@@ -4815,8 +6557,11 @@ export class TelegramNotificationDaemon {
|
|
|
4815
6557
|
}
|
|
4816
6558
|
continue;
|
|
4817
6559
|
}
|
|
4818
|
-
const { send, topicLease } = item.payload;
|
|
4819
|
-
if (
|
|
6560
|
+
const { send, topicLease, socketLease } = item.payload;
|
|
6561
|
+
if (
|
|
6562
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
6563
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6564
|
+
) {
|
|
4820
6565
|
this.pool.settle(item.itemId!, "rejected");
|
|
4821
6566
|
continue;
|
|
4822
6567
|
}
|
|
@@ -4825,7 +6570,10 @@ export class TelegramNotificationDaemon {
|
|
|
4825
6570
|
this.pool.settle(item.itemId!, "rejected");
|
|
4826
6571
|
continue;
|
|
4827
6572
|
}
|
|
4828
|
-
if (
|
|
6573
|
+
if (
|
|
6574
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
6575
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6576
|
+
) {
|
|
4829
6577
|
this.pool.settle(item.itemId!, "rejected");
|
|
4830
6578
|
continue;
|
|
4831
6579
|
}
|
|
@@ -4870,7 +6618,10 @@ export class TelegramNotificationDaemon {
|
|
|
4870
6618
|
}
|
|
4871
6619
|
}
|
|
4872
6620
|
}
|
|
4873
|
-
if (
|
|
6621
|
+
if (
|
|
6622
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
6623
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6624
|
+
) {
|
|
4874
6625
|
disposition = "rejected";
|
|
4875
6626
|
continue;
|
|
4876
6627
|
}
|
|
@@ -4910,7 +6661,11 @@ export class TelegramNotificationDaemon {
|
|
|
4910
6661
|
// non-editable, HTML-only pool items (rich markers stripped) — same
|
|
4911
6662
|
// per-token discipline as the non-rich split path.
|
|
4912
6663
|
const chunks = splitTelegramHtml(send.text!);
|
|
4913
|
-
if (
|
|
6664
|
+
if (
|
|
6665
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
6666
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6667
|
+
)
|
|
6668
|
+
return;
|
|
4914
6669
|
await this.botApi.call("sendMessage", {
|
|
4915
6670
|
chat_id: this.opts.chatId,
|
|
4916
6671
|
...threadField,
|
|
@@ -4935,6 +6690,7 @@ export class TelegramNotificationDaemon {
|
|
|
4935
6690
|
richClass: undefined,
|
|
4936
6691
|
},
|
|
4937
6692
|
topicLease,
|
|
6693
|
+
socketLease,
|
|
4938
6694
|
},
|
|
4939
6695
|
});
|
|
4940
6696
|
}
|
|
@@ -4969,6 +6725,11 @@ export class TelegramNotificationDaemon {
|
|
|
4969
6725
|
// transport error) resends so the first chunk is never lost.
|
|
4970
6726
|
let edited = false;
|
|
4971
6727
|
try {
|
|
6728
|
+
if (
|
|
6729
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
6730
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6731
|
+
)
|
|
6732
|
+
return;
|
|
4972
6733
|
const res = (await this.botApi.call("editMessageText", {
|
|
4973
6734
|
chat_id: this.opts.chatId,
|
|
4974
6735
|
message_id: existingId,
|
|
@@ -4981,7 +6742,10 @@ export class TelegramNotificationDaemon {
|
|
|
4981
6742
|
}
|
|
4982
6743
|
if (edited) {
|
|
4983
6744
|
firstMessageId = existingId;
|
|
4984
|
-
} else if (
|
|
6745
|
+
} else if (
|
|
6746
|
+
(!socketLease || this.#leaseTokenAllows(socketLease)) &&
|
|
6747
|
+
(!topicLease || this.topicLeaseIsCurrent(topicLease))
|
|
6748
|
+
) {
|
|
4985
6749
|
const res = (await this.botApi.call("sendMessage", {
|
|
4986
6750
|
chat_id: this.opts.chatId,
|
|
4987
6751
|
...threadField,
|
|
@@ -4993,6 +6757,11 @@ export class TelegramNotificationDaemon {
|
|
|
4993
6757
|
} else {
|
|
4994
6758
|
// No streamed message to edit: a single granted slot maps to a
|
|
4995
6759
|
// single Telegram send.
|
|
6760
|
+
if (
|
|
6761
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
6762
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6763
|
+
)
|
|
6764
|
+
return;
|
|
4996
6765
|
const res = (await this.botApi.call("sendMessage", {
|
|
4997
6766
|
chat_id: this.opts.chatId,
|
|
4998
6767
|
...threadField,
|
|
@@ -5026,6 +6795,7 @@ export class TelegramNotificationDaemon {
|
|
|
5026
6795
|
richClass: undefined,
|
|
5027
6796
|
},
|
|
5028
6797
|
topicLease,
|
|
6798
|
+
socketLease,
|
|
5029
6799
|
},
|
|
5030
6800
|
});
|
|
5031
6801
|
}
|
|
@@ -5103,17 +6873,21 @@ export class TelegramNotificationDaemon {
|
|
|
5103
6873
|
sessionId: string,
|
|
5104
6874
|
send: ThreadedSend,
|
|
5105
6875
|
toolActivity?: ToolActivityOwner,
|
|
6876
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
5106
6877
|
): Promise<void> {
|
|
5107
|
-
if (!(await this.pairedChatIsPrivate())) return;
|
|
5108
|
-
|
|
6878
|
+
if ((socketLease && !this.#leaseTokenAllows(socketLease)) || !(await this.pairedChatIsPrivate())) return;
|
|
6879
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
6880
|
+
await this.notifyThreadedFallback(socketLease);
|
|
6881
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
5109
6882
|
if (send.identity && this.flatIdentitySent.has(sessionId)) return;
|
|
5110
6883
|
this.submitPool({
|
|
5111
6884
|
sessionId,
|
|
5112
6885
|
lane: send.lane,
|
|
5113
6886
|
coalesceKey: send.coalesceKey,
|
|
5114
|
-
payload: { send, ...(toolActivity ? { toolActivity } : {}) },
|
|
6887
|
+
payload: { send, ...(socketLease ? { socketLease } : {}), ...(toolActivity ? { toolActivity } : {}) },
|
|
5115
6888
|
});
|
|
5116
6889
|
await this.flushPool();
|
|
6890
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
5117
6891
|
if (send.identity) this.flatIdentitySent.add(sessionId);
|
|
5118
6892
|
}
|
|
5119
6893
|
|
|
@@ -5156,10 +6930,21 @@ export class TelegramNotificationDaemon {
|
|
|
5156
6930
|
}
|
|
5157
6931
|
|
|
5158
6932
|
/** Tell the user once (per daemon run) how to enable Threaded Mode. */
|
|
5159
|
-
private async notifyThreadedFallback(
|
|
5160
|
-
|
|
6933
|
+
private async notifyThreadedFallback(socketLease?: {
|
|
6934
|
+
session: SessionSocket;
|
|
6935
|
+
token: number;
|
|
6936
|
+
logicalSessionId: string;
|
|
6937
|
+
}): Promise<void> {
|
|
6938
|
+
if (
|
|
6939
|
+
this.threadedFallbackNoticeSent ||
|
|
6940
|
+
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
6941
|
+
!(await this.pairedChatIsPrivate())
|
|
6942
|
+
)
|
|
6943
|
+
return;
|
|
6944
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
5161
6945
|
this.threadedFallbackNoticeSent = true;
|
|
5162
6946
|
try {
|
|
6947
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
5163
6948
|
await this.botApi.call("sendMessage", {
|
|
5164
6949
|
chat_id: this.opts.chatId,
|
|
5165
6950
|
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 +7019,20 @@ export class TelegramNotificationDaemon {
|
|
|
5234
7019
|
}
|
|
5235
7020
|
|
|
5236
7021
|
/** Send a single `typing` chat action into a busy session's topic (best-effort). */
|
|
5237
|
-
private async sendTyping(
|
|
7022
|
+
private async sendTyping(
|
|
7023
|
+
sessionId: string,
|
|
7024
|
+
capturedLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
7025
|
+
): Promise<void> {
|
|
7026
|
+
const session = this.logicalSessionOwners.get(sessionId) ?? this.sessions.get(sessionId);
|
|
7027
|
+
const socketLease = capturedLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
|
|
7028
|
+
if (!socketLease) return;
|
|
5238
7029
|
const topicLease = await this.topicAuthorityLease(sessionId);
|
|
5239
|
-
if (!topicLease || !this.topicLeaseIsCurrent(topicLease)) return;
|
|
5240
|
-
const topicId = topicLease.topicId;
|
|
7030
|
+
if (!topicLease || !this.topicLeaseIsCurrent(topicLease) || !this.#leaseTokenAllows(socketLease)) return;
|
|
5241
7031
|
try {
|
|
7032
|
+
if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return;
|
|
5242
7033
|
await this.botApi.call("sendChatAction", {
|
|
5243
7034
|
chat_id: this.opts.chatId,
|
|
5244
|
-
message_thread_id: Number(topicId),
|
|
7035
|
+
message_thread_id: Number(topicLease.topicId),
|
|
5245
7036
|
action: "typing",
|
|
5246
7037
|
});
|
|
5247
7038
|
} catch {
|
|
@@ -5250,9 +7041,14 @@ export class TelegramNotificationDaemon {
|
|
|
5250
7041
|
}
|
|
5251
7042
|
|
|
5252
7043
|
/** Set a native reaction on an inbound thread message (best-effort). */
|
|
5253
|
-
private async setReaction(
|
|
5254
|
-
|
|
7044
|
+
private async setReaction(
|
|
7045
|
+
messageId: number,
|
|
7046
|
+
emoji: string,
|
|
7047
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
7048
|
+
): Promise<void> {
|
|
7049
|
+
if ((socketLease && !this.#leaseTokenAllows(socketLease)) || !(await this.pairedChatIsPrivate())) return;
|
|
5255
7050
|
try {
|
|
7051
|
+
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
5256
7052
|
await this.botApi.call("setMessageReaction", {
|
|
5257
7053
|
chat_id: this.opts.chatId,
|
|
5258
7054
|
message_id: messageId,
|
|
@@ -5276,7 +7072,10 @@ export class TelegramNotificationDaemon {
|
|
|
5276
7072
|
|
|
5277
7073
|
/** Render successful `/model` lists as session-bound, one-shot inline choices. */
|
|
5278
7074
|
async #renderModelChoices(session: SessionSocket, msg: Record<string, unknown>): Promise<boolean> {
|
|
5279
|
-
const logicalSessionId =
|
|
7075
|
+
const logicalSessionId =
|
|
7076
|
+
!session.logicalSessionIdTrusted && typeof msg.sessionId === "string" && msg.sessionId.trim()
|
|
7077
|
+
? msg.sessionId
|
|
7078
|
+
: this.#logicalSessionId(session);
|
|
5280
7079
|
if (
|
|
5281
7080
|
msg.type !== "control_command_result" ||
|
|
5282
7081
|
msg.status !== "ok" ||
|
|
@@ -5285,6 +7084,8 @@ export class TelegramNotificationDaemon {
|
|
|
5285
7084
|
this.sessions.get(session.sessionId) !== session
|
|
5286
7085
|
)
|
|
5287
7086
|
return false;
|
|
7087
|
+
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
7088
|
+
if (!socketLease) return false;
|
|
5288
7089
|
|
|
5289
7090
|
const choices: RenderedModelChoice[] = [];
|
|
5290
7091
|
for (const choice of msg.modelChoices) {
|
|
@@ -5299,21 +7100,27 @@ export class TelegramNotificationDaemon {
|
|
|
5299
7100
|
const rendered = renderThreadedFrame({ ...msg, type: "control_command_result" });
|
|
5300
7101
|
if (!rendered?.text) return false;
|
|
5301
7102
|
const topicId =
|
|
5302
|
-
(await this.existingTopicForPrivateChat(
|
|
5303
|
-
(await this.ensureTopic(
|
|
5304
|
-
|
|
7103
|
+
(await this.existingTopicForPrivateChat(logicalSessionId)) ??
|
|
7104
|
+
(await this.ensureTopic(
|
|
7105
|
+
logicalSessionId,
|
|
7106
|
+
this.topicNameFor(logicalSessionId, msg),
|
|
7107
|
+
session.logicalSessionIdTrusted ? session : undefined,
|
|
7108
|
+
socketLease,
|
|
7109
|
+
));
|
|
7110
|
+
const topicLease = await this.topicAuthorityLease(logicalSessionId);
|
|
5305
7111
|
if (!topicId || !topicLease || topicLease.topicId !== topicId) return false;
|
|
7112
|
+
if (!session.logicalSessionIdTrusted) this.legacyTopicOwners.set(logicalSessionId, session);
|
|
5306
7113
|
|
|
5307
7114
|
// Each logical session owns only its most recently rendered menu.
|
|
5308
7115
|
this.#clearModelChoiceAliases(logicalSessionId);
|
|
5309
7116
|
const aliases = choices.map(choice =>
|
|
5310
|
-
this.#putModelChoiceAlias({ session, sessionId: logicalSessionId, selector: choice.selector }),
|
|
7117
|
+
this.#putModelChoiceAlias({ session, socketLease, sessionId: logicalSessionId, selector: choice.selector }),
|
|
5311
7118
|
);
|
|
5312
7119
|
const inline_keyboard = buildButtonGrid(
|
|
5313
7120
|
choices.map(choice => choice.label),
|
|
5314
7121
|
index => aliases[index]!,
|
|
5315
7122
|
);
|
|
5316
|
-
if (!this.topicLeaseIsCurrent(topicLease)) return false;
|
|
7123
|
+
if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return false;
|
|
5317
7124
|
try {
|
|
5318
7125
|
const response = await this.botApi.call("sendMessage", {
|
|
5319
7126
|
chat_id: this.opts.chatId,
|
|
@@ -5336,6 +7143,18 @@ export class TelegramNotificationDaemon {
|
|
|
5336
7143
|
}
|
|
5337
7144
|
|
|
5338
7145
|
async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
|
|
7146
|
+
if (msg?.type === "hello") {
|
|
7147
|
+
const capabilities = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
7148
|
+
if (capabilities.includes("ephemeral_turn_v1")) {
|
|
7149
|
+
session.ephemeralCapable = true;
|
|
7150
|
+
this.#resumeBtwTurnsForSession(session);
|
|
7151
|
+
}
|
|
7152
|
+
if (capabilities.includes(CLIENT_PING_PONG_CAPABILITY)) {
|
|
7153
|
+
session.capable = true;
|
|
7154
|
+
this.startLiveness(session);
|
|
7155
|
+
}
|
|
7156
|
+
return;
|
|
7157
|
+
}
|
|
5339
7158
|
if (session.replayPending) {
|
|
5340
7159
|
if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled === false || this.toolActivityStopping))
|
|
5341
7160
|
return;
|
|
@@ -5346,34 +7165,85 @@ export class TelegramNotificationDaemon {
|
|
|
5346
7165
|
session.replayQueue.push(frame);
|
|
5347
7166
|
return;
|
|
5348
7167
|
}
|
|
5349
|
-
session.replayPending = false;
|
|
5350
7168
|
const replayValid =
|
|
7169
|
+
msg.ok === true &&
|
|
5351
7170
|
Number.isSafeInteger(msg.generation) &&
|
|
5352
7171
|
msg.generation >= 1 &&
|
|
5353
7172
|
Number.isSafeInteger(msg.lastSeq) &&
|
|
5354
7173
|
msg.lastSeq >= 0 &&
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
}
|
|
7174
|
+
msg.gap === undefined &&
|
|
7175
|
+
Array.isArray(msg.events) &&
|
|
7176
|
+
msg.events.every((event: unknown) => event !== null && typeof event === "object" && !Array.isArray(event));
|
|
5359
7177
|
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
|
-
];
|
|
7178
|
+
? (msg.events as Record<string, unknown>[]).map(event => {
|
|
7179
|
+
const payload = event.payload;
|
|
7180
|
+
return payload && typeof payload === "object" && !Array.isArray(payload)
|
|
7181
|
+
? (payload as Record<string, unknown>)
|
|
7182
|
+
: event;
|
|
5369
7183
|
})
|
|
5370
7184
|
: [];
|
|
7185
|
+
const identityFrames = replayed.filter(frame => frame.type === "identity_header");
|
|
7186
|
+
const identities = identityFrames.flatMap(frame =>
|
|
7187
|
+
typeof frame.sessionId === "string" && frame.sessionId.trim() ? [frame.sessionId] : [],
|
|
7188
|
+
);
|
|
7189
|
+
const malformedIdentity = identityFrames.some(
|
|
7190
|
+
frame => typeof frame.sessionId !== "string" || !frame.sessionId.trim(),
|
|
7191
|
+
);
|
|
7192
|
+
const identityConflict = new Set(identities).size > 1;
|
|
7193
|
+
if (!replayValid || malformedIdentity || identityConflict) {
|
|
7194
|
+
// A replay result is the admission proof. Never clear its barrier or drain
|
|
7195
|
+
// queued frames after malformed/conflicting proof; the socket cannot fall
|
|
7196
|
+
// back to transport-local config rekeying.
|
|
7197
|
+
this.dropSession(session, "invalid_replay");
|
|
7198
|
+
return;
|
|
7199
|
+
}
|
|
7200
|
+
session.hostGeneration = msg.generation;
|
|
7201
|
+
session.logicalSessionIdTrusted = true;
|
|
7202
|
+
const identityIndex = replayed.findLastIndex(frame => frame.type === "identity_header");
|
|
7203
|
+
const latestIdentity = identityIndex < 0 ? undefined : replayed[identityIndex];
|
|
7204
|
+
const replayIdentitySessionId = latestIdentity?.sessionId as string | undefined;
|
|
7205
|
+
const endpointBinding = this.#endpointBinding(session);
|
|
7206
|
+
// Identity-less replay may resume only the exact transport owner. A
|
|
7207
|
+
// rekeyed A→B transport remains denied unless replay proves B.
|
|
7208
|
+
const endpointAuthority = this.#endpointAuthority(endpointBinding, session);
|
|
7209
|
+
const ownsLiveOpenEndpoint = this.#ownsLiveOpenEndpoint(session, endpointBinding);
|
|
7210
|
+
const canResumeTransport =
|
|
7211
|
+
endpointAuthority.state === "unique" &&
|
|
7212
|
+
endpointAuthority.sessionId === session.sessionId &&
|
|
7213
|
+
ownsLiveOpenEndpoint &&
|
|
7214
|
+
this.topics.matchesEndpoint(session.sessionId, endpointBinding);
|
|
7215
|
+
const canBootstrapTransport =
|
|
7216
|
+
endpointAuthority.state === "none" &&
|
|
7217
|
+
ownsLiveOpenEndpoint &&
|
|
7218
|
+
!this.topics.get(session.sessionId) &&
|
|
7219
|
+
!this.preservedInitiatorTopics.has(session.sessionId);
|
|
7220
|
+
const replayCandidateSessionId =
|
|
7221
|
+
replayIdentitySessionId ?? (canResumeTransport || canBootstrapTransport ? session.sessionId : undefined);
|
|
7222
|
+
if (!replayCandidateSessionId) return;
|
|
7223
|
+
const recovered = await this.#recoverTopicBinding(
|
|
7224
|
+
session,
|
|
7225
|
+
replayCandidateSessionId ?? session.sessionId,
|
|
7226
|
+
true,
|
|
7227
|
+
true,
|
|
7228
|
+
replayIdentitySessionId ? undefined : canBootstrapTransport ? "bootstrap" : "resume",
|
|
7229
|
+
);
|
|
7230
|
+
if (!recovered) {
|
|
7231
|
+
if (session.hostGeneration === msg.generation && session.recoveryLease?.state !== "pending")
|
|
7232
|
+
this.dropSession(session, "recovery_rejected");
|
|
7233
|
+
return;
|
|
7234
|
+
}
|
|
7235
|
+
if (
|
|
7236
|
+
!this.#ownsLiveOpenEndpoint(session, endpointBinding) ||
|
|
7237
|
+
!this.#leaseAllows(session, this.#logicalSessionId(session))
|
|
7238
|
+
) {
|
|
7239
|
+
this.dropSession(session, "recovery_rejected");
|
|
7240
|
+
return;
|
|
7241
|
+
}
|
|
7242
|
+
session.replayPending = false;
|
|
5371
7243
|
// Replay restores durable attachment state only. Live notification effects
|
|
5372
7244
|
// (turn streams, context updates, lifecycle messages) may already have been
|
|
5373
7245
|
// delivered before a reconnect and must never be rendered a second time.
|
|
5374
|
-
const identityIndex = replayed.findLastIndex(frame => frame.type === "identity_header");
|
|
5375
7246
|
const currentGeneration = identityIndex < 0 ? replayed : replayed.slice(identityIndex);
|
|
5376
|
-
const latestIdentity = identityIndex < 0 ? undefined : replayed[identityIndex];
|
|
5377
7247
|
const latestActions = new Map<string, Record<string, unknown>>();
|
|
5378
7248
|
for (const frame of currentGeneration) {
|
|
5379
7249
|
if ((frame.type === "action_needed" || frame.type === "action_resolved") && typeof frame.id === "string")
|
|
@@ -5384,7 +7254,15 @@ export class TelegramNotificationDaemon {
|
|
|
5384
7254
|
for (const frame of replayState) {
|
|
5385
7255
|
const fingerprint = JSON.stringify(frame);
|
|
5386
7256
|
replayCounts.set(fingerprint, (replayCounts.get(fingerprint) ?? 0) + 1);
|
|
5387
|
-
|
|
7257
|
+
try {
|
|
7258
|
+
await this.handleSessionMessage(session, frame);
|
|
7259
|
+
} catch (error) {
|
|
7260
|
+
logger.warn(
|
|
7261
|
+
`notifications: Telegram replay admission failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
|
|
7262
|
+
);
|
|
7263
|
+
this.dropSession(session, "replay_admission_failed");
|
|
7264
|
+
return;
|
|
7265
|
+
}
|
|
5388
7266
|
}
|
|
5389
7267
|
const queued = session.replayQueue.splice(0);
|
|
5390
7268
|
for (const frame of queued) {
|
|
@@ -5402,16 +7280,19 @@ export class TelegramNotificationDaemon {
|
|
|
5402
7280
|
}
|
|
5403
7281
|
await this.handleSessionMessage(session, frame);
|
|
5404
7282
|
}
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
7283
|
+
this.#terminalizeBtwTurnsForGenerationChange(session);
|
|
7284
|
+
this.#resumeBtwTurnsForSession(session);
|
|
7285
|
+
const recoveredSessionId = this.#logicalSessionId(session);
|
|
7286
|
+
if (
|
|
7287
|
+
this.#leaseAllows(session, recoveredSessionId) &&
|
|
7288
|
+
this.topics.markReplayCursor(recoveredSessionId, msg.generation, msg.lastSeq)
|
|
7289
|
+
)
|
|
7290
|
+
await this.persistTopics();
|
|
5411
7291
|
return;
|
|
5412
7292
|
}
|
|
5413
7293
|
if (msg?.type === "event_replay_result") return;
|
|
5414
|
-
if (msg && typeof msg === "object") this.#updateLogicalSessionForThreadedFrame(session, msg);
|
|
7294
|
+
if (msg && typeof msg === "object") await this.#updateLogicalSessionForThreadedFrame(session, msg);
|
|
7295
|
+
if (session.logicalSessionIdTrusted && !this.#leaseAllows(session)) return;
|
|
5415
7296
|
if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
|
|
5416
7297
|
if (await this.#renderModelChoices(session, msg as Record<string, unknown>)) return;
|
|
5417
7298
|
|
|
@@ -5445,7 +7326,7 @@ export class TelegramNotificationDaemon {
|
|
|
5445
7326
|
msg.threadId !== pending.threadId ||
|
|
5446
7327
|
msg.updateId !== pending.updateId ||
|
|
5447
7328
|
msg.messageId !== pending.messageId ||
|
|
5448
|
-
this.topics.sessionForTopic(pending.threadId) !== pending.
|
|
7329
|
+
this.topics.sessionForTopic(pending.threadId) !== pending.logicalSessionId
|
|
5449
7330
|
)
|
|
5450
7331
|
return;
|
|
5451
7332
|
if (
|
|
@@ -5459,6 +7340,21 @@ export class TelegramNotificationDaemon {
|
|
|
5459
7340
|
return;
|
|
5460
7341
|
if (msg.status === "ok" && typeof msg.text !== "string") return;
|
|
5461
7342
|
if (this.#stoppingBtw || this.#btwTerminalDeliveries.has(requestId)) return;
|
|
7343
|
+
const isAuthoritative = (): boolean =>
|
|
7344
|
+
!this.#stoppingBtw &&
|
|
7345
|
+
this.#leaseTokenAllows(pending.socketLease) &&
|
|
7346
|
+
this.sessions.get(session.sessionId) === session &&
|
|
7347
|
+
session.ws.readyState === WebSocket.OPEN &&
|
|
7348
|
+
pending.endpointDigest === session.endpointDigest &&
|
|
7349
|
+
pending.generation === session.hostGeneration &&
|
|
7350
|
+
pending.logicalSessionId === this.#logicalSessionId(session) &&
|
|
7351
|
+
pending.transportSessionId === session.sessionId &&
|
|
7352
|
+
requestId === msg.requestId &&
|
|
7353
|
+
pending.logicalSessionId === msg.sessionId &&
|
|
7354
|
+
pending.messageId === msg.messageId &&
|
|
7355
|
+
pending.threadId === msg.threadId &&
|
|
7356
|
+
pending.updateId === msg.updateId &&
|
|
7357
|
+
this.topics.sessionForTopic(pending.threadId) === pending.logicalSessionId;
|
|
5462
7358
|
const finished = Promise.withResolvers<void>();
|
|
5463
7359
|
const terminalDelivery: PendingBtwDelivery = {
|
|
5464
7360
|
pending,
|
|
@@ -5487,6 +7383,7 @@ export class TelegramNotificationDaemon {
|
|
|
5487
7383
|
messageId: pending.messageId,
|
|
5488
7384
|
text,
|
|
5489
7385
|
signal: terminalDelivery.controller.signal,
|
|
7386
|
+
isAuthoritative,
|
|
5490
7387
|
});
|
|
5491
7388
|
deliveryOutcome =
|
|
5492
7389
|
response && typeof response === "object" && (response as { ok?: unknown }).ok === true
|
|
@@ -5501,20 +7398,6 @@ export class TelegramNotificationDaemon {
|
|
|
5501
7398
|
return;
|
|
5502
7399
|
}
|
|
5503
7400
|
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
7401
|
const signal = AbortSignal.any([
|
|
5519
7402
|
this.#btwDeliveryAbort.signal,
|
|
5520
7403
|
terminalDelivery.controller.signal,
|
|
@@ -5641,13 +7524,13 @@ export class TelegramNotificationDaemon {
|
|
|
5641
7524
|
toolAdmissionEpoch === this.toolActivityPolicyEpoch);
|
|
5642
7525
|
const abandonStaleToolStart = (): void => {
|
|
5643
7526
|
if (toolActivity?.phase !== "started") return;
|
|
5644
|
-
const key = `${
|
|
7527
|
+
const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
5645
7528
|
const owner = this.toolActivityOwners.get(key);
|
|
5646
7529
|
if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
|
|
5647
7530
|
this.toolActivityOwners.delete(key);
|
|
5648
7531
|
};
|
|
5649
7532
|
if (toolActivity) {
|
|
5650
|
-
const liveKey = `${
|
|
7533
|
+
const liveKey = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
5651
7534
|
const currentOwner = this.toolActivityOwners.get(liveKey);
|
|
5652
7535
|
if (toolActivity.phase === "started") {
|
|
5653
7536
|
if (!toolStartIsCurrent()) return;
|
|
@@ -5672,102 +7555,106 @@ export class TelegramNotificationDaemon {
|
|
|
5672
7555
|
}
|
|
5673
7556
|
const send = renderThreadedFrame(msg);
|
|
5674
7557
|
if (!send) return;
|
|
5675
|
-
const
|
|
7558
|
+
const transportLogicalSessionId = this.#logicalSessionId(session);
|
|
7559
|
+
// Preserve legacy identity routing for direct/non-replay session callers.
|
|
7560
|
+
// Authenticated transports never infer topic ownership from display identity:
|
|
7561
|
+
// a resume must bind only its proven logical session.
|
|
7562
|
+
const logicalSessionId =
|
|
7563
|
+
send.identity && !session.logicalSessionIdTrusted && this.sessions.get(session.sessionId) !== session
|
|
7564
|
+
? (this.topicOwnerForIdentity(msg) ?? transportLogicalSessionId)
|
|
7565
|
+
: transportLogicalSessionId;
|
|
7566
|
+
if (!this.#leaseAllows(session, logicalSessionId)) return;
|
|
7567
|
+
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
7568
|
+
if (!socketLease) return;
|
|
7569
|
+
const existingTopic = await this.existingTopicForPrivateChat(logicalSessionId);
|
|
5676
7570
|
if (!toolStartIsCurrent()) {
|
|
5677
7571
|
abandonStaleToolStart();
|
|
5678
7572
|
return;
|
|
5679
7573
|
}
|
|
5680
|
-
if (
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
7574
|
+
if (
|
|
7575
|
+
this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
|
|
7576
|
+
this.topics.get(logicalSessionId)?.bindingMalformed
|
|
7577
|
+
)
|
|
7578
|
+
return;
|
|
7579
|
+
if (!send.identity && !existingTopic && !this.flatIdentitySent.has(logicalSessionId)) {
|
|
5686
7580
|
this.rememberPendingThreadedFrame(session, send, threadedFrame, toolActivity);
|
|
5687
7581
|
return;
|
|
5688
7582
|
}
|
|
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
7583
|
const topicId =
|
|
5698
|
-
existingTopic ??
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
abandonStaleToolStart();
|
|
5702
|
-
return;
|
|
5703
|
-
}
|
|
7584
|
+
existingTopic ??
|
|
7585
|
+
(await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session));
|
|
7586
|
+
const topicLease = await this.topicAuthorityLease(logicalSessionId);
|
|
5704
7587
|
if (!topicId || !topicLease || topicLease.topicId !== topicId) {
|
|
5705
|
-
if (
|
|
5706
|
-
|
|
7588
|
+
if (
|
|
7589
|
+
this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
|
|
7590
|
+
this.topics.get(logicalSessionId)?.bindingMalformed
|
|
7591
|
+
)
|
|
7592
|
+
return;
|
|
7593
|
+
await this.deliverFlatFallback(logicalSessionId, send, toolActivity, socketLease);
|
|
5707
7594
|
return;
|
|
5708
7595
|
}
|
|
5709
7596
|
if (send.identity) {
|
|
5710
7597
|
const identityKey = this.topicIdentityKey(msg);
|
|
5711
7598
|
if (identityKey) {
|
|
5712
|
-
this.topicOwnerByIdentity.set(identityKey,
|
|
5713
|
-
if (this.topics.markIdentityKey(
|
|
7599
|
+
this.topicOwnerByIdentity.set(identityKey, logicalSessionId);
|
|
7600
|
+
if (this.topics.markIdentityKey(logicalSessionId, identityKey)) await this.persistTopics();
|
|
5714
7601
|
}
|
|
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
7602
|
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
|
-
);
|
|
7603
|
+
const name = this.topicNameFor(logicalSessionId, msg);
|
|
7604
|
+
if (this.topics.needsRename(logicalSessionId, name)) {
|
|
5725
7605
|
try {
|
|
5726
|
-
if (!this.topicLeaseIsCurrent(topicLease)) return;
|
|
7606
|
+
if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return;
|
|
7607
|
+
this.daemonRenameAttempts.set(
|
|
7608
|
+
logicalSessionId,
|
|
7609
|
+
(this.daemonRenameAttempts.get(logicalSessionId) ?? 0) + 1,
|
|
7610
|
+
);
|
|
5727
7611
|
const response = await this.botApi.call("editForumTopic", {
|
|
5728
7612
|
chat_id: this.opts.chatId,
|
|
5729
7613
|
message_thread_id: Number(topicId),
|
|
5730
7614
|
name,
|
|
5731
7615
|
});
|
|
5732
|
-
if (topicRenameApplied(response)) this.topics.markNameApplied(
|
|
7616
|
+
if (topicRenameApplied(response)) this.topics.markNameApplied(logicalSessionId, name);
|
|
5733
7617
|
} catch {
|
|
5734
|
-
//
|
|
5735
|
-
// later identity frame retries.
|
|
7618
|
+
// A later identity frame retries a transient daemon rename failure.
|
|
5736
7619
|
} finally {
|
|
5737
|
-
const
|
|
5738
|
-
if (
|
|
5739
|
-
else
|
|
5740
|
-
this.daemonRenameAttempts.delete(session.sessionId);
|
|
5741
|
-
await this.reconcileUserTopicName(topicLease);
|
|
5742
|
-
}
|
|
7620
|
+
const attempts = (this.daemonRenameAttempts.get(logicalSessionId) ?? 1) - 1;
|
|
7621
|
+
if (attempts > 0) this.daemonRenameAttempts.set(logicalSessionId, attempts);
|
|
7622
|
+
else this.daemonRenameAttempts.delete(logicalSessionId);
|
|
5743
7623
|
}
|
|
5744
7624
|
}
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
this.topics.markIdentitySent(session.sessionId);
|
|
7625
|
+
if (this.topics.needsIdentity(logicalSessionId)) {
|
|
7626
|
+
await this.submitThreadedFrame(logicalSessionId, send, topicLease, undefined, socketLease);
|
|
7627
|
+
this.topics.markIdentitySent(logicalSessionId);
|
|
5749
7628
|
}
|
|
5750
|
-
await this.flushPendingThreadedFrames(session.sessionId, topicLease);
|
|
5751
7629
|
await this.persistTopics();
|
|
7630
|
+
await this.flushPendingThreadedFrames(logicalSessionId, topicLease);
|
|
7631
|
+
await this.reconcileUserTopicName(topicLease);
|
|
5752
7632
|
return;
|
|
5753
7633
|
}
|
|
5754
7634
|
if (!toolStartIsCurrent()) {
|
|
5755
7635
|
abandonStaleToolStart();
|
|
5756
7636
|
return;
|
|
5757
7637
|
}
|
|
5758
|
-
await this.submitThreadedFrame(
|
|
7638
|
+
await this.submitThreadedFrame(logicalSessionId, send, topicLease, toolActivity, socketLease);
|
|
5759
7639
|
return;
|
|
5760
7640
|
}
|
|
5761
7641
|
if (msg.type === "action_needed" && msg.id) {
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
7642
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
7643
|
+
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
7644
|
+
if (!socketLease) return;
|
|
7645
|
+
if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: logicalSessionId, actionId: msg.id });
|
|
7646
|
+
if (
|
|
7647
|
+
this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
|
|
7648
|
+
this.topics.get(logicalSessionId)?.bindingMalformed
|
|
7649
|
+
)
|
|
7650
|
+
return;
|
|
7651
|
+
const topicId = await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session);
|
|
7652
|
+
const topicLease = topicId ? this.topicAuthorityLeaseFromRegistry(logicalSessionId) : undefined;
|
|
5766
7653
|
if (topicId && (!topicLease || topicLease.topicId !== topicId)) return;
|
|
5767
7654
|
if (!topicId) {
|
|
5768
7655
|
// Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
|
|
5769
7656
|
if (!(await this.pairedChatIsPrivate())) return;
|
|
5770
|
-
await this.notifyThreadedFallback();
|
|
7657
|
+
await this.notifyThreadedFallback(socketLease);
|
|
5771
7658
|
}
|
|
5772
7659
|
const threadField = topicLease ? { message_thread_id: Number(topicLease.topicId) } : {};
|
|
5773
7660
|
const controls: Array<{
|
|
@@ -5799,19 +7686,20 @@ export class TelegramNotificationDaemon {
|
|
|
5799
7686
|
id: msg.id,
|
|
5800
7687
|
question: msg.question,
|
|
5801
7688
|
options: msg.options,
|
|
7689
|
+
recommendedIndex: msg.recommendedIndex,
|
|
5802
7690
|
controls,
|
|
5803
7691
|
summary: msg.summary,
|
|
5804
7692
|
});
|
|
5805
7693
|
const options = Array.isArray(msg.options) ? msg.options : [];
|
|
5806
7694
|
const inline_keyboard = [
|
|
5807
7695
|
...buildCompactChoiceGrid(options, (i: number) =>
|
|
5808
|
-
this.aliasTable.put({ sessionId:
|
|
7696
|
+
this.aliasTable.put({ sessionId: logicalSessionId, actionId: msg.id, answer: i }),
|
|
5809
7697
|
),
|
|
5810
7698
|
...controls.map(control => [
|
|
5811
7699
|
{
|
|
5812
7700
|
text: control.label,
|
|
5813
7701
|
callback_data: this.aliasTable.put({
|
|
5814
|
-
sessionId:
|
|
7702
|
+
sessionId: logicalSessionId,
|
|
5815
7703
|
actionId: msg.id,
|
|
5816
7704
|
answer: { controlId: control.id },
|
|
5817
7705
|
}),
|
|
@@ -5824,7 +7712,8 @@ export class TelegramNotificationDaemon {
|
|
|
5824
7712
|
const chunks = splitTelegramHtml(rendered.text);
|
|
5825
7713
|
let result: { result?: { message_id?: number } } = {};
|
|
5826
7714
|
for (let i = 0; i < chunks.length; i++) {
|
|
5827
|
-
if (topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
7715
|
+
if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease)))
|
|
7716
|
+
return undefined;
|
|
5828
7717
|
result = (await this.botApi.call("sendMessage", {
|
|
5829
7718
|
chat_id: this.opts.chatId,
|
|
5830
7719
|
...threadField,
|
|
@@ -5840,7 +7729,7 @@ export class TelegramNotificationDaemon {
|
|
|
5840
7729
|
// Rich (default on): promote to sendRichMessage with a top-level
|
|
5841
7730
|
// reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
|
|
5842
7731
|
|
|
5843
|
-
if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
|
|
7732
|
+
if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease))) return;
|
|
5844
7733
|
const outcome = await deliverRichActionWithFallback(
|
|
5845
7734
|
this.botApi,
|
|
5846
7735
|
{ chat_id: this.opts.chatId, ...threadField },
|
|
@@ -5849,6 +7738,7 @@ export class TelegramNotificationDaemon {
|
|
|
5849
7738
|
kind,
|
|
5850
7739
|
question: msg.question,
|
|
5851
7740
|
options: msg.options,
|
|
7741
|
+
recommendedIndex: msg.recommendedIndex,
|
|
5852
7742
|
summary: msg.summary,
|
|
5853
7743
|
}),
|
|
5854
7744
|
replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
|
|
@@ -5859,21 +7749,22 @@ export class TelegramNotificationDaemon {
|
|
|
5859
7749
|
);
|
|
5860
7750
|
// Only asks are reply-routable; idle pings register no route.
|
|
5861
7751
|
if (kind === "ask" && outcome.messageId !== undefined)
|
|
5862
|
-
this.messageRoutes.set(String(outcome.messageId), { sessionId:
|
|
7752
|
+
this.messageRoutes.set(String(outcome.messageId), { sessionId: logicalSessionId, actionId: msg.id });
|
|
5863
7753
|
} else {
|
|
5864
7754
|
// Off: byte-identical to the pre-rich HTML path.
|
|
5865
7755
|
const messageId = await sendHtmlChunks();
|
|
5866
7756
|
// Only asks are reply-routable; idle pings register no route (parity
|
|
5867
7757
|
// with the rich branch and correct even in the byte-identical off path).
|
|
5868
7758
|
if (kind === "ask" && messageId !== undefined)
|
|
5869
|
-
this.messageRoutes.set(String(messageId), { sessionId:
|
|
7759
|
+
this.messageRoutes.set(String(messageId), { sessionId: logicalSessionId, actionId: msg.id });
|
|
5870
7760
|
}
|
|
5871
7761
|
await this.persistAliases();
|
|
5872
7762
|
} else if (msg.type === "action_resolved" && msg.id) {
|
|
5873
7763
|
session.pending.delete(msg.id);
|
|
5874
|
-
this.deleteMessageRoutes(session
|
|
7764
|
+
this.deleteMessageRoutes(this.#logicalSessionId(session), msg.id);
|
|
5875
7765
|
for (const [alias, route] of this.aliasTable.entries()) {
|
|
5876
|
-
if (route.sessionId === session
|
|
7766
|
+
if (route.sessionId === this.#logicalSessionId(session) && route.actionId === msg.id)
|
|
7767
|
+
this.aliasTable.delete(alias);
|
|
5877
7768
|
}
|
|
5878
7769
|
await this.persistAliases();
|
|
5879
7770
|
}
|
|
@@ -5920,7 +7811,9 @@ export class TelegramNotificationDaemon {
|
|
|
5920
7811
|
if (
|
|
5921
7812
|
this.sessions.get(session.sessionId) !== session ||
|
|
5922
7813
|
this.#logicalSessionId(session) !== route.sessionId ||
|
|
5923
|
-
session.ws.readyState !== WebSocket.OPEN
|
|
7814
|
+
session.ws.readyState !== WebSocket.OPEN ||
|
|
7815
|
+
!this.#leaseTokenAllows(route.socketLease) ||
|
|
7816
|
+
(this.topics.get(route.sessionId) !== undefined && !this.topicAuthorityLeaseFromRegistry(route.sessionId))
|
|
5924
7817
|
) {
|
|
5925
7818
|
this.#modelChoiceAliases.delete(alias);
|
|
5926
7819
|
await this.#sendModelStaleGuidance(callbackId);
|
|
@@ -5932,6 +7825,13 @@ export class TelegramNotificationDaemon {
|
|
|
5932
7825
|
const updateId = (update as { update_id?: unknown }).update_id;
|
|
5933
7826
|
const safeUpdateId =
|
|
5934
7827
|
typeof updateId === "number" && Number.isSafeInteger(updateId) && updateId >= 0 ? updateId : undefined;
|
|
7828
|
+
if (
|
|
7829
|
+
!this.#leaseTokenAllows(route.socketLease) ||
|
|
7830
|
+
(this.topics.get(route.sessionId) !== undefined && !this.topicAuthorityLeaseFromRegistry(route.sessionId))
|
|
7831
|
+
) {
|
|
7832
|
+
await this.#sendModelStaleGuidance(callbackId);
|
|
7833
|
+
return true;
|
|
7834
|
+
}
|
|
5935
7835
|
try {
|
|
5936
7836
|
session.ws.send(
|
|
5937
7837
|
JSON.stringify({
|
|
@@ -6205,7 +8105,40 @@ export class TelegramNotificationDaemon {
|
|
|
6205
8105
|
if (!raw.callback_query && (!isAskReply || interceptAskControl || reservedBtw !== undefined)) {
|
|
6206
8106
|
const inbound = decideThreadedInbound(update as never, {
|
|
6207
8107
|
pairedChatId: this.opts.chatId,
|
|
6208
|
-
topicToSession: t =>
|
|
8108
|
+
topicToSession: t => {
|
|
8109
|
+
const topicSessionId = this.topics.sessionForTopic(t);
|
|
8110
|
+
if (!topicSessionId) return undefined;
|
|
8111
|
+
const legacyOwner = this.legacyTopicOwners.get(topicSessionId);
|
|
8112
|
+
if (this.preservedInitiatorTopics.has(topicSessionId)) return undefined;
|
|
8113
|
+
const owner =
|
|
8114
|
+
this.logicalSessionOwners.get(topicSessionId) ??
|
|
8115
|
+
legacyOwner ??
|
|
8116
|
+
[...this.sessions.values()].find(
|
|
8117
|
+
session =>
|
|
8118
|
+
(session.sessionId === topicSessionId || this.#logicalSessionId(session) === topicSessionId) &&
|
|
8119
|
+
this.#leaseAllows(session, topicSessionId),
|
|
8120
|
+
);
|
|
8121
|
+
if (owner) {
|
|
8122
|
+
if (owner.replayPending || owner.recoveryLease?.state === "pending") return undefined;
|
|
8123
|
+
return this.#leaseAllows(owner, topicSessionId) ? owner.logicalSessionId : undefined;
|
|
8124
|
+
}
|
|
8125
|
+
const transportOwner = [...this.sessions.values()].find(
|
|
8126
|
+
candidate => candidate.sessionId === topicSessionId && this.#leaseAllows(candidate),
|
|
8127
|
+
);
|
|
8128
|
+
if (transportOwner) {
|
|
8129
|
+
if (transportOwner.replayPending || transportOwner.recoveryLease?.state === "pending")
|
|
8130
|
+
return undefined;
|
|
8131
|
+
return transportOwner.logicalSessionId;
|
|
8132
|
+
}
|
|
8133
|
+
return [...this.sessions.values()].some(
|
|
8134
|
+
session =>
|
|
8135
|
+
session.sessionId === topicSessionId ||
|
|
8136
|
+
this.#logicalSessionId(session) === topicSessionId ||
|
|
8137
|
+
session.recoveryLease?.logicalSessionId === topicSessionId,
|
|
8138
|
+
)
|
|
8139
|
+
? undefined
|
|
8140
|
+
: topicSessionId;
|
|
8141
|
+
},
|
|
6209
8142
|
isDuplicate: id => this.dispatchState.seenUpdateIds.has(id),
|
|
6210
8143
|
});
|
|
6211
8144
|
if (inbound.kind === "duplicate") return;
|
|
@@ -6215,10 +8148,45 @@ export class TelegramNotificationDaemon {
|
|
|
6215
8148
|
? { kind: "none" as const }
|
|
6216
8149
|
: parseTelegramControlCommand(inbound.text, this.botUsername);
|
|
6217
8150
|
if (preliminaryControl.kind === "ignored") return;
|
|
6218
|
-
const session =
|
|
8151
|
+
const session =
|
|
8152
|
+
this.logicalSessionOwners.get(inbound.sessionId) ??
|
|
8153
|
+
this.sessions.get(inbound.sessionId) ??
|
|
8154
|
+
[...this.sessions.values()].find(
|
|
8155
|
+
candidate =>
|
|
8156
|
+
this.#logicalSessionId(candidate) === inbound.sessionId &&
|
|
8157
|
+
this.#leaseAllows(candidate, inbound.sessionId),
|
|
8158
|
+
);
|
|
8159
|
+
if (session && !this.#leaseAllows(session, inbound.sessionId)) return;
|
|
8160
|
+
const topicSessionId = this.topics.sessionForTopic(inbound.threadId);
|
|
8161
|
+
const topicLease = topicSessionId ? this.topicAuthorityLeaseFromRegistry(topicSessionId) : undefined;
|
|
8162
|
+
const topicLeaseAllows = (): boolean => {
|
|
8163
|
+
const current = topicSessionId ? this.topicAuthorityLeaseFromRegistry(topicSessionId) : undefined;
|
|
8164
|
+
return (
|
|
8165
|
+
!!topicLease &&
|
|
8166
|
+
current?.topicId === topicLease.topicId &&
|
|
8167
|
+
current.authorityEpoch === topicLease.authorityEpoch
|
|
8168
|
+
);
|
|
8169
|
+
};
|
|
8170
|
+
const routeLease = session ? this.#socketLease(session, inbound.sessionId) : undefined;
|
|
8171
|
+
const routeAllows = (): boolean =>
|
|
8172
|
+
!!session &&
|
|
8173
|
+
!!routeLease &&
|
|
8174
|
+
session.ws.readyState === WebSocket.OPEN &&
|
|
8175
|
+
this.#leaseTokenAllows(routeLease) &&
|
|
8176
|
+
topicLeaseAllows();
|
|
8177
|
+
const routeLeaseAllows = (): boolean =>
|
|
8178
|
+
topicLeaseAllows() && (!session || (!!routeLease && this.#leaseTokenAllows(routeLease)));
|
|
8179
|
+
const reserveRouteUpdate = async (): Promise<boolean> => {
|
|
8180
|
+
if (!(await this.reserveSeenUpdateId(inbound.updateId))) return false;
|
|
8181
|
+
if (routeLeaseAllows()) return true;
|
|
8182
|
+
await this.releaseSeenUpdateId(inbound.updateId);
|
|
8183
|
+
return false;
|
|
8184
|
+
};
|
|
8185
|
+
|
|
8186
|
+
if (session && !routeLease) return;
|
|
6219
8187
|
if (preliminaryControl.kind === "invalid" && session?.ws.readyState !== WebSocket.OPEN) return;
|
|
6220
8188
|
if (preliminaryControl.kind === "command" && session?.ws.readyState !== WebSocket.OPEN) {
|
|
6221
|
-
if (await
|
|
8189
|
+
if (await reserveRouteUpdate()) {
|
|
6222
8190
|
try {
|
|
6223
8191
|
await this.botApi.call("sendMessage", {
|
|
6224
8192
|
chat_id: this.opts.chatId,
|
|
@@ -6237,36 +8205,41 @@ export class TelegramNotificationDaemon {
|
|
|
6237
8205
|
return;
|
|
6238
8206
|
}
|
|
6239
8207
|
if (reservedBtw?.kind === "question" && (!reservedBtw.question || inbound.attachment)) {
|
|
6240
|
-
if (!(await
|
|
8208
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6241
8209
|
await this.#sendBtwMessage({
|
|
6242
8210
|
threadId: inbound.threadId,
|
|
6243
8211
|
messageId: inbound.messageId,
|
|
6244
8212
|
text: BTW_USAGE_TEXT,
|
|
8213
|
+
isAuthoritative: routeLeaseAllows,
|
|
6245
8214
|
});
|
|
6246
8215
|
return;
|
|
6247
8216
|
}
|
|
6248
8217
|
if (reservedBtw?.kind === "question" && this.opts.btw?.enabled === false) {
|
|
6249
|
-
if (!(await
|
|
8218
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6250
8219
|
await this.#sendBtwMessage({
|
|
6251
8220
|
threadId: inbound.threadId,
|
|
6252
8221
|
messageId: inbound.messageId,
|
|
6253
8222
|
text: "Telegram /btw is disabled in local settings.",
|
|
8223
|
+
isAuthoritative: routeLeaseAllows,
|
|
6254
8224
|
});
|
|
6255
8225
|
return;
|
|
6256
8226
|
}
|
|
6257
8227
|
if (reservedBtw?.kind === "question" && session?.ws.readyState !== WebSocket.OPEN) {
|
|
6258
|
-
if (!(await
|
|
8228
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6259
8229
|
await this.#sendBtwMessage({
|
|
6260
8230
|
threadId: inbound.threadId,
|
|
6261
8231
|
messageId: inbound.messageId,
|
|
6262
8232
|
text: "Restart this GJC session to enable /btw.",
|
|
8233
|
+
isAuthoritative: routeLeaseAllows,
|
|
6263
8234
|
});
|
|
6264
8235
|
return;
|
|
6265
8236
|
}
|
|
6266
|
-
if (session
|
|
8237
|
+
if (!session || !routeLease) return;
|
|
8238
|
+
if (routeAllows()) {
|
|
6267
8239
|
const attachmentResult = inbound.attachment
|
|
6268
8240
|
? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
|
|
6269
8241
|
: undefined;
|
|
8242
|
+
if (!routeAllows()) return;
|
|
6270
8243
|
const images = attachmentResult?.images ?? [];
|
|
6271
8244
|
const fileNotes = attachmentResult?.fileNotes ?? [];
|
|
6272
8245
|
const hasMedia = inbound.attachment !== undefined || images.length > 0 || fileNotes.length > 0;
|
|
@@ -6289,11 +8262,12 @@ export class TelegramNotificationDaemon {
|
|
|
6289
8262
|
if (btw?.kind === "question") {
|
|
6290
8263
|
const btwQuestion = btw.question;
|
|
6291
8264
|
if (!btwQuestion || hasMedia) {
|
|
6292
|
-
if (!(await
|
|
8265
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6293
8266
|
await this.#sendBtwMessage({
|
|
6294
8267
|
threadId: inbound.threadId,
|
|
6295
8268
|
messageId: inbound.messageId,
|
|
6296
8269
|
text: BTW_USAGE_TEXT,
|
|
8270
|
+
isAuthoritative: routeLeaseAllows,
|
|
6297
8271
|
});
|
|
6298
8272
|
return;
|
|
6299
8273
|
}
|
|
@@ -6303,28 +8277,31 @@ export class TelegramNotificationDaemon {
|
|
|
6303
8277
|
threadId: inbound.threadId,
|
|
6304
8278
|
messageId: inbound.messageId,
|
|
6305
8279
|
text: BTW_QUESTION_LIMIT_TEXT,
|
|
8280
|
+
isAuthoritative: routeLeaseAllows,
|
|
6306
8281
|
});
|
|
6307
8282
|
} catch {
|
|
6308
8283
|
logger.warn("notifications: /btw question-limit delivery failed");
|
|
6309
8284
|
}
|
|
6310
|
-
await
|
|
8285
|
+
await reserveRouteUpdate();
|
|
6311
8286
|
return;
|
|
6312
8287
|
}
|
|
6313
8288
|
if (this.opts.btw?.enabled === false) {
|
|
6314
|
-
if (!(await
|
|
8289
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6315
8290
|
await this.#sendBtwMessage({
|
|
6316
8291
|
threadId: inbound.threadId,
|
|
6317
8292
|
messageId: inbound.messageId,
|
|
6318
8293
|
text: "Telegram /btw is disabled in local settings.",
|
|
8294
|
+
isAuthoritative: routeLeaseAllows,
|
|
6319
8295
|
});
|
|
6320
8296
|
return;
|
|
6321
8297
|
}
|
|
6322
8298
|
if (!session.ephemeralCapable || session.hostGeneration < 1) {
|
|
6323
|
-
if (!(await
|
|
8299
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6324
8300
|
await this.#sendBtwMessage({
|
|
6325
8301
|
threadId: inbound.threadId,
|
|
6326
8302
|
messageId: inbound.messageId,
|
|
6327
8303
|
text: "Restart this GJC session to enable /btw.",
|
|
8304
|
+
isAuthoritative: routeLeaseAllows,
|
|
6328
8305
|
});
|
|
6329
8306
|
return;
|
|
6330
8307
|
}
|
|
@@ -6340,21 +8317,23 @@ export class TelegramNotificationDaemon {
|
|
|
6340
8317
|
this.#btwTerminalTombstones.delete(this.#btwTerminalTombstones.keys().next().value!);
|
|
6341
8318
|
}
|
|
6342
8319
|
if (this.#pendingBtwTurns.size >= BTW_MAX_PENDING) {
|
|
6343
|
-
if (!(await
|
|
8320
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6344
8321
|
await this.#sendBtwMessage({
|
|
6345
8322
|
threadId: inbound.threadId,
|
|
6346
8323
|
messageId: inbound.messageId,
|
|
6347
8324
|
text: BTW_CAPACITY_TEXT,
|
|
8325
|
+
isAuthoritative: routeLeaseAllows,
|
|
6348
8326
|
});
|
|
6349
8327
|
return;
|
|
6350
8328
|
}
|
|
6351
8329
|
const transportSessionId = session.sessionId;
|
|
6352
8330
|
const logicalSessionId = this.#logicalSessionId(session);
|
|
6353
8331
|
const requestId = `btw:${crypto.randomUUID()}`;
|
|
6354
|
-
if (!(await
|
|
8332
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6355
8333
|
const pending: PendingBtwTurn = {
|
|
6356
8334
|
transportSessionId,
|
|
6357
8335
|
logicalSessionId,
|
|
8336
|
+
socketLease: routeLease,
|
|
6358
8337
|
endpointDigest: session.endpointDigest,
|
|
6359
8338
|
generation: session.hostGeneration,
|
|
6360
8339
|
question: btwQuestion,
|
|
@@ -6370,6 +8349,7 @@ export class TelegramNotificationDaemon {
|
|
|
6370
8349
|
threadId: inbound.threadId,
|
|
6371
8350
|
messageId: inbound.messageId,
|
|
6372
8351
|
text: "Unable to start /btw because this GJC session disconnected. Reopen the session and try again.",
|
|
8352
|
+
isAuthoritative: routeLeaseAllows,
|
|
6373
8353
|
});
|
|
6374
8354
|
return;
|
|
6375
8355
|
}
|
|
@@ -6377,9 +8357,10 @@ export class TelegramNotificationDaemon {
|
|
|
6377
8357
|
}
|
|
6378
8358
|
const control = hasMedia ? { kind: "none" as const } : preliminaryControl;
|
|
6379
8359
|
if (control.kind !== "none") {
|
|
6380
|
-
await
|
|
8360
|
+
if (!(await reserveRouteUpdate())) return;
|
|
6381
8361
|
const sendControlNotice = async (body: string): Promise<void> => {
|
|
6382
8362
|
try {
|
|
8363
|
+
if (!routeLeaseAllows()) return;
|
|
6383
8364
|
await this.botApi.call("sendMessage", {
|
|
6384
8365
|
chat_id: this.opts.chatId,
|
|
6385
8366
|
message_thread_id: Number(inbound.threadId),
|
|
@@ -6398,6 +8379,7 @@ export class TelegramNotificationDaemon {
|
|
|
6398
8379
|
await sendControlNotice("Session control unavailable: session is disconnected.");
|
|
6399
8380
|
return;
|
|
6400
8381
|
}
|
|
8382
|
+
if (!routeAllows()) return;
|
|
6401
8383
|
session.ws.send(
|
|
6402
8384
|
JSON.stringify({
|
|
6403
8385
|
type: "control_command",
|
|
@@ -6417,6 +8399,7 @@ export class TelegramNotificationDaemon {
|
|
|
6417
8399
|
// Telegram asks always accept custom text (the SDK maps a string answer
|
|
6418
8400
|
// to the ask's custom-input slot), so route the latest pending ask here.
|
|
6419
8401
|
const pendingAsk = cfg || hasMedia ? undefined : [...session.pending.values()].at(-1);
|
|
8402
|
+
if (!routeAllows()) return;
|
|
6420
8403
|
if (pendingAsk) {
|
|
6421
8404
|
session.ws.send(
|
|
6422
8405
|
JSON.stringify({
|
|
@@ -6427,9 +8410,11 @@ export class TelegramNotificationDaemon {
|
|
|
6427
8410
|
}),
|
|
6428
8411
|
);
|
|
6429
8412
|
await this.rememberSeenUpdateId(inbound.updateId);
|
|
6430
|
-
if (inbound.messageId !== undefined)
|
|
8413
|
+
if (inbound.messageId !== undefined)
|
|
8414
|
+
await this.setReaction(inbound.messageId, QUEUED_REACTION, routeLease);
|
|
6431
8415
|
return;
|
|
6432
8416
|
}
|
|
8417
|
+
if (!routeAllows()) return;
|
|
6433
8418
|
session.ws.send(
|
|
6434
8419
|
JSON.stringify(
|
|
6435
8420
|
cfg
|
|
@@ -6450,8 +8435,12 @@ export class TelegramNotificationDaemon {
|
|
|
6450
8435
|
// flipped to consumed when the session acks the turn that picks it
|
|
6451
8436
|
// up. Config commands are not user turns and get no reaction.
|
|
6452
8437
|
if (!cfg && inbound.messageId !== undefined) {
|
|
6453
|
-
|
|
6454
|
-
|
|
8438
|
+
if (!routeAllows()) return;
|
|
8439
|
+
this.inboundReactions.set(inbound.updateId, {
|
|
8440
|
+
messageId: inbound.messageId,
|
|
8441
|
+
socketLease: routeLease,
|
|
8442
|
+
});
|
|
8443
|
+
await this.setReaction(inbound.messageId, QUEUED_REACTION, routeLease);
|
|
6455
8444
|
}
|
|
6456
8445
|
}
|
|
6457
8446
|
return;
|
|
@@ -6465,8 +8454,14 @@ export class TelegramNotificationDaemon {
|
|
|
6465
8454
|
pairedChatId: this.opts.chatId,
|
|
6466
8455
|
});
|
|
6467
8456
|
if (decision.kind === "reply") {
|
|
6468
|
-
const session = this.sessions.get(decision.sessionId);
|
|
6469
|
-
if (
|
|
8457
|
+
const session = this.logicalSessionOwners.get(decision.sessionId) ?? this.sessions.get(decision.sessionId);
|
|
8458
|
+
if (
|
|
8459
|
+
session?.ws.readyState !== WebSocket.OPEN ||
|
|
8460
|
+
!session.pending.has(decision.actionId) ||
|
|
8461
|
+
!this.#leaseAllows(session, decision.sessionId) ||
|
|
8462
|
+
(this.topics.get(decision.sessionId) !== undefined &&
|
|
8463
|
+
!this.topicAuthorityLeaseFromRegistry(decision.sessionId))
|
|
8464
|
+
) {
|
|
6470
8465
|
await this.sendStaleGuidance(callbackId);
|
|
6471
8466
|
return;
|
|
6472
8467
|
}
|
|
@@ -6517,31 +8512,49 @@ export class TelegramNotificationDaemon {
|
|
|
6517
8512
|
}
|
|
6518
8513
|
|
|
6519
8514
|
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();
|
|
8515
|
+
// Runtime callers can bypass TypeScript's option type. Without a valid bot
|
|
8516
|
+
// token, there is no authenticated daemon identity or lifecycle authority.
|
|
8517
|
+
if (!validBotToken(this.opts.botToken)) return;
|
|
8518
|
+
let ownershipProved = false;
|
|
6544
8519
|
try {
|
|
8520
|
+
const renewed = await renewDaemonHeartbeat({
|
|
8521
|
+
settings: this.opts.settings,
|
|
8522
|
+
ownerId: this.opts.ownerId,
|
|
8523
|
+
acquisitionId: this.opts.ownerId,
|
|
8524
|
+
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
8525
|
+
chatId: this.opts.chatId,
|
|
8526
|
+
fs: this.fsImpl,
|
|
8527
|
+
now: this.opts.now,
|
|
8528
|
+
pid: this.opts.pid ?? process.pid,
|
|
8529
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
8530
|
+
});
|
|
8531
|
+
if (!renewed) return;
|
|
8532
|
+
ownershipProved = true;
|
|
8533
|
+
this.running = !this.stopRequested;
|
|
8534
|
+
if (!this.running) return;
|
|
8535
|
+
// Self-heal durable notification state before any scan/poll work so
|
|
8536
|
+
// `daemon reload` recovers dead roots + leak artifacts (#2956).
|
|
8537
|
+
try {
|
|
8538
|
+
await healTelegramDaemonNotificationState({
|
|
8539
|
+
settings: this.opts.settings,
|
|
8540
|
+
fs: this.fsImpl,
|
|
8541
|
+
now: this.opts.now,
|
|
8542
|
+
});
|
|
8543
|
+
} catch (error) {
|
|
8544
|
+
logger.warn(`notifications: startup self-heal failed: ${sanitizeDiagnostic(String(error))}`);
|
|
8545
|
+
}
|
|
8546
|
+
// Owner-only: start lifecycle control immediately after ownership proof,
|
|
8547
|
+
// before timers or pre-poll startup work can invalidate this run.
|
|
8548
|
+
// Best-effort; notification delivery remains available on failure.
|
|
8549
|
+
await this.startLifecycleControl();
|
|
8550
|
+
// A stop may arrive while lifecycle startup awaits its control endpoint.
|
|
8551
|
+
// Do not re-enable runtime work after that stop; close the partial server.
|
|
8552
|
+
if (!this.running) return;
|
|
8553
|
+
this.runtime.start();
|
|
8554
|
+
this.startOwnershipHeartbeatTimer();
|
|
8555
|
+
this.startFlushTimer();
|
|
8556
|
+
this.startScanTimer();
|
|
8557
|
+
this.startTypingTimer();
|
|
6545
8558
|
await this.refreshBotIdentity();
|
|
6546
8559
|
await this.registerBotCommands();
|
|
6547
8560
|
await this.loadAliases();
|
|
@@ -6549,9 +8562,6 @@ export class TelegramNotificationDaemon {
|
|
|
6549
8562
|
await this.loadSeenUpdateIds();
|
|
6550
8563
|
await this.replyStore.load();
|
|
6551
8564
|
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
8565
|
let idleSince = this.runtime.now();
|
|
6556
8566
|
while (this.running) {
|
|
6557
8567
|
if (await this.controlStopRequested()) break;
|
|
@@ -6603,61 +8613,64 @@ export class TelegramNotificationDaemon {
|
|
|
6603
8613
|
await this.runtime.sleep(10);
|
|
6604
8614
|
}
|
|
6605
8615
|
} 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,
|
|
8616
|
+
// A contender must not mutate durable owner state while unwinding startup.
|
|
8617
|
+
if (ownershipProved) {
|
|
8618
|
+
let toolShutdownError: unknown;
|
|
8619
|
+
try {
|
|
8620
|
+
await this.beginToolActivityShutdown();
|
|
8621
|
+
} catch (error) {
|
|
8622
|
+
toolShutdownError = error;
|
|
8623
|
+
}
|
|
8624
|
+
this.effects.beginShutdown();
|
|
8625
|
+
this.#deliveryAbort.abort();
|
|
8626
|
+
let persisted = false;
|
|
8627
|
+
const shutdown = this.effects.allowTerminal(async () => {
|
|
8628
|
+
if (toolShutdownError) throw toolShutdownError;
|
|
8629
|
+
await this.#drainBtwTurns();
|
|
8630
|
+
await this.toolTerminalizationChain;
|
|
8631
|
+
this.runtime.stop();
|
|
8632
|
+
this.stopOwnershipHeartbeatTimer();
|
|
8633
|
+
this.stopFlushTimer();
|
|
8634
|
+
this.stopScanTimer();
|
|
8635
|
+
this.stopTypingTimer();
|
|
8636
|
+
this.stopLifecycleControl();
|
|
8637
|
+
await this.cleanupAllAttachmentDirs();
|
|
8638
|
+
await this.persistAliases();
|
|
8639
|
+
await this.persistTopics();
|
|
8640
|
+
await this.persistSeenUpdateIds();
|
|
8641
|
+
await this.opts.control?.clear?.(this.opts.ownerId);
|
|
8642
|
+
persisted = true;
|
|
6660
8643
|
});
|
|
8644
|
+
const deadline = Promise.withResolvers<boolean>();
|
|
8645
|
+
const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
|
|
8646
|
+
const completed = await Promise.race([
|
|
8647
|
+
shutdown.then(
|
|
8648
|
+
() => true,
|
|
8649
|
+
error => {
|
|
8650
|
+
logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
|
|
8651
|
+
return false;
|
|
8652
|
+
},
|
|
8653
|
+
),
|
|
8654
|
+
deadline.promise,
|
|
8655
|
+
]);
|
|
8656
|
+
clearTimeout(deadlineTimer);
|
|
8657
|
+
const quiesced = completed && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
|
|
8658
|
+
if (!quiesced || !persisted) {
|
|
8659
|
+
logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
|
|
8660
|
+
} else {
|
|
8661
|
+
await releaseDaemonOwnership({
|
|
8662
|
+
settings: this.opts.settings,
|
|
8663
|
+
ownerId: this.opts.ownerId,
|
|
8664
|
+
acquisitionId: this.opts.ownerId,
|
|
8665
|
+
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
8666
|
+
chatId: this.opts.chatId,
|
|
8667
|
+
pid: this.opts.pid ?? process.pid,
|
|
8668
|
+
generation: DAEMON_GENERATION,
|
|
8669
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
8670
|
+
fs: this.fsImpl,
|
|
8671
|
+
now: this.opts.now,
|
|
8672
|
+
});
|
|
8673
|
+
}
|
|
6661
8674
|
}
|
|
6662
8675
|
}
|
|
6663
8676
|
}
|