@gajae-code/coding-agent 0.12.11 → 0.12.12
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 +38 -0
- package/dist/types/cli/notify-cli.d.ts +42 -1
- package/dist/types/cli/update-cli.d.ts +9 -3
- package/dist/types/commands/notify.d.ts +6 -0
- package/dist/types/config/file-lock.d.ts +3 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
- package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
- package/dist/types/extensibility/extensions/types.d.ts +2 -0
- package/dist/types/main.d.ts +6 -2
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/utils/host-status.d.ts +23 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
- package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
- package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
- package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
- package/dist/types/sdk/bus/notification-service.d.ts +10 -0
- package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
- package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
- package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
- package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +24 -7
- package/dist/types/sdk/bus/telegram-daemon.d.ts +104 -16
- package/dist/types/sdk/bus/telegram-reference.d.ts +10 -1
- package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
- package/dist/types/sdk/host/host.d.ts +52 -0
- package/dist/types/sdk/lifecycle-session.d.ts +6 -0
- package/dist/types/sdk/session-activation.d.ts +41 -0
- package/dist/types/sdk/session.d.ts +18 -0
- package/dist/types/sdk/startup-capability.d.ts +8 -1
- package/dist/types/session/agent-session.d.ts +7 -0
- package/dist/types/session/artifacts.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +23 -5
- package/dist/types/tools/index.d.ts +8 -0
- package/dist/types/utils/npm-registry.d.ts +71 -0
- package/examples/extensions/README.md +38 -4
- package/examples/extensions/ooo-bridge.ts +15 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +4 -0
- package/src/async/job-manager.ts +9 -2
- package/src/cli/notify-cli.ts +226 -2
- package/src/cli/update-cli.ts +90 -23
- package/src/commands/notify.ts +27 -3
- package/src/commands/sdk.ts +1 -0
- package/src/config/file-lock-gc.ts +18 -0
- package/src/config/file-lock.ts +61 -16
- package/src/config/model-profiles.ts +31 -15
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/question-state.ts +2 -0
- package/src/coordinator-mcp/server.ts +358 -7
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
- package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
- package/src/extensibility/extensions/runner.ts +15 -2
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/gjc-runtime/launch-tmux.ts +53 -1
- package/src/internal-urls/docs-index.generated.ts +11 -10
- package/src/main.ts +203 -67
- package/src/modes/DESIGN.md +8 -5
- package/src/modes/components/footer.ts +10 -18
- package/src/modes/components/session-selector.ts +26 -3
- package/src/modes/components/status-line/gh.ts +23 -2
- package/src/modes/controllers/event-controller.ts +3 -0
- package/src/modes/controllers/extension-ui-controller.ts +2 -0
- package/src/modes/controllers/input-controller.ts +11 -0
- package/src/modes/controllers/selector-controller.ts +8 -2
- package/src/modes/interactive-mode.ts +7 -1
- package/src/modes/utils/host-status.ts +38 -0
- package/src/runtime-mcp/types.ts +2 -0
- package/src/sdk/broker/lifecycle.ts +60 -3
- package/src/sdk/bus/chat-daemon-cli.ts +42 -0
- package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
- package/src/sdk/bus/chat-daemon-control.ts +16 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
- package/src/sdk/bus/control-plane-frames.ts +51 -0
- package/src/sdk/bus/conversation-store.ts +29 -1
- package/src/sdk/bus/existing-thread-readiness.ts +155 -0
- package/src/sdk/bus/index.ts +70 -0
- package/src/sdk/bus/notification-service.ts +28 -1
- package/src/sdk/bus/rich-render.ts +3 -2
- package/src/sdk/bus/slack-daemon.ts +99 -4
- package/src/sdk/bus/slack-live-provider.ts +32 -2
- package/src/sdk/bus/slack-provider.ts +11 -0
- package/src/sdk/bus/slack-thread-binding.ts +641 -0
- package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +24 -7
- package/src/sdk/bus/telegram-daemon.ts +1961 -497
- package/src/sdk/bus/telegram-reference.ts +42 -6
- package/src/sdk/bus/topic-registry.ts +674 -181
- package/src/sdk/host/host.ts +180 -1
- package/src/sdk/lifecycle-session.ts +8 -2
- package/src/sdk/protocol/operation-inventory.generated.json +22 -0
- package/src/sdk/session-activation.ts +127 -0
- package/src/sdk/session.ts +139 -28
- package/src/sdk/startup-capability.ts +10 -1
- package/src/session/agent-session.ts +220 -9
- package/src/session/artifacts.ts +56 -13
- package/src/session/internal/managed-session-storage.ts +206 -7
- package/src/session/session-manager.ts +148 -38
- package/src/setup/provider-presets.json +1 -1
- package/src/task/index.ts +92 -44
- package/src/tools/index.ts +8 -0
- package/src/utils/npm-registry.ts +660 -0
|
@@ -4,6 +4,7 @@ import * as crypto from "node:crypto";
|
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
|
+
import { exactReplacePath, type NativeExactFileIdentity, type NativeExactUnlinkResult } from "@gajae-code/natives";
|
|
7
8
|
import { logger } from "@gajae-code/utils";
|
|
8
9
|
import { withFileLock } from "../../config/file-lock";
|
|
9
10
|
import type { Settings } from "../../config/settings";
|
|
@@ -107,7 +108,13 @@ import {
|
|
|
107
108
|
} from "./telegram-reference";
|
|
108
109
|
import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound";
|
|
109
110
|
import { renderThreadedFrame, supportsTelegramPhotoUpload, type ThreadedSend } from "./threaded-render";
|
|
110
|
-
import {
|
|
111
|
+
import {
|
|
112
|
+
parseTopicRegistryState,
|
|
113
|
+
type TopicEndpointBinding,
|
|
114
|
+
TopicRegistry,
|
|
115
|
+
type TopicRegistryCasAuthority,
|
|
116
|
+
type TopicRegistryState,
|
|
117
|
+
} from "./topic-registry";
|
|
111
118
|
|
|
112
119
|
export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked";
|
|
113
120
|
/** Detailed result for orchestration that must distinguish a #2028 handoff from a fresh spawn. */
|
|
@@ -146,15 +153,27 @@ export interface DaemonState {
|
|
|
146
153
|
servingEpoch?: number;
|
|
147
154
|
stoppedAt?: number;
|
|
148
155
|
}
|
|
156
|
+
interface ExactFileStat {
|
|
157
|
+
dev: bigint;
|
|
158
|
+
ino: bigint;
|
|
159
|
+
nlink: bigint;
|
|
160
|
+
size: bigint;
|
|
161
|
+
mtimeNs: bigint;
|
|
162
|
+
isFile(): boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
149
165
|
export interface TelegramDaemonFs {
|
|
150
166
|
mkdir(path: string, opts?: fs.MakeDirectoryOptions): Promise<void>;
|
|
151
167
|
readFile(path: string, encoding: BufferEncoding): Promise<string>;
|
|
152
168
|
writeFile(path: string, data: string, opts?: fs.WriteFileOptions): Promise<void>;
|
|
153
169
|
rename(oldPath: string, newPath: string): Promise<void>;
|
|
154
170
|
unlink(path: string): Promise<void>;
|
|
155
|
-
open(path: string, flags: string, mode?: number): Promise<{ close(): Promise<void> }>;
|
|
171
|
+
open(path: string, flags: string, mode?: number): Promise<{ sync?: () => Promise<void>; close(): Promise<void> }>;
|
|
156
172
|
readdir(path: string): Promise<string[]>;
|
|
157
173
|
chmod(path: string, mode: number): Promise<void>;
|
|
174
|
+
/** Crash-atomic persistence seams. Implementations without them fail closed. */
|
|
175
|
+
fsyncFile?(path: string): Promise<void>;
|
|
176
|
+
fsyncDirectory?(path: string): Promise<void>;
|
|
158
177
|
stat?(path: string): Promise<{
|
|
159
178
|
mtimeMs: number;
|
|
160
179
|
size?: number;
|
|
@@ -163,8 +182,9 @@ export interface TelegramDaemonFs {
|
|
|
163
182
|
/** Hard-link count; required to prove a staging temp has no second name. */
|
|
164
183
|
nlink?: number;
|
|
165
184
|
ctimeMs?: number;
|
|
166
|
-
isDirectory
|
|
185
|
+
isDirectory?(): boolean;
|
|
167
186
|
}>;
|
|
187
|
+
lstat?(path: string, opts: { bigint: true }): Promise<ExactFileStat>;
|
|
168
188
|
readEndpointFile?(path: string): Promise<NotificationEndpointFile>;
|
|
169
189
|
exactUnlink?(
|
|
170
190
|
path: string,
|
|
@@ -233,6 +253,22 @@ function negotiateToolActivityCapability(
|
|
|
233
253
|
|
|
234
254
|
const nodeFs: TelegramDaemonFs = {
|
|
235
255
|
...(fs.promises as unknown as TelegramDaemonFs),
|
|
256
|
+
fsyncFile: async file => {
|
|
257
|
+
const handle = await fs.promises.open(file, "r+");
|
|
258
|
+
try {
|
|
259
|
+
await handle.sync();
|
|
260
|
+
} finally {
|
|
261
|
+
await handle.close();
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
fsyncDirectory: async directory => {
|
|
265
|
+
const handle = await fs.promises.open(directory, "r");
|
|
266
|
+
try {
|
|
267
|
+
await handle.sync();
|
|
268
|
+
} finally {
|
|
269
|
+
await handle.close();
|
|
270
|
+
}
|
|
271
|
+
},
|
|
236
272
|
readEndpointFile: readNotificationEndpointFile,
|
|
237
273
|
exactUnlink: async (file, identity, quarantineName) =>
|
|
238
274
|
exactUnlinkNotificationFile(
|
|
@@ -479,12 +515,15 @@ function endpointGenerationKey(url: string, token: string): string {
|
|
|
479
515
|
function topicRenameApplied(response: unknown): boolean {
|
|
480
516
|
return !!response && typeof response === "object" && (response as { ok?: unknown }).ok === true;
|
|
481
517
|
}
|
|
482
|
-
function
|
|
518
|
+
function topicArchiveSettled(response: unknown): boolean {
|
|
483
519
|
if (!response || typeof response !== "object") return false;
|
|
484
|
-
const result = response as { ok?: unknown; description?: unknown };
|
|
485
|
-
if (result.ok === true) return true;
|
|
486
|
-
if (typeof result.description !== "string") return false;
|
|
487
|
-
|
|
520
|
+
const result = response as { ok?: unknown; result?: unknown; error_code?: unknown; description?: unknown };
|
|
521
|
+
if (result.ok === true && result.result === true) return true;
|
|
522
|
+
if (result.ok !== false || result.error_code !== 400 || typeof result.description !== "string") return false;
|
|
523
|
+
const description = result.description.trim();
|
|
524
|
+
return /^(?:Bad Request: )?(?:TOPIC_NOT_FOUND|THREAD_NOT_FOUND|topic (?:already|is already) closed|message thread (?:not found|is not modified))$/i.test(
|
|
525
|
+
description,
|
|
526
|
+
);
|
|
488
527
|
}
|
|
489
528
|
|
|
490
529
|
/**
|
|
@@ -589,11 +628,428 @@ async function readJson<T>(fsImpl: TelegramDaemonFs, file: string): Promise<T |
|
|
|
589
628
|
}
|
|
590
629
|
}
|
|
591
630
|
|
|
592
|
-
async function writeJsonAtomic(
|
|
631
|
+
async function writeJsonAtomic(
|
|
632
|
+
fsImpl: TelegramDaemonFs,
|
|
633
|
+
file: string,
|
|
634
|
+
data: unknown,
|
|
635
|
+
options: { durable?: boolean } = {},
|
|
636
|
+
): Promise<void> {
|
|
593
637
|
const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
638
|
+
let renamed = false;
|
|
639
|
+
try {
|
|
640
|
+
await fsImpl.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
641
|
+
await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
|
|
642
|
+
if (options.durable) await syncTelegramFile(fsImpl, tmp);
|
|
643
|
+
await fsImpl.rename(tmp, file);
|
|
644
|
+
renamed = true;
|
|
645
|
+
if (options.durable) await syncTelegramDirectory(fsImpl, path.dirname(file));
|
|
646
|
+
} catch (error) {
|
|
647
|
+
if (renamed) throw error;
|
|
648
|
+
let cleanupError: unknown;
|
|
649
|
+
try {
|
|
650
|
+
await fsImpl.unlink(tmp);
|
|
651
|
+
} catch (candidate) {
|
|
652
|
+
if ((candidate as NodeJS.ErrnoException).code !== "ENOENT") cleanupError = candidate;
|
|
653
|
+
}
|
|
654
|
+
if (cleanupError) throw new AggregateError([error, cleanupError], "Atomic JSON write and cleanup failed");
|
|
655
|
+
throw error;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function syncTelegramFile(fsImpl: TelegramDaemonFs, file: string): Promise<void> {
|
|
660
|
+
const handle = await fsImpl.open(file, "r+");
|
|
661
|
+
let syncError: unknown;
|
|
662
|
+
try {
|
|
663
|
+
if (typeof handle.sync !== "function")
|
|
664
|
+
throw new Error("Telegram alias durability requires filesystem sync support");
|
|
665
|
+
await handle.sync();
|
|
666
|
+
} catch (error) {
|
|
667
|
+
syncError = error;
|
|
668
|
+
}
|
|
669
|
+
let closeError: unknown;
|
|
670
|
+
try {
|
|
671
|
+
await handle.close();
|
|
672
|
+
} catch (error) {
|
|
673
|
+
closeError = error;
|
|
674
|
+
}
|
|
675
|
+
if (syncError && closeError)
|
|
676
|
+
throw new AggregateError([syncError, closeError], "Telegram alias file sync and close failed");
|
|
677
|
+
if (syncError) throw syncError;
|
|
678
|
+
if (closeError) throw closeError;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function syncTelegramDirectory(fsImpl: TelegramDaemonFs, directory: string): Promise<void> {
|
|
682
|
+
let handle: Awaited<ReturnType<TelegramDaemonFs["open"]>>;
|
|
683
|
+
try {
|
|
684
|
+
handle = await fsImpl.open(directory, "r");
|
|
685
|
+
} catch (error) {
|
|
686
|
+
if (process.platform === "win32" && isUnsupportedTelegramDirectoryBarrier(error)) return;
|
|
687
|
+
throw error;
|
|
688
|
+
}
|
|
689
|
+
let syncError: unknown;
|
|
690
|
+
try {
|
|
691
|
+
if (typeof handle.sync !== "function")
|
|
692
|
+
throw new Error("Telegram alias durability requires filesystem sync support");
|
|
693
|
+
await handle.sync();
|
|
694
|
+
} catch (error) {
|
|
695
|
+
if (!(process.platform === "win32" && isUnsupportedTelegramDirectoryBarrier(error))) syncError = error;
|
|
696
|
+
}
|
|
697
|
+
let closeError: unknown;
|
|
698
|
+
try {
|
|
699
|
+
await handle.close();
|
|
700
|
+
} catch (error) {
|
|
701
|
+
closeError = error;
|
|
702
|
+
}
|
|
703
|
+
if (syncError && closeError)
|
|
704
|
+
throw new AggregateError([syncError, closeError], "Telegram alias directory sync and close failed");
|
|
705
|
+
if (syncError) throw syncError;
|
|
706
|
+
if (closeError) throw closeError;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function isUnsupportedTelegramDirectoryBarrier(error: unknown): boolean {
|
|
710
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
711
|
+
return code === "EINVAL" || code === "EISDIR" || code === "ENOTSUP" || code === "EPERM";
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export class TopicRegistryDurabilityUnavailableError extends Error {
|
|
715
|
+
readonly code = "durability_unavailable";
|
|
716
|
+
|
|
717
|
+
constructor(cause: unknown) {
|
|
718
|
+
super("topic registry durability is unavailable", { cause });
|
|
719
|
+
this.name = "TopicRegistryDurabilityUnavailableError";
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
type TopicRegistryExactReplace = (
|
|
724
|
+
sourcePath: string,
|
|
725
|
+
destinationPath: string,
|
|
726
|
+
expectedSource: NativeExactFileIdentity,
|
|
727
|
+
expectedDestination: NativeExactFileIdentity,
|
|
728
|
+
) => NativeExactUnlinkResult;
|
|
729
|
+
|
|
730
|
+
function sameExactFileStat(left: ExactFileStat, right: ExactFileStat): boolean {
|
|
731
|
+
return (
|
|
732
|
+
left.dev === right.dev &&
|
|
733
|
+
left.ino === right.ino &&
|
|
734
|
+
left.nlink === right.nlink &&
|
|
735
|
+
left.size === right.size &&
|
|
736
|
+
left.mtimeNs === right.mtimeNs
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function captureExactFileSnapshot(
|
|
741
|
+
fsImpl: TelegramDaemonFs,
|
|
742
|
+
file: string,
|
|
743
|
+
): Promise<{ bytes: string; identity: NativeExactFileIdentity }> {
|
|
744
|
+
if (!fsImpl.lstat)
|
|
745
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
746
|
+
new Error("topic registry persistence requires no-follow file identity"),
|
|
747
|
+
);
|
|
748
|
+
const before = await fsImpl.lstat(file, { bigint: true });
|
|
749
|
+
const bytes = await fsImpl.readFile(file, "utf8");
|
|
750
|
+
const after = await fsImpl.lstat(file, { bigint: true });
|
|
751
|
+
const parentStat = await fsImpl.lstat(path.dirname(file), { bigint: true });
|
|
752
|
+
if (!before.isFile() || !after.isFile() || before.nlink !== 1n || !sameExactFileStat(before, after))
|
|
753
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
754
|
+
new Error("topic registry persistence requires a stable single-link regular file"),
|
|
755
|
+
);
|
|
756
|
+
return {
|
|
757
|
+
bytes,
|
|
758
|
+
identity: {
|
|
759
|
+
dev: after.dev,
|
|
760
|
+
ino: after.ino,
|
|
761
|
+
nlink: after.nlink,
|
|
762
|
+
parentDev: parentStat.dev,
|
|
763
|
+
parentIno: parentStat.ino,
|
|
764
|
+
size: after.size,
|
|
765
|
+
mtimeNs: after.mtimeNs,
|
|
766
|
+
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
767
|
+
},
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
async function captureTopicRegistryDestination(
|
|
771
|
+
fsImpl: TelegramDaemonFs,
|
|
772
|
+
file: string,
|
|
773
|
+
platform: NodeJS.Platform,
|
|
774
|
+
): Promise<NativeExactFileIdentity | undefined> {
|
|
775
|
+
if (platform !== "win32") return undefined;
|
|
776
|
+
try {
|
|
777
|
+
return (await captureExactFileSnapshot(fsImpl, file)).identity;
|
|
778
|
+
} catch (error) {
|
|
779
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Publish topic authority only after its staged bytes and replacement are durable. */
|
|
785
|
+
export async function writeTopicRegistryAtomic(
|
|
786
|
+
fsImpl: TelegramDaemonFs,
|
|
787
|
+
file: string,
|
|
788
|
+
data: unknown,
|
|
789
|
+
platform: NodeJS.Platform = process.platform,
|
|
790
|
+
exactReplace: TopicRegistryExactReplace = exactReplacePath,
|
|
791
|
+
expectedDestination?: NativeExactFileIdentity,
|
|
792
|
+
): Promise<void> {
|
|
793
|
+
if (!fsImpl.fsyncFile)
|
|
794
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
795
|
+
new Error("topic registry persistence requires file fsync crash-atomicity"),
|
|
796
|
+
);
|
|
797
|
+
if (platform !== "win32" && !fsImpl.fsyncDirectory)
|
|
798
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
799
|
+
new Error("topic registry persistence requires directory fsync crash-atomicity"),
|
|
800
|
+
);
|
|
801
|
+
const parent = path.dirname(file);
|
|
802
|
+
const serialized = `${JSON.stringify(data, null, 2)}\n`;
|
|
803
|
+
let destinationExists = true;
|
|
804
|
+
try {
|
|
805
|
+
await fsImpl.readFile(file, "utf8");
|
|
806
|
+
} catch (error) {
|
|
807
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
808
|
+
destinationExists = false;
|
|
809
|
+
}
|
|
810
|
+
if (!destinationExists && expectedDestination)
|
|
811
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
812
|
+
new Error("topic registry destination disappeared after generation validation"),
|
|
813
|
+
);
|
|
814
|
+
if (!destinationExists) {
|
|
815
|
+
try {
|
|
816
|
+
await fsImpl.writeFile(file, serialized, { mode: 0o600, flag: "wx" });
|
|
817
|
+
} catch (error) {
|
|
818
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST")
|
|
819
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
820
|
+
new Error("topic registry appeared during exclusive creation"),
|
|
821
|
+
);
|
|
822
|
+
throw error;
|
|
823
|
+
}
|
|
824
|
+
await fsImpl.chmod(file, 0o600).catch(() => undefined);
|
|
825
|
+
await fsImpl.fsyncFile(file);
|
|
826
|
+
if (platform !== "win32") await fsImpl.fsyncDirectory!(parent);
|
|
827
|
+
const written = await fsImpl.readFile(file, "utf8");
|
|
828
|
+
if (written !== serialized)
|
|
829
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
830
|
+
new Error("exclusive topic registry creation verification failed"),
|
|
831
|
+
);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
836
|
+
try {
|
|
837
|
+
await fsImpl.writeFile(tmp, serialized, { mode: 0o600, flag: "wx" });
|
|
838
|
+
await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
|
|
839
|
+
await fsImpl.fsyncFile(tmp);
|
|
840
|
+
if (platform === "win32") {
|
|
841
|
+
if (typeof exactReplace !== "function")
|
|
842
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
843
|
+
new Error("native Windows exact replacement is unavailable"),
|
|
844
|
+
);
|
|
845
|
+
if (!expectedDestination)
|
|
846
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
847
|
+
new Error("native Windows replacement requires the validated destination identity"),
|
|
848
|
+
);
|
|
849
|
+
const expectedSource = (await captureExactFileSnapshot(fsImpl, tmp)).identity;
|
|
850
|
+
const outcome = exactReplace(tmp, file, expectedSource, expectedDestination);
|
|
851
|
+
if (!outcome.ok)
|
|
852
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
853
|
+
new Error(`native Windows topic replacement failed: ${outcome.code ?? "unknown"}`),
|
|
854
|
+
);
|
|
855
|
+
} else {
|
|
856
|
+
await fsImpl.rename(tmp, file);
|
|
857
|
+
await fsImpl.fsyncDirectory!(parent);
|
|
858
|
+
}
|
|
859
|
+
const written = await fsImpl.readFile(file, "utf8");
|
|
860
|
+
if (written !== serialized)
|
|
861
|
+
throw new TopicRegistryDurabilityUnavailableError(new Error("topic registry replacement verification failed"));
|
|
862
|
+
} catch (error) {
|
|
863
|
+
await fsImpl.unlink(tmp).catch(() => undefined);
|
|
864
|
+
throw error;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Shared-volume topic authority backed by the existing cross-process file lock.
|
|
869
|
+
* A missing authority is the only valid bootstrap state; malformed and future
|
|
870
|
+
* snapshots are never interpreted as empty state.
|
|
871
|
+
*/
|
|
872
|
+
export class FilesystemTopicRegistryCasAuthority implements TopicRegistryCasAuthority {
|
|
873
|
+
private readonly fsImpl: TelegramDaemonFs;
|
|
874
|
+
private readonly platform: NodeJS.Platform;
|
|
875
|
+
private readonly exactReplace: TopicRegistryExactReplace;
|
|
876
|
+
private readonly installationHostId: string;
|
|
877
|
+
|
|
878
|
+
constructor(
|
|
879
|
+
private readonly file: string,
|
|
880
|
+
input: {
|
|
881
|
+
installationHostId: string;
|
|
882
|
+
fs?: TelegramDaemonFs;
|
|
883
|
+
platform?: NodeJS.Platform;
|
|
884
|
+
exactReplace?: TopicRegistryExactReplace;
|
|
885
|
+
},
|
|
886
|
+
) {
|
|
887
|
+
if (!input.installationHostId) throw new Error("installationHostId must be non-empty");
|
|
888
|
+
this.installationHostId = input.installationHostId;
|
|
889
|
+
this.fsImpl = input.fs ?? nodeFs;
|
|
890
|
+
this.platform = input.platform ?? process.platform;
|
|
891
|
+
this.exactReplace = input.exactReplace ?? exactReplacePath;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async read(): Promise<TopicRegistryState> {
|
|
895
|
+
return await withFileLock(this.file, async () => await this.readLocked(), {
|
|
896
|
+
staleMs: 10_000,
|
|
897
|
+
ownerHostId: this.installationHostId,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async compareAndSet(expectedGeneration: number, next: TopicRegistryState): Promise<boolean> {
|
|
902
|
+
if (!Number.isSafeInteger(expectedGeneration) || expectedGeneration < 0)
|
|
903
|
+
throw new Error("invalid expected topic registry generation");
|
|
904
|
+
if (
|
|
905
|
+
next.version !== 2 ||
|
|
906
|
+
next.registryGeneration !== expectedGeneration + 1 ||
|
|
907
|
+
parseTopicRegistryState(next) === undefined
|
|
908
|
+
)
|
|
909
|
+
throw new Error("invalid next topic registry generation");
|
|
910
|
+
return await withFileLock(
|
|
911
|
+
this.file,
|
|
912
|
+
async () => {
|
|
913
|
+
const { state: current, legacyRaw, expectedDestination } = await this.#readLockedWithLegacy();
|
|
914
|
+
if (current.registryGeneration !== expectedGeneration) return false;
|
|
915
|
+
await ensureDir(this.fsImpl, path.dirname(this.file));
|
|
916
|
+
if (legacyRaw !== undefined) await this.#quarantineLegacyLocked(legacyRaw);
|
|
917
|
+
await writeTopicRegistryAtomic(
|
|
918
|
+
this.fsImpl,
|
|
919
|
+
this.file,
|
|
920
|
+
next,
|
|
921
|
+
this.platform,
|
|
922
|
+
this.exactReplace,
|
|
923
|
+
expectedDestination,
|
|
924
|
+
);
|
|
925
|
+
return true;
|
|
926
|
+
},
|
|
927
|
+
{ staleMs: 10_000, ownerHostId: this.installationHostId },
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
private async readLocked(): Promise<TopicRegistryState> {
|
|
932
|
+
return (await this.#readLockedWithLegacy()).state;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
async #readLockedWithLegacy(): Promise<{
|
|
936
|
+
state: TopicRegistryState;
|
|
937
|
+
legacyRaw?: unknown;
|
|
938
|
+
expectedDestination?: NativeExactFileIdentity;
|
|
939
|
+
}> {
|
|
940
|
+
let raw: unknown;
|
|
941
|
+
let expectedDestination: NativeExactFileIdentity | undefined;
|
|
942
|
+
try {
|
|
943
|
+
if (this.platform === "win32") {
|
|
944
|
+
const snapshot = await captureExactFileSnapshot(this.fsImpl, this.file);
|
|
945
|
+
raw = JSON.parse(snapshot.bytes);
|
|
946
|
+
expectedDestination = snapshot.identity;
|
|
947
|
+
} else {
|
|
948
|
+
raw = JSON.parse(await this.fsImpl.readFile(this.file, "utf8"));
|
|
949
|
+
}
|
|
950
|
+
} catch (error) {
|
|
951
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
952
|
+
return { state: { version: 2, registryGeneration: 0, topics: {} } };
|
|
953
|
+
throw new Error("shared topic authority is malformed or unavailable");
|
|
954
|
+
}
|
|
955
|
+
const legacyRaw =
|
|
956
|
+
raw && typeof raw === "object" && !Array.isArray(raw) && !Object.hasOwn(raw as object, "version")
|
|
957
|
+
? raw
|
|
958
|
+
: undefined;
|
|
959
|
+
const state = parseTopicRegistryState(raw);
|
|
960
|
+
const generation = state?.registryGeneration;
|
|
961
|
+
if (state?.version !== 2 || generation === undefined || !Number.isSafeInteger(generation) || generation < 0)
|
|
962
|
+
throw new Error("shared topic authority is malformed or unsupported");
|
|
963
|
+
return {
|
|
964
|
+
state: { ...state, registryGeneration: generation },
|
|
965
|
+
...(legacyRaw === undefined ? {} : { legacyRaw }),
|
|
966
|
+
...(expectedDestination === undefined ? {} : { expectedDestination }),
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async #quarantineLegacyLocked(raw: unknown): Promise<void> {
|
|
971
|
+
const digest = crypto.createHash("sha256").update(JSON.stringify(raw)).digest("hex");
|
|
972
|
+
const quarantinePath = `${this.file}.legacy-quarantine.${digest}.json`;
|
|
973
|
+
try {
|
|
974
|
+
await this.fsImpl.readFile(quarantinePath, "utf8");
|
|
975
|
+
return;
|
|
976
|
+
} catch (error) {
|
|
977
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
|
|
978
|
+
throw new Error("shared topic authority is malformed or unavailable");
|
|
979
|
+
}
|
|
980
|
+
await writeTopicRegistryAtomic(this.fsImpl, quarantinePath, raw, this.platform, this.exactReplace);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
export interface MachineIdentityDeps {
|
|
985
|
+
platform?: NodeJS.Platform;
|
|
986
|
+
readFile?: (file: string) => Promise<string>;
|
|
987
|
+
runCommand?: (command: string, args: readonly string[]) => { exitCode: number; stdout: Uint8Array };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function normalizedMachineIdentity(value: string, pattern: RegExp): string | undefined {
|
|
991
|
+
const normalized = value.trim().toLowerCase();
|
|
992
|
+
if (!pattern.test(normalized) || /^0+$/.test(normalized.replace(/-/g, ""))) return undefined;
|
|
993
|
+
return normalized;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** @internal */
|
|
997
|
+
export function parseWindowsMachineGuid(output: string): string | undefined {
|
|
998
|
+
const match = /^\s*MachineGuid\s+REG_\w+\s+(\S+)\s*$/im.exec(output);
|
|
999
|
+
return match
|
|
1000
|
+
? normalizedMachineIdentity(match[1], /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
|
|
1001
|
+
: undefined;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** @internal */
|
|
1005
|
+
export function parseMacPlatformUuid(output: string): string | undefined {
|
|
1006
|
+
const match = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(output);
|
|
1007
|
+
return match
|
|
1008
|
+
? normalizedMachineIdentity(match[1], /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
|
|
1009
|
+
: undefined;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function hashMachineIdentity(rawId: string): string {
|
|
1013
|
+
return crypto
|
|
1014
|
+
.createHash("sha256")
|
|
1015
|
+
.update("gajae-code:telegram-daemon:machine-identity:v1\0")
|
|
1016
|
+
.update(rawId)
|
|
1017
|
+
.digest("hex");
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** Loads a verified machine-local identity without persisting the underlying machine ID. */
|
|
1021
|
+
export async function loadInstallationHostId(deps: MachineIdentityDeps = {}): Promise<string> {
|
|
1022
|
+
const platform = deps.platform ?? process.platform;
|
|
1023
|
+
const readFile = deps.readFile ?? (async (file: string) => await fs.promises.readFile(file, "utf8"));
|
|
1024
|
+
const runCommand =
|
|
1025
|
+
deps.runCommand ??
|
|
1026
|
+
((command: string, args: readonly string[]) =>
|
|
1027
|
+
Bun.spawnSync([command, ...args], { stdout: "pipe", stderr: "ignore" }));
|
|
1028
|
+
|
|
1029
|
+
let rawId: string | undefined;
|
|
1030
|
+
if (platform === "linux") {
|
|
1031
|
+
for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
1032
|
+
try {
|
|
1033
|
+
const value = normalizedMachineIdentity(await readFile(file), /^[0-9a-f]{32}$/);
|
|
1034
|
+
if (!value) throw new Error("machine-local identity is unavailable or malformed");
|
|
1035
|
+
rawId = value;
|
|
1036
|
+
break;
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
} else if (platform === "win32") {
|
|
1042
|
+
const result = runCommand("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"]);
|
|
1043
|
+
if (result.exitCode === 0) rawId = parseWindowsMachineGuid(new TextDecoder().decode(result.stdout));
|
|
1044
|
+
} else if (platform === "darwin") {
|
|
1045
|
+
const result = runCommand("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]);
|
|
1046
|
+
if (result.exitCode === 0) rawId = parseMacPlatformUuid(new TextDecoder().decode(result.stdout));
|
|
1047
|
+
} else {
|
|
1048
|
+
throw new Error(`machine-local identity is unsupported on ${platform}`);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (!rawId) throw new Error("machine-local identity is unavailable or malformed");
|
|
1052
|
+
return hashMachineIdentity(rawId);
|
|
597
1053
|
}
|
|
598
1054
|
|
|
599
1055
|
function validDaemonPid(pid: unknown): pid is number {
|
|
@@ -2184,7 +2640,8 @@ export function isCurrentCompatibleOwner(input: {
|
|
|
2184
2640
|
state?.ownershipPhase === "ready" &&
|
|
2185
2641
|
typeof state.acquisitionId === "string" &&
|
|
2186
2642
|
state.acquisitionId.length > 0 &&
|
|
2187
|
-
|
|
2643
|
+
state.generation === DAEMON_GENERATION &&
|
|
2644
|
+
state.servingEpoch === SERVING_EPOCH,
|
|
2188
2645
|
);
|
|
2189
2646
|
}
|
|
2190
2647
|
|
|
@@ -3122,6 +3579,8 @@ export interface TelegramSpawnOwnerInput {
|
|
|
3122
3579
|
roots?: string[];
|
|
3123
3580
|
tokenFingerprint: string;
|
|
3124
3581
|
chatId: string;
|
|
3582
|
+
/** Ephemeral outbound-only validation destination for this owner launch. */
|
|
3583
|
+
validationTestSupergroupChatId?: string;
|
|
3125
3584
|
}
|
|
3126
3585
|
|
|
3127
3586
|
export interface TelegramSpawnAcquisition {
|
|
@@ -3149,7 +3608,12 @@ export type TelegramSpawnOwnerResult =
|
|
|
3149
3608
|
* Source mode prepends the entry script so the respawn loads edited source;
|
|
3150
3609
|
* a compiled binary self-spawns its own subcommand directly.
|
|
3151
3610
|
*/
|
|
3152
|
-
export function buildTelegramDaemonSpawnArgs(input: {
|
|
3611
|
+
export function buildTelegramDaemonSpawnArgs(input: {
|
|
3612
|
+
execPath?: string;
|
|
3613
|
+
ownerId: string;
|
|
3614
|
+
agentDir: string;
|
|
3615
|
+
validationTestSupergroupChatId?: string;
|
|
3616
|
+
}): {
|
|
3153
3617
|
command: string;
|
|
3154
3618
|
args: string[];
|
|
3155
3619
|
runtime: DaemonRuntimeInfo;
|
|
@@ -3163,6 +3627,9 @@ export function buildTelegramDaemonSpawnArgs(input: { execPath?: string; ownerId
|
|
|
3163
3627
|
input.ownerId,
|
|
3164
3628
|
"--agent-dir",
|
|
3165
3629
|
input.agentDir,
|
|
3630
|
+
...(input.validationTestSupergroupChatId !== undefined
|
|
3631
|
+
? ["--validation-test-supergroup-chat-id", input.validationTestSupergroupChatId]
|
|
3632
|
+
: []),
|
|
3166
3633
|
];
|
|
3167
3634
|
const runtime: DaemonRuntimeInfo = {
|
|
3168
3635
|
mode: rt.mode,
|
|
@@ -3238,6 +3705,7 @@ export async function spawnTelegramDaemonOwner(
|
|
|
3238
3705
|
execPath,
|
|
3239
3706
|
ownerId: provisionalAcquisition.ownerId,
|
|
3240
3707
|
agentDir,
|
|
3708
|
+
validationTestSupergroupChatId: input.validationTestSupergroupChatId,
|
|
3241
3709
|
});
|
|
3242
3710
|
const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
|
|
3243
3711
|
const child = spawnImpl(command, args, {
|
|
@@ -3419,13 +3887,17 @@ async function ensureTelegramDaemonRunningDetailedOnce(
|
|
|
3419
3887
|
// unproven cooperative handoff into destructive cleanup or a replacement spawn.
|
|
3420
3888
|
if ((deps.platform ?? process.platform) === "win32") {
|
|
3421
3889
|
const parentState: unknown = await readDaemonState(input.settings, deps.fs);
|
|
3422
|
-
if (
|
|
3890
|
+
if (
|
|
3891
|
+
isParentDaemonState(parentState) &&
|
|
3892
|
+
(deps.pidAlive ?? defaultPidAlive)(parentState.pid) &&
|
|
3893
|
+
(!hasSafeDaemonStateShape(parentState) || ownerProvenanceMatches(parentState, deps.pidIncarnation))
|
|
3894
|
+
)
|
|
3423
3895
|
return "blocked_identity";
|
|
3424
3896
|
}
|
|
3425
3897
|
// Windows can retain dead launcher metadata without an ownership lock; reclaim
|
|
3426
3898
|
// its dead discovery records before the replacement can publish a new owner.
|
|
3427
3899
|
if ((deps.platform ?? process.platform) === "win32" && !deps.fs) {
|
|
3428
|
-
|
|
3900
|
+
let preflight = await reclaimDeadDaemonOwner({
|
|
3429
3901
|
settings: input.settings,
|
|
3430
3902
|
endpointDir: path.join(root, "sdk"),
|
|
3431
3903
|
fs: deps.fs,
|
|
@@ -3433,6 +3905,28 @@ async function ensureTelegramDaemonRunningDetailedOnce(
|
|
|
3433
3905
|
pidAlive: deps.pidAlive,
|
|
3434
3906
|
pidIncarnation: deps.pidIncarnation,
|
|
3435
3907
|
});
|
|
3908
|
+
// A just-reused PID can be reported alive for the first probe. Recheck once
|
|
3909
|
+
// before acquisition only when stale discovery records need fenced cleanup.
|
|
3910
|
+
// reclaimDeadDaemonOwner still requires exact state, lock, incarnation,
|
|
3911
|
+
// transition-lock, and endpoint-identity proofs before deleting anything.
|
|
3912
|
+
const endpointNames =
|
|
3913
|
+
preflight.reason === "not-confirmed-dead"
|
|
3914
|
+
? await (deps.fs ?? nodeFs).readdir(path.join(root, "sdk")).catch(() => undefined)
|
|
3915
|
+
: undefined;
|
|
3916
|
+
if (
|
|
3917
|
+
!preflight.recovered &&
|
|
3918
|
+
preflight.reason === "not-confirmed-dead" &&
|
|
3919
|
+
endpointNames?.some(name => name.endsWith(".json"))
|
|
3920
|
+
) {
|
|
3921
|
+
preflight = await reclaimDeadDaemonOwner({
|
|
3922
|
+
settings: input.settings,
|
|
3923
|
+
endpointDir: path.join(root, "sdk"),
|
|
3924
|
+
fs: deps.fs,
|
|
3925
|
+
now: deps.now,
|
|
3926
|
+
pidAlive: deps.pidAlive,
|
|
3927
|
+
pidIncarnation: deps.pidIncarnation,
|
|
3928
|
+
});
|
|
3929
|
+
}
|
|
3436
3930
|
if (!preflight.recovered && preflight.reason !== "not-confirmed-dead") {
|
|
3437
3931
|
logger.warn(
|
|
3438
3932
|
`notifications: startup recovery unsafe (${preflight.reason}); run \`gjc notify recovery\` for diagnostics`,
|
|
@@ -3849,7 +4343,7 @@ export class TelegramBotTransport implements BotApi {
|
|
|
3849
4343
|
}
|
|
3850
4344
|
}
|
|
3851
4345
|
|
|
3852
|
-
type PairedChatPrivacy = "private" | "non-private" | "indeterminate";
|
|
4346
|
+
type PairedChatPrivacy = "private" | "validation-forum" | "non-private" | "indeterminate";
|
|
3853
4347
|
|
|
3854
4348
|
export type TelegramUpdateOutcome = "consumed" | "retry";
|
|
3855
4349
|
|
|
@@ -4065,6 +4559,12 @@ export interface TelegramDaemonOptions {
|
|
|
4065
4559
|
ownerId: string;
|
|
4066
4560
|
botToken: string;
|
|
4067
4561
|
chatId: string;
|
|
4562
|
+
/**
|
|
4563
|
+
* Exact ephemeral forum destination supplied to the daemon-internal validation
|
|
4564
|
+
* command. Production ownership, pairing, and durable topic authority remain
|
|
4565
|
+
* bound to `chatId`.
|
|
4566
|
+
*/
|
|
4567
|
+
validationTestSupergroupChatId?: string;
|
|
4068
4568
|
apiBase?: string;
|
|
4069
4569
|
fetchImpl?: typeof fetch;
|
|
4070
4570
|
fs?: TelegramDaemonFs;
|
|
@@ -4116,6 +4616,20 @@ export interface TelegramDaemonOptions {
|
|
|
4116
4616
|
* built-in `{repo}/{branch} - {title}` composition and its fallbacks.
|
|
4117
4617
|
*/
|
|
4118
4618
|
topics?: { nameTemplate?: string };
|
|
4619
|
+
/**
|
|
4620
|
+
* Optional compare-and-set store for installations that share topic state
|
|
4621
|
+
* across hosts. When configured, every publication is fenced by it.
|
|
4622
|
+
*/
|
|
4623
|
+
topicRegistryAuthority?: TopicRegistryCasAuthority;
|
|
4624
|
+
/** Stable host-local identity. Required whenever a shared authority is configured. */
|
|
4625
|
+
installationHostId?: string;
|
|
4626
|
+
}
|
|
4627
|
+
|
|
4628
|
+
interface StagedCallbackActivation {
|
|
4629
|
+
pending: { sessionId: string; actionId: string };
|
|
4630
|
+
socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
4631
|
+
topicLease?: TopicAuthorityLease;
|
|
4632
|
+
settled: Promise<boolean>;
|
|
4119
4633
|
}
|
|
4120
4634
|
|
|
4121
4635
|
interface SessionSocket {
|
|
@@ -4226,7 +4740,8 @@ interface PendingThreadedFrame {
|
|
|
4226
4740
|
send: ThreadedSend;
|
|
4227
4741
|
msg: Record<string, unknown>;
|
|
4228
4742
|
logicalSessionId: string;
|
|
4229
|
-
|
|
4743
|
+
session: SessionSocket;
|
|
4744
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
4230
4745
|
toolActivity?: ToolActivityOwner;
|
|
4231
4746
|
}
|
|
4232
4747
|
|
|
@@ -4246,6 +4761,37 @@ interface BotApiCallResult {
|
|
|
4246
4761
|
response: unknown;
|
|
4247
4762
|
outcome: BotApiCallOutcome;
|
|
4248
4763
|
}
|
|
4764
|
+
function createBotApiAdapter(
|
|
4765
|
+
call: (
|
|
4766
|
+
method: string,
|
|
4767
|
+
body: unknown,
|
|
4768
|
+
callOpts?: { signal?: AbortSignal; noRetry?: boolean },
|
|
4769
|
+
) => Promise<BotApiCallResult>,
|
|
4770
|
+
): BotApi {
|
|
4771
|
+
return {
|
|
4772
|
+
call: async (method, body, callOpts) => (await call(method, body, callOpts)).response,
|
|
4773
|
+
};
|
|
4774
|
+
}
|
|
4775
|
+
function createBotApiPipeline(
|
|
4776
|
+
rawBotApi: BotApi,
|
|
4777
|
+
callBotApi: (
|
|
4778
|
+
rawBotApi: BotApi,
|
|
4779
|
+
method: string,
|
|
4780
|
+
body: unknown,
|
|
4781
|
+
callOpts?: { signal?: AbortSignal; noRetry?: boolean },
|
|
4782
|
+
) => Promise<BotApiCallResult>,
|
|
4783
|
+
): {
|
|
4784
|
+
classified: (
|
|
4785
|
+
method: string,
|
|
4786
|
+
body: unknown,
|
|
4787
|
+
callOpts?: { signal?: AbortSignal; noRetry?: boolean },
|
|
4788
|
+
) => Promise<BotApiCallResult>;
|
|
4789
|
+
api: BotApi;
|
|
4790
|
+
} {
|
|
4791
|
+
const classified = (method: string, body: unknown, callOpts?: { signal?: AbortSignal; noRetry?: boolean }) =>
|
|
4792
|
+
callBotApi(rawBotApi, method, body, callOpts);
|
|
4793
|
+
return { classified, api: createBotApiAdapter(classified) };
|
|
4794
|
+
}
|
|
4249
4795
|
|
|
4250
4796
|
function classifyBotApiCallOutcome(response: unknown, cooldownSuppressed = false): BotApiCallOutcome {
|
|
4251
4797
|
if (cooldownSuppressed) return { kind: "retryable", retryAfterMs: 0 };
|
|
@@ -4329,6 +4875,7 @@ interface PendingBtwDelivery {
|
|
|
4329
4875
|
|
|
4330
4876
|
class TelegramEffectSupervisor {
|
|
4331
4877
|
#stopping = false;
|
|
4878
|
+
#admitting = true;
|
|
4332
4879
|
readonly #abort = new AbortController();
|
|
4333
4880
|
readonly #terminalContext = new AsyncLocalStorage<boolean>();
|
|
4334
4881
|
readonly #pending = new Set<Promise<unknown>>();
|
|
@@ -4350,6 +4897,16 @@ class TelegramEffectSupervisor {
|
|
|
4350
4897
|
return this.track(api.call(method, body, { ...opts, signal }));
|
|
4351
4898
|
}
|
|
4352
4899
|
|
|
4900
|
+
admit<T>(effect: () => Promise<T>): Promise<T> {
|
|
4901
|
+
if (!this.#admitting)
|
|
4902
|
+
return Promise.reject(Object.assign(new Error("Daemon is stopping"), { name: "AbortError" }));
|
|
4903
|
+
try {
|
|
4904
|
+
return this.track(effect());
|
|
4905
|
+
} catch (error) {
|
|
4906
|
+
return Promise.reject(error);
|
|
4907
|
+
}
|
|
4908
|
+
}
|
|
4909
|
+
|
|
4353
4910
|
track<T>(effect: Promise<T>): Promise<T> {
|
|
4354
4911
|
this.#pending.add(effect);
|
|
4355
4912
|
void effect.then(
|
|
@@ -4363,7 +4920,12 @@ class TelegramEffectSupervisor {
|
|
|
4363
4920
|
return this.#stopping;
|
|
4364
4921
|
}
|
|
4365
4922
|
|
|
4923
|
+
closeAdmission(): void {
|
|
4924
|
+
this.#admitting = false;
|
|
4925
|
+
}
|
|
4926
|
+
|
|
4366
4927
|
beginShutdown(): void {
|
|
4928
|
+
this.closeAdmission();
|
|
4367
4929
|
this.#stopping = true;
|
|
4368
4930
|
this.#abort.abort("daemon_shutdown");
|
|
4369
4931
|
}
|
|
@@ -4388,7 +4950,18 @@ class TelegramEffectSupervisor {
|
|
|
4388
4950
|
|
|
4389
4951
|
export class TelegramNotificationDaemon {
|
|
4390
4952
|
readonly aliasTable: AliasTable;
|
|
4953
|
+
/** Callback aliases are routable only by the immutable socket lease that rendered them. */
|
|
4954
|
+
#callbackAliasLeases = new Map<string, { session: SessionSocket; token: number; logicalSessionId: string }>();
|
|
4955
|
+
/** Each active alias is bound to the exact pending ask object that rendered it. */
|
|
4956
|
+
#callbackAliasPending = new Map<string, { sessionId: string; actionId: string }>();
|
|
4957
|
+
/** Active callbacks preserve the exact topic authority under which their keyboard was rendered. */
|
|
4958
|
+
#callbackAliasTopics = new Map<string, TopicAuthorityLease>();
|
|
4959
|
+
/** Exposed aliases wait for their accepted receipt persistence before routing. */
|
|
4960
|
+
#stagedCallbackActivations = new Map<string, StagedCallbackActivation>();
|
|
4391
4961
|
readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
|
|
4962
|
+
/** Restart-revoked aliases retained only to terminalize their old keyboards on an authoritative replay. */
|
|
4963
|
+
private readonly reissueBacklog = new Map<string, CallbackRoute>();
|
|
4964
|
+
private aliasPersistenceQueue: Promise<void> = Promise.resolve();
|
|
4392
4965
|
/** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
|
|
4393
4966
|
private readonly liveMessages = new Map<string, number>();
|
|
4394
4967
|
/** Endpoint-bound ownership for visible or dispatching tool bubbles. */
|
|
@@ -4432,6 +5005,8 @@ export class TelegramNotificationDaemon {
|
|
|
4432
5005
|
private readonly botApi: BotApi;
|
|
4433
5006
|
private readonly effects = new TelegramEffectSupervisor();
|
|
4434
5007
|
private readonly topics = new TopicRegistry();
|
|
5008
|
+
/** Stable host-local identity; never persisted in shared topic authority. */
|
|
5009
|
+
private installationHostId: string;
|
|
4435
5010
|
/**
|
|
4436
5011
|
* Telegram may accept createForumTopic while returning an unusable success
|
|
4437
5012
|
* payload. Remember that ambiguity per endpoint so later frames cannot repeat
|
|
@@ -4446,6 +5021,11 @@ export class TelegramNotificationDaemon {
|
|
|
4446
5021
|
private recoveryBindingClaimQueue: Promise<void> = Promise.resolve();
|
|
4447
5022
|
/** Durable compensation fences retry under supervision until persistence succeeds. */
|
|
4448
5023
|
private readonly compensationFenceRetries = new Map<string, Promise<void>>();
|
|
5024
|
+
/** All archive paths for one session share one durable fence and remote dispatch. */
|
|
5025
|
+
private readonly archiveFlights = new Map<
|
|
5026
|
+
string,
|
|
5027
|
+
Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled">
|
|
5028
|
+
>();
|
|
4449
5029
|
|
|
4450
5030
|
/** Daemon edit attempts that can race an accepted user service message. */
|
|
4451
5031
|
private readonly daemonRenameAttempts = new Map<string, number>();
|
|
@@ -4486,8 +5066,8 @@ export class TelegramNotificationDaemon {
|
|
|
4486
5066
|
private threadedFallbackNoticeSent = false;
|
|
4487
5067
|
/** Sessions whose identity header was already sent flat (Threaded Mode off). */
|
|
4488
5068
|
private readonly flatIdentitySent = new Set<string>();
|
|
4489
|
-
/** Cached
|
|
4490
|
-
private
|
|
5069
|
+
/** Cached delivery boundary for the private owner chat or validation forum. */
|
|
5070
|
+
private pairedChatPrivacy: PairedChatPrivacy | undefined;
|
|
4491
5071
|
/** Bot username from getMe, cached once at owner startup for group/forum command targeting. */
|
|
4492
5072
|
private botUsername: string | undefined;
|
|
4493
5073
|
/** Sessions whose agent loop is currently busy (drives the typing indicator). */
|
|
@@ -4555,6 +5135,7 @@ export class TelegramNotificationDaemon {
|
|
|
4555
5135
|
*/
|
|
4556
5136
|
requestStop(_reason?: "reload" | "stop" | "signal"): void {
|
|
4557
5137
|
this.stopRequested = true;
|
|
5138
|
+
this.effects.closeAdmission();
|
|
4558
5139
|
|
|
4559
5140
|
const toolShutdown = this.beginToolActivityShutdown();
|
|
4560
5141
|
void toolShutdown
|
|
@@ -4919,7 +5500,8 @@ export class TelegramNotificationDaemon {
|
|
|
4919
5500
|
const outcome = classifyBotApiCallOutcome(undefined, true);
|
|
4920
5501
|
return { response: undefined, outcome };
|
|
4921
5502
|
}
|
|
4922
|
-
const
|
|
5503
|
+
const outboundBody = this.validationTopicDestination(method, body);
|
|
5504
|
+
const response = await this.effects.call(rawBotApi, method, outboundBody, callOpts);
|
|
4923
5505
|
const outcome = classifyBotApiCallOutcome(response);
|
|
4924
5506
|
if (outcome.kind === "retryable")
|
|
4925
5507
|
this.botCooldownUntil = Math.max(
|
|
@@ -4928,6 +5510,20 @@ export class TelegramNotificationDaemon {
|
|
|
4928
5510
|
);
|
|
4929
5511
|
return { response, outcome };
|
|
4930
5512
|
}
|
|
5513
|
+
private validationTopicDestination(method: string, body: unknown): unknown {
|
|
5514
|
+
if (
|
|
5515
|
+
!this.validationMode() ||
|
|
5516
|
+
method === "getChat" ||
|
|
5517
|
+
!body ||
|
|
5518
|
+
typeof body !== "object" ||
|
|
5519
|
+
String((body as { chat_id?: unknown }).chat_id) !== String(this.opts.chatId)
|
|
5520
|
+
)
|
|
5521
|
+
return body;
|
|
5522
|
+
return {
|
|
5523
|
+
...(body as Record<string, unknown>),
|
|
5524
|
+
chat_id: this.opts.validationTestSupergroupChatId!,
|
|
5525
|
+
};
|
|
5526
|
+
}
|
|
4931
5527
|
|
|
4932
5528
|
private readonly callBotApiClassified: (
|
|
4933
5529
|
method: string,
|
|
@@ -4948,6 +5544,9 @@ export class TelegramNotificationDaemon {
|
|
|
4948
5544
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
4949
5545
|
this.fsImpl = opts.fs ?? nodeFs;
|
|
4950
5546
|
this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
|
|
5547
|
+
if (opts.topicRegistryAuthority && !opts.installationHostId)
|
|
5548
|
+
throw new Error("shared topic authority requires a host-local installation identity");
|
|
5549
|
+
this.installationHostId = opts.installationHostId ?? crypto.randomUUID();
|
|
4951
5550
|
this.aliasTable = createAliasTable();
|
|
4952
5551
|
this.#adoptionIntentTtlMs = opts.adoptionIntentTtlMs ?? DEFAULT_ADOPTION_INTENT_TTL_MS;
|
|
4953
5552
|
const adoptionFs: AdoptionIntentFs = {
|
|
@@ -4981,10 +5580,9 @@ export class TelegramNotificationDaemon {
|
|
|
4981
5580
|
fetchImpl: opts.fetchImpl,
|
|
4982
5581
|
setTimeoutImpl: opts.setTimeoutImpl,
|
|
4983
5582
|
});
|
|
4984
|
-
|
|
4985
|
-
this.
|
|
4986
|
-
|
|
4987
|
-
};
|
|
5583
|
+
const botApiPipeline = createBotApiPipeline(rawBotApi, this.callBotApi.bind(this));
|
|
5584
|
+
this.callBotApiClassified = botApiPipeline.classified;
|
|
5585
|
+
this.botApi = botApiPipeline.api;
|
|
4988
5586
|
this.runtime = new NotificationOperatorRuntime({
|
|
4989
5587
|
now: opts.now,
|
|
4990
5588
|
setTimeoutImpl: opts.setTimeoutImpl,
|
|
@@ -5058,7 +5656,6 @@ export class TelegramNotificationDaemon {
|
|
|
5058
5656
|
if (!socketLease) return;
|
|
5059
5657
|
const topicLease = this.topicAuthorityLeaseFromRegistry(logicalSessionId);
|
|
5060
5658
|
if (
|
|
5061
|
-
this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
|
|
5062
5659
|
this.topics.get(logicalSessionId)?.bindingMalformed ||
|
|
5063
5660
|
(mode === "recovery" && (!topicLease || msg.sessionId !== logicalSessionId))
|
|
5064
5661
|
) {
|
|
@@ -5217,40 +5814,49 @@ export class TelegramNotificationDaemon {
|
|
|
5217
5814
|
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
5218
5815
|
|
|
5219
5816
|
const closedBinding = this.#endpointBinding(session);
|
|
5220
|
-
const
|
|
5817
|
+
const closedBindingMatches = () => {
|
|
5818
|
+
const current = this.closedEndpointKeys.get(session.sessionId);
|
|
5819
|
+
return (
|
|
5820
|
+
current?.chatId === closedBinding.chatId &&
|
|
5821
|
+
current.endpointKey === closedBinding.endpointKey &&
|
|
5822
|
+
current.endpointDigest === closedBinding.endpointDigest &&
|
|
5823
|
+
current.endpointGeneration === closedBinding.endpointGeneration
|
|
5824
|
+
);
|
|
5825
|
+
};
|
|
5826
|
+
const closeTopicAuthority = this.topics.captureArchiveAuthority(logicalSessionId);
|
|
5221
5827
|
const previousClosedBinding = this.closedEndpointKeys.get(session.sessionId);
|
|
5222
5828
|
await this.#persistTopicMutation(
|
|
5223
5829
|
() => {
|
|
5224
5830
|
this.closedEndpointKeys.set(session.sessionId, closedBinding);
|
|
5225
|
-
this.topics.
|
|
5831
|
+
this.topics.beginArchive(logicalSessionId, this.installationHostId, this.runtime.now());
|
|
5226
5832
|
},
|
|
5227
5833
|
() => {
|
|
5228
|
-
this.topics.
|
|
5834
|
+
this.topics.restoreArchiveAuthority(closeTopicAuthority);
|
|
5229
5835
|
if (previousClosedBinding === undefined) this.closedEndpointKeys.delete(session.sessionId);
|
|
5230
|
-
else if (
|
|
5836
|
+
else if (closedBindingMatches())
|
|
5231
5837
|
this.closedEndpointKeys.set(session.sessionId, previousClosedBinding);
|
|
5232
5838
|
},
|
|
5233
5839
|
);
|
|
5234
5840
|
if (
|
|
5235
|
-
|
|
5236
|
-
(
|
|
5237
|
-
session.recoveryLease?.token !== socketLease.token ||
|
|
5238
|
-
|
|
5239
|
-
|
|
5841
|
+
this.sessions.get(session.sessionId) !== session ||
|
|
5842
|
+
(socketLease &&
|
|
5843
|
+
(session.recoveryLease?.token !== socketLease.token ||
|
|
5844
|
+
session.recoveryLease.state !== "authorized" ||
|
|
5845
|
+
this.logicalSessionOwners.get(logicalSessionId) !== session))
|
|
5240
5846
|
) {
|
|
5241
5847
|
// A replacement won after the close fence committed. Restore the exact
|
|
5242
5848
|
// pre-close authority and remove the predecessor tombstone together.
|
|
5243
5849
|
const restoreCloseAuthority = (): Promise<boolean> =>
|
|
5244
5850
|
this.#persistTopicMutation(
|
|
5245
5851
|
() => {
|
|
5246
|
-
|
|
5247
|
-
if (
|
|
5248
|
-
this.closedEndpointKeys.delete(session.sessionId);
|
|
5852
|
+
this.topics.restoreArchiveAuthority(closeTopicAuthority);
|
|
5853
|
+
if (closedBindingMatches()) this.closedEndpointKeys.delete(session.sessionId);
|
|
5249
5854
|
return true;
|
|
5250
5855
|
},
|
|
5251
5856
|
() => {
|
|
5252
|
-
this.topics.
|
|
5253
|
-
this.
|
|
5857
|
+
this.topics.restoreArchiveFence(closeTopicAuthority);
|
|
5858
|
+
if (this.sessions.get(session.sessionId) === session)
|
|
5859
|
+
this.closedEndpointKeys.set(session.sessionId, closedBinding);
|
|
5254
5860
|
},
|
|
5255
5861
|
);
|
|
5256
5862
|
try {
|
|
@@ -5260,7 +5866,7 @@ export class TelegramNotificationDaemon {
|
|
|
5260
5866
|
}
|
|
5261
5867
|
return;
|
|
5262
5868
|
}
|
|
5263
|
-
const deleteOutcome = await this.
|
|
5869
|
+
const deleteOutcome = await this.archiveTopic(logicalSessionId, socketLease, true);
|
|
5264
5870
|
if (
|
|
5265
5871
|
deleteOutcome === "pre_dispatch_cancelled" &&
|
|
5266
5872
|
socketLease &&
|
|
@@ -5268,12 +5874,12 @@ export class TelegramNotificationDaemon {
|
|
|
5268
5874
|
) {
|
|
5269
5875
|
await this.#persistTopicMutation(
|
|
5270
5876
|
() => {
|
|
5271
|
-
if (!this.topics.
|
|
5877
|
+
if (!this.topics.restoreArchiveAuthority(closeTopicAuthority))
|
|
5272
5878
|
throw new Error("close authority changed before compensation");
|
|
5273
5879
|
this.closedEndpointKeys.delete(session.sessionId);
|
|
5274
5880
|
},
|
|
5275
5881
|
() => {
|
|
5276
|
-
this.topics.
|
|
5882
|
+
this.topics.restoreArchiveFence(closeTopicAuthority);
|
|
5277
5883
|
this.closedEndpointKeys.set(session.sessionId, closedBinding);
|
|
5278
5884
|
},
|
|
5279
5885
|
);
|
|
@@ -5286,17 +5892,40 @@ export class TelegramNotificationDaemon {
|
|
|
5286
5892
|
}
|
|
5287
5893
|
|
|
5288
5894
|
async loadAliases(): Promise<void> {
|
|
5895
|
+
if (this.validationMode()) return;
|
|
5289
5896
|
const raw = await readJson<unknown>(this.fsImpl, daemonPaths(this.opts.settings.getAgentDir()).aliases);
|
|
5290
|
-
if (raw)
|
|
5897
|
+
if (!raw) return;
|
|
5898
|
+
const persisted = raw as { revokedRoutes?: unknown };
|
|
5899
|
+
const revoked = createAliasTable();
|
|
5900
|
+
revoked.load({ version: 2, next: 1, routes: persisted.revokedRoutes });
|
|
5901
|
+
for (const [alias, route] of revoked.entries()) this.reissueBacklog.set(alias, route);
|
|
5902
|
+
this.aliasTable.load(raw);
|
|
5903
|
+
for (const [alias, route] of this.aliasTable.entries()) this.reissueBacklog.set(alias, route);
|
|
5904
|
+
this.aliasTable.clear();
|
|
5905
|
+
await this.persistAliases();
|
|
5291
5906
|
}
|
|
5292
5907
|
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5908
|
+
persistAliases(): Promise<void> {
|
|
5909
|
+
if (this.validationMode()) return Promise.resolve();
|
|
5910
|
+
const pending = this.aliasPersistenceQueue.then(async () => {
|
|
5911
|
+
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
5912
|
+
await ensureDir(this.fsImpl, paths.dir);
|
|
5913
|
+
await writeJsonAtomic(
|
|
5914
|
+
this.fsImpl,
|
|
5915
|
+
paths.aliases,
|
|
5916
|
+
{
|
|
5917
|
+
...this.aliasTable.serialize(),
|
|
5918
|
+
revokedRoutes: Object.fromEntries(this.reissueBacklog),
|
|
5919
|
+
},
|
|
5920
|
+
{ durable: true },
|
|
5921
|
+
);
|
|
5922
|
+
});
|
|
5923
|
+
this.aliasPersistenceQueue = pending.catch(() => undefined);
|
|
5924
|
+
return pending;
|
|
5297
5925
|
}
|
|
5298
5926
|
|
|
5299
5927
|
async loadSeenUpdateIds(): Promise<void> {
|
|
5928
|
+
if (this.validationMode()) return;
|
|
5300
5929
|
const raw = await readJson<{ updateIds?: unknown }>(
|
|
5301
5930
|
this.fsImpl,
|
|
5302
5931
|
daemonPaths(this.opts.settings.getAgentDir()).seenUpdates,
|
|
@@ -5312,6 +5941,7 @@ export class TelegramNotificationDaemon {
|
|
|
5312
5941
|
}
|
|
5313
5942
|
|
|
5314
5943
|
async persistSeenUpdateIds(): Promise<void> {
|
|
5944
|
+
if (this.validationMode()) return;
|
|
5315
5945
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
5316
5946
|
await ensureDir(this.fsImpl, paths.dir);
|
|
5317
5947
|
await writeJsonAtomic(this.fsImpl, paths.seenUpdates, {
|
|
@@ -5341,6 +5971,12 @@ export class TelegramNotificationDaemon {
|
|
|
5341
5971
|
}
|
|
5342
5972
|
}
|
|
5343
5973
|
private async reserveSeenUpdateId(updateId: number): Promise<boolean> {
|
|
5974
|
+
if (this.validationMode()) {
|
|
5975
|
+
if (this.dispatchState.seenUpdateIds.has(updateId)) return false;
|
|
5976
|
+
this.dispatchState.seenUpdateIds.add(updateId);
|
|
5977
|
+
this.pruneSeenUpdateIds();
|
|
5978
|
+
return true;
|
|
5979
|
+
}
|
|
5344
5980
|
if (!Number.isSafeInteger(updateId) || updateId < 0) return false;
|
|
5345
5981
|
const candidate = new Set(this.dispatchState.seenUpdateIds);
|
|
5346
5982
|
candidate.add(updateId);
|
|
@@ -5362,6 +5998,10 @@ export class TelegramNotificationDaemon {
|
|
|
5362
5998
|
}
|
|
5363
5999
|
|
|
5364
6000
|
private async releaseSeenUpdateId(updateId: number): Promise<void> {
|
|
6001
|
+
if (this.validationMode()) {
|
|
6002
|
+
this.dispatchState.seenUpdateIds.delete(updateId);
|
|
6003
|
+
return;
|
|
6004
|
+
}
|
|
5365
6005
|
if (!this.dispatchState.seenUpdateIds.has(updateId)) return;
|
|
5366
6006
|
const candidate = new Set(this.dispatchState.seenUpdateIds);
|
|
5367
6007
|
candidate.delete(updateId);
|
|
@@ -5447,28 +6087,31 @@ export class TelegramNotificationDaemon {
|
|
|
5447
6087
|
} catch {}
|
|
5448
6088
|
}
|
|
5449
6089
|
}
|
|
5450
|
-
if (
|
|
6090
|
+
if (!this.validationMode()) {
|
|
6091
|
+
if (permanentlyMissingRoots.length > 0) {
|
|
6092
|
+
try {
|
|
6093
|
+
await pruneMissingNotificationRoots({
|
|
6094
|
+
settings: this.opts.settings,
|
|
6095
|
+
fs: this.fsImpl,
|
|
6096
|
+
candidates: permanentlyMissingRoots,
|
|
6097
|
+
});
|
|
6098
|
+
} catch (error) {
|
|
6099
|
+
logger.warn(`notifications: dead-root prune failed: ${sanitizeDiagnostic(String(error))}`);
|
|
6100
|
+
}
|
|
6101
|
+
}
|
|
6102
|
+
// Best-effort periodic reap of retained exact-unlink quarantines (#2956).
|
|
5451
6103
|
try {
|
|
5452
|
-
await
|
|
6104
|
+
await reapStaleNotificationArtifacts({
|
|
5453
6105
|
settings: this.opts.settings,
|
|
5454
6106
|
fs: this.fsImpl,
|
|
5455
|
-
|
|
6107
|
+
now: this.opts.now,
|
|
6108
|
+
pidAlive: this.opts.pidAlive,
|
|
5456
6109
|
});
|
|
5457
6110
|
} catch (error) {
|
|
5458
|
-
logger.warn(`notifications:
|
|
6111
|
+
logger.warn(`notifications: leak-artifact reap failed: ${sanitizeDiagnostic(String(error))}`);
|
|
5459
6112
|
}
|
|
5460
6113
|
}
|
|
5461
|
-
|
|
5462
|
-
try {
|
|
5463
|
-
await reapStaleNotificationArtifacts({
|
|
5464
|
-
settings: this.opts.settings,
|
|
5465
|
-
fs: this.fsImpl,
|
|
5466
|
-
now: this.opts.now,
|
|
5467
|
-
pidAlive: this.opts.pidAlive,
|
|
5468
|
-
});
|
|
5469
|
-
} catch (error) {
|
|
5470
|
-
logger.warn(`notifications: leak-artifact reap failed: ${sanitizeDiagnostic(String(error))}`);
|
|
5471
|
-
}
|
|
6114
|
+
|
|
5472
6115
|
if (allRootsReadable) {
|
|
5473
6116
|
for (const sessionId of this.topics.sessionIds()) {
|
|
5474
6117
|
const owner = this.logicalSessionOwners.get(sessionId);
|
|
@@ -5521,7 +6164,7 @@ export class TelegramNotificationDaemon {
|
|
|
5521
6164
|
replayQueue: [],
|
|
5522
6165
|
};
|
|
5523
6166
|
this.sessions.set(sessionId, session);
|
|
5524
|
-
if (this.topics.get(sessionId)) this.preservedInitiatorTopics.add(sessionId);
|
|
6167
|
+
if (this.topics.get(sessionId)?.authorityState === "active") this.preservedInitiatorTopics.add(sessionId);
|
|
5525
6168
|
|
|
5526
6169
|
// Bidirectional capability advertisement: announce client_ping_pong once the
|
|
5527
6170
|
// socket is open. Sent on "open" only — a real WHATWG WebSocket cannot send
|
|
@@ -5566,7 +6209,9 @@ export class TelegramNotificationDaemon {
|
|
|
5566
6209
|
void (async () => {
|
|
5567
6210
|
if (this.#logicalSessionId(session) !== sessionId) return;
|
|
5568
6211
|
const topic = this.topics.get(sessionId);
|
|
5569
|
-
if (!topic || topic.
|
|
6212
|
+
if (!topic || topic.bindingMalformed) return;
|
|
6213
|
+
if (topic.authorityState === "disconnect_grace" && !(await this.#renewTopicLease(sessionId))) return;
|
|
6214
|
+
if (topic.authorityState !== "active") return;
|
|
5570
6215
|
const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
|
|
5571
6216
|
if (topicLease?.topicId === topic.topicId) await this.flushPendingThreadedFrames(sessionId, topicLease);
|
|
5572
6217
|
})().catch(err =>
|
|
@@ -5579,11 +6224,13 @@ export class TelegramNotificationDaemon {
|
|
|
5579
6224
|
// Identity guard: a delayed frame from a superseded socket must not act
|
|
5580
6225
|
// through the replacement session.
|
|
5581
6226
|
if (this.sessions.get(sessionId) !== session) return;
|
|
5582
|
-
void this.
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
6227
|
+
void this.effects
|
|
6228
|
+
.admit(() => this.handleSessionMessage(session, JSON.parse(String(ev.data))))
|
|
6229
|
+
.catch(err => {
|
|
6230
|
+
// Surface frame-handling failures (e.g. a rejected ask sendMessage) to
|
|
6231
|
+
// the daemon log instead of an invisible unhandled rejection.
|
|
6232
|
+
logger.error("notifications daemon: handleSessionMessage failed", { error: String(err) });
|
|
6233
|
+
});
|
|
5587
6234
|
});
|
|
5588
6235
|
ws.addEventListener("close", () => {
|
|
5589
6236
|
this.dropSession(session, "socket_closed");
|
|
@@ -5610,6 +6257,11 @@ export class TelegramNotificationDaemon {
|
|
|
5610
6257
|
this.dropSession(session, "liveness_timeout");
|
|
5611
6258
|
return;
|
|
5612
6259
|
}
|
|
6260
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
6261
|
+
if (session.logicalSessionIdTrusted)
|
|
6262
|
+
void this.#renewTopicLease(logicalSessionId).then(renewed => {
|
|
6263
|
+
if (!renewed) this.dropSession(session, "topic_lease_lost");
|
|
6264
|
+
});
|
|
5613
6265
|
if (session.ws.readyState === WebSocket.OPEN) {
|
|
5614
6266
|
const nonce = `${session.sessionId}:${t}:${Math.random().toString(36).slice(2)}`;
|
|
5615
6267
|
session.awaitingNonce = nonce;
|
|
@@ -5753,12 +6405,24 @@ export class TelegramNotificationDaemon {
|
|
|
5753
6405
|
}
|
|
5754
6406
|
|
|
5755
6407
|
private dropSession(session: SessionSocket, reason: string): void {
|
|
5756
|
-
//
|
|
6408
|
+
// Capture the exact callback lease before revoking recovery authority. A
|
|
6409
|
+
// predecessor transport can remain keyed by its own session id after a
|
|
6410
|
+
// successor becomes the logical owner, so logical-session revocation
|
|
6411
|
+
// would incorrectly delete the successor's freshly accepted aliases.
|
|
6412
|
+
const callbackLease = {
|
|
6413
|
+
session,
|
|
6414
|
+
token: session.recoveryLease?.token ?? 0,
|
|
6415
|
+
logicalSessionId: this.#logicalSessionId(session),
|
|
6416
|
+
};
|
|
6417
|
+
// A dropped socket must lose authority before any teardown-triggered asynchronous
|
|
5757
6418
|
// work can observe it. Its immutable lease token remains captured by queued work,
|
|
5758
6419
|
// but is no longer usable.
|
|
5759
6420
|
if (session.recoveryLease) session.recoveryLease = { ...session.recoveryLease, state: "rejected" };
|
|
5760
6421
|
const isCurrentSession = this.sessions.get(session.sessionId) === session;
|
|
5761
|
-
if (isCurrentSession)
|
|
6422
|
+
if (isCurrentSession) {
|
|
6423
|
+
this.revokeCallbackAliases(callbackLease);
|
|
6424
|
+
this.droppedSessions.add(session);
|
|
6425
|
+
}
|
|
5762
6426
|
if (isCurrentSession) this.cancelLegacyToolStartsForSession(session);
|
|
5763
6427
|
if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
|
|
5764
6428
|
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
@@ -5771,6 +6435,7 @@ export class TelegramNotificationDaemon {
|
|
|
5771
6435
|
} else {
|
|
5772
6436
|
void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
|
|
5773
6437
|
}
|
|
6438
|
+
if (isCurrentSession && session.logicalSessionIdTrusted) this.#releaseTopicLease(this.#logicalSessionId(session));
|
|
5774
6439
|
if (isCurrentSession || reason === "session_closed") {
|
|
5775
6440
|
this.deleteMessageRoutes(session.sessionId);
|
|
5776
6441
|
}
|
|
@@ -6094,6 +6759,82 @@ export class TelegramNotificationDaemon {
|
|
|
6094
6759
|
}
|
|
6095
6760
|
}
|
|
6096
6761
|
}
|
|
6762
|
+
#revokeCallbackAlias(alias: string): CallbackRoute | undefined {
|
|
6763
|
+
const route = this.aliasTable.get(alias);
|
|
6764
|
+
if (!route) return undefined;
|
|
6765
|
+
this.reissueBacklog.set(alias, route);
|
|
6766
|
+
this.aliasTable.delete(alias);
|
|
6767
|
+
this.#callbackAliasLeases.delete(alias);
|
|
6768
|
+
this.#callbackAliasPending.delete(alias);
|
|
6769
|
+
this.#callbackAliasTopics.delete(alias);
|
|
6770
|
+
return route;
|
|
6771
|
+
}
|
|
6772
|
+
#stageCallbackActivation(
|
|
6773
|
+
aliases: Iterable<string>,
|
|
6774
|
+
pending: { sessionId: string; actionId: string },
|
|
6775
|
+
socketLease: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
6776
|
+
topicLease: TopicAuthorityLease | undefined,
|
|
6777
|
+
): PromiseWithResolvers<boolean> {
|
|
6778
|
+
const activation = Promise.withResolvers<boolean>();
|
|
6779
|
+
const staged: StagedCallbackActivation = { pending, socketLease, topicLease, settled: activation.promise };
|
|
6780
|
+
for (const alias of aliases) this.#stagedCallbackActivations.set(alias, staged);
|
|
6781
|
+
return activation;
|
|
6782
|
+
}
|
|
6783
|
+
|
|
6784
|
+
revokeCallbackAliases(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): void {
|
|
6785
|
+
const actions = new Set<string>();
|
|
6786
|
+
for (const [alias, aliasLease] of this.#callbackAliasLeases) {
|
|
6787
|
+
if (
|
|
6788
|
+
aliasLease.session === socketLease.session &&
|
|
6789
|
+
aliasLease.token === socketLease.token &&
|
|
6790
|
+
aliasLease.logicalSessionId === socketLease.logicalSessionId
|
|
6791
|
+
) {
|
|
6792
|
+
const route = this.#revokeCallbackAlias(alias);
|
|
6793
|
+
if (route) actions.add(route.actionId);
|
|
6794
|
+
}
|
|
6795
|
+
}
|
|
6796
|
+
if (actions.size === 0) return;
|
|
6797
|
+
const terminalization = this.effects.track(
|
|
6798
|
+
(async () => {
|
|
6799
|
+
await this.persistAliases();
|
|
6800
|
+
for (const actionId of actions) await this.reissuePendingAction(socketLease.logicalSessionId, actionId);
|
|
6801
|
+
})(),
|
|
6802
|
+
);
|
|
6803
|
+
void terminalization.catch(() => undefined);
|
|
6804
|
+
}
|
|
6805
|
+
|
|
6806
|
+
private async reissuePendingAction(sessionId: string, actionId: string): Promise<void> {
|
|
6807
|
+
const oldEntries = [...this.reissueBacklog.entries()].filter(
|
|
6808
|
+
([, route]) => route.sessionId === sessionId && route.actionId === actionId,
|
|
6809
|
+
);
|
|
6810
|
+
const messageIds = new Set<number>();
|
|
6811
|
+
for (const [, route] of oldEntries) {
|
|
6812
|
+
if (String(route.chatId) === String(this.opts.chatId) && Number.isSafeInteger(route.messageId))
|
|
6813
|
+
messageIds.add(route.messageId!);
|
|
6814
|
+
}
|
|
6815
|
+
const terminalizedMessageIds = new Set<number>();
|
|
6816
|
+
for (const messageId of messageIds) {
|
|
6817
|
+
try {
|
|
6818
|
+
const terminalized = (await this.botApi.call("editMessageReplyMarkup", {
|
|
6819
|
+
chat_id: this.opts.chatId,
|
|
6820
|
+
message_id: messageId,
|
|
6821
|
+
reply_markup: { inline_keyboard: [] },
|
|
6822
|
+
})) as { ok?: unknown } | undefined;
|
|
6823
|
+
if (terminalized?.ok === true) terminalizedMessageIds.add(messageId);
|
|
6824
|
+
} catch {
|
|
6825
|
+
// Retain the durable receipt so a later replay can retry terminalization.
|
|
6826
|
+
}
|
|
6827
|
+
}
|
|
6828
|
+
for (const [alias, route] of oldEntries) {
|
|
6829
|
+
if (
|
|
6830
|
+
String(route.chatId) === String(this.opts.chatId) &&
|
|
6831
|
+
Number.isSafeInteger(route.messageId) &&
|
|
6832
|
+
terminalizedMessageIds.has(route.messageId!)
|
|
6833
|
+
)
|
|
6834
|
+
this.reissueBacklog.delete(alias);
|
|
6835
|
+
}
|
|
6836
|
+
await this.persistAliases();
|
|
6837
|
+
}
|
|
6097
6838
|
|
|
6098
6839
|
#logicalSessionId(session: SessionSocket): string {
|
|
6099
6840
|
return session.logicalSessionId ?? session.sessionId;
|
|
@@ -6123,8 +6864,10 @@ export class TelegramNotificationDaemon {
|
|
|
6123
6864
|
lease.binding.endpointDigest === session.endpointDigest &&
|
|
6124
6865
|
lease.binding.endpointGeneration === session.hostGeneration &&
|
|
6125
6866
|
(!record ||
|
|
6126
|
-
(record.authorityState
|
|
6867
|
+
(record.authorityState === "active" &&
|
|
6127
6868
|
!record.bindingMalformed &&
|
|
6869
|
+
record.leaseOwner === this.installationHostId &&
|
|
6870
|
+
(record.leaseExpiresAt ?? 0) > this.runtime.now() &&
|
|
6128
6871
|
record.chatId === lease.binding.chatId &&
|
|
6129
6872
|
record.endpointKey === lease.binding.endpointKey &&
|
|
6130
6873
|
record.endpointDigest === lease.binding.endpointDigest &&
|
|
@@ -6208,14 +6951,86 @@ export class TelegramNotificationDaemon {
|
|
|
6208
6951
|
? undefined
|
|
6209
6952
|
: { session, token: 0, logicalSessionId };
|
|
6210
6953
|
}
|
|
6954
|
+
async #renewTopicLease(sessionId: string): Promise<boolean> {
|
|
6955
|
+
const record = this.topics.get(sessionId);
|
|
6956
|
+
if (!record) return true;
|
|
6957
|
+
const previous = {
|
|
6958
|
+
leaseOwner: record.leaseOwner,
|
|
6959
|
+
leaseHeartbeatAt: record.leaseHeartbeatAt,
|
|
6960
|
+
leaseExpiresAt: record.leaseExpiresAt,
|
|
6961
|
+
authorityState: record.authorityState,
|
|
6962
|
+
orphanedAt: record.orphanedAt,
|
|
6963
|
+
disconnectGraceExpiresAt: record.disconnectGraceExpiresAt,
|
|
6964
|
+
};
|
|
6965
|
+
return this.#persistTopicMutation(
|
|
6966
|
+
() =>
|
|
6967
|
+
this.topics.acquireLease(
|
|
6968
|
+
sessionId,
|
|
6969
|
+
this.installationHostId,
|
|
6970
|
+
this.runtime.now(),
|
|
6971
|
+
HEARTBEAT_TTL_MS,
|
|
6972
|
+
ORPHAN_TOPIC_GRACE_MS,
|
|
6973
|
+
),
|
|
6974
|
+
() => {
|
|
6975
|
+
const current = this.topics.get(sessionId);
|
|
6976
|
+
if (!current) return;
|
|
6977
|
+
Object.assign(current, previous);
|
|
6978
|
+
if (previous.leaseOwner === undefined) delete current.leaseOwner;
|
|
6979
|
+
if (previous.leaseHeartbeatAt === undefined) delete current.leaseHeartbeatAt;
|
|
6980
|
+
if (previous.leaseExpiresAt === undefined) delete current.leaseExpiresAt;
|
|
6981
|
+
if (previous.orphanedAt === undefined) delete current.orphanedAt;
|
|
6982
|
+
if (previous.disconnectGraceExpiresAt === undefined) delete current.disconnectGraceExpiresAt;
|
|
6983
|
+
},
|
|
6984
|
+
);
|
|
6985
|
+
}
|
|
6986
|
+
#releaseTopicLease(sessionId: string): void {
|
|
6987
|
+
const record = this.topics.get(sessionId);
|
|
6988
|
+
if (!record) return;
|
|
6989
|
+
const previous = {
|
|
6990
|
+
leaseHeartbeatAt: record.leaseHeartbeatAt,
|
|
6991
|
+
leaseExpiresAt: record.leaseExpiresAt,
|
|
6992
|
+
authorityState: record.authorityState,
|
|
6993
|
+
orphanedAt: record.orphanedAt,
|
|
6994
|
+
disconnectGraceExpiresAt: record.disconnectGraceExpiresAt,
|
|
6995
|
+
};
|
|
6996
|
+
void this.#persistTopicMutation(
|
|
6997
|
+
() => {
|
|
6998
|
+
const owner = this.logicalSessionOwners.get(sessionId);
|
|
6999
|
+
return owner === undefined
|
|
7000
|
+
? this.topics.releaseLeaseToGrace(
|
|
7001
|
+
sessionId,
|
|
7002
|
+
this.installationHostId,
|
|
7003
|
+
this.runtime.now(),
|
|
7004
|
+
ORPHAN_TOPIC_GRACE_MS,
|
|
7005
|
+
)
|
|
7006
|
+
: false;
|
|
7007
|
+
},
|
|
7008
|
+
() => {
|
|
7009
|
+
const current = this.topics.get(sessionId);
|
|
7010
|
+
if (!current) return;
|
|
7011
|
+
Object.assign(current, previous);
|
|
7012
|
+
if (previous.leaseHeartbeatAt === undefined) delete current.leaseHeartbeatAt;
|
|
7013
|
+
if (previous.leaseExpiresAt === undefined) delete current.leaseExpiresAt;
|
|
7014
|
+
if (previous.orphanedAt === undefined) delete current.orphanedAt;
|
|
7015
|
+
if (previous.disconnectGraceExpiresAt === undefined) delete current.disconnectGraceExpiresAt;
|
|
7016
|
+
},
|
|
7017
|
+
).catch(() => undefined);
|
|
7018
|
+
}
|
|
6211
7019
|
|
|
6212
7020
|
#authorizeLease(session: SessionSocket, logicalSessionId: string, binding: TopicEndpointBinding): void {
|
|
6213
7021
|
const previousSessionId = this.#logicalSessionId(session);
|
|
6214
7022
|
if (previousSessionId !== logicalSessionId && this.logicalSessionOwners.get(previousSessionId) === session)
|
|
6215
7023
|
this.logicalSessionOwners.delete(previousSessionId);
|
|
6216
7024
|
const previousOwner = this.logicalSessionOwners.get(logicalSessionId);
|
|
6217
|
-
if (previousOwner && previousOwner !== session && previousOwner.recoveryLease)
|
|
7025
|
+
if (previousOwner && previousOwner !== session && previousOwner.recoveryLease) {
|
|
7026
|
+
const predecessorLease = {
|
|
7027
|
+
session: previousOwner,
|
|
7028
|
+
token: previousOwner.recoveryLease.token,
|
|
7029
|
+
logicalSessionId,
|
|
7030
|
+
};
|
|
6218
7031
|
previousOwner.recoveryLease = { ...previousOwner.recoveryLease, state: "rejected" };
|
|
7032
|
+
this.revokeCallbackAliases(predecessorLease);
|
|
7033
|
+
}
|
|
6219
7034
|
this.logicalSessionOwners.set(logicalSessionId, session);
|
|
6220
7035
|
this.preservedInitiatorTopics.delete(logicalSessionId);
|
|
6221
7036
|
session.logicalSessionId = logicalSessionId;
|
|
@@ -6229,8 +7044,18 @@ export class TelegramNotificationDaemon {
|
|
|
6229
7044
|
}
|
|
6230
7045
|
|
|
6231
7046
|
async #revokeAskAuthority(sessionId: string): Promise<void> {
|
|
7047
|
+
const actionIds = new Set<string>();
|
|
6232
7048
|
for (const [alias, route] of this.aliasTable.entries()) {
|
|
6233
|
-
if (route.sessionId
|
|
7049
|
+
if (route.sessionId !== sessionId) continue;
|
|
7050
|
+
this.reissueBacklog.set(alias, route);
|
|
7051
|
+
this.aliasTable.delete(alias);
|
|
7052
|
+
this.#callbackAliasLeases.delete(alias);
|
|
7053
|
+
this.#callbackAliasPending.delete(alias);
|
|
7054
|
+
this.#callbackAliasTopics.delete(alias);
|
|
7055
|
+
actionIds.add(route.actionId);
|
|
7056
|
+
}
|
|
7057
|
+
for (const route of this.reissueBacklog.values()) {
|
|
7058
|
+
if (route.sessionId === sessionId) actionIds.add(route.actionId);
|
|
6234
7059
|
}
|
|
6235
7060
|
for (const session of this.sessions.values()) {
|
|
6236
7061
|
if (session.sessionId === sessionId || this.#logicalSessionId(session) === sessionId) {
|
|
@@ -6240,6 +7065,7 @@ export class TelegramNotificationDaemon {
|
|
|
6240
7065
|
}
|
|
6241
7066
|
}
|
|
6242
7067
|
await this.persistAliases();
|
|
7068
|
+
for (const actionId of actionIds) await this.reissuePendingAction(sessionId, actionId);
|
|
6243
7069
|
}
|
|
6244
7070
|
|
|
6245
7071
|
#clearModelChoiceAliasesForSocket(session: SessionSocket): void {
|
|
@@ -6378,84 +7204,123 @@ export class TelegramNotificationDaemon {
|
|
|
6378
7204
|
const binding = this.#endpointBinding(session);
|
|
6379
7205
|
const pendingToken = this.nextSocketLeaseToken++;
|
|
6380
7206
|
session.recoveryLease = { state: "pending", logicalSessionId: candidateSessionId, binding, token: pendingToken };
|
|
6381
|
-
|
|
6382
|
-
|
|
6383
|
-
|
|
6384
|
-
|
|
6385
|
-
|
|
6386
|
-
|
|
6387
|
-
|
|
6388
|
-
|
|
6389
|
-
|
|
6390
|
-
|
|
6391
|
-
|
|
6392
|
-
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
|
|
6396
|
-
|
|
6397
|
-
|
|
6398
|
-
|
|
6399
|
-
|
|
6400
|
-
|
|
6401
|
-
|
|
6402
|
-
|
|
6403
|
-
|
|
6404
|
-
|
|
6405
|
-
|
|
6406
|
-
|
|
6407
|
-
|
|
6408
|
-
|
|
6409
|
-
|
|
6410
|
-
|
|
6411
|
-
|
|
6412
|
-
|
|
6413
|
-
|
|
6414
|
-
|
|
6415
|
-
|
|
6416
|
-
|
|
6417
|
-
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
|
|
6421
|
-
|
|
6422
|
-
|
|
7207
|
+
let claim: { previousSessionId: string; hadDurableTopic: boolean } | undefined;
|
|
7208
|
+
try {
|
|
7209
|
+
claim = await this.#withRecoveryBindingClaim(async () => {
|
|
7210
|
+
const existing = this.topics.get(candidateSessionId);
|
|
7211
|
+
const hadDurableTopic = existing !== undefined;
|
|
7212
|
+
const previousBinding = existing
|
|
7213
|
+
? {
|
|
7214
|
+
chatId: existing.chatId,
|
|
7215
|
+
endpointKey: existing.endpointKey,
|
|
7216
|
+
endpointDigest: existing.endpointDigest,
|
|
7217
|
+
endpointGeneration: existing.endpointGeneration,
|
|
7218
|
+
endpointIncarnation: existing.endpointIncarnation,
|
|
7219
|
+
}
|
|
7220
|
+
: undefined;
|
|
7221
|
+
let retiredTopicState: TopicRegistryState | undefined;
|
|
7222
|
+
const outcome = await this.#persistTopicMutation(
|
|
7223
|
+
() => {
|
|
7224
|
+
if (allowEndpointRotation && this.topics.get(candidateSessionId)?.authorityState === "inactive") {
|
|
7225
|
+
retiredTopicState = this.topics.serialize();
|
|
7226
|
+
this.topics.retireInactiveEndpointForSuccessor(candidateSessionId, binding);
|
|
7227
|
+
}
|
|
7228
|
+
return this.topics.get(candidateSessionId)
|
|
7229
|
+
? this.topics.bindEndpoint(
|
|
7230
|
+
candidateSessionId,
|
|
7231
|
+
binding,
|
|
7232
|
+
this.#activeEndpointKeysFor(candidateSessionId, session),
|
|
7233
|
+
allowEndpointRotation,
|
|
7234
|
+
)
|
|
7235
|
+
: "unchanged";
|
|
7236
|
+
},
|
|
7237
|
+
() => {
|
|
7238
|
+
if (retiredTopicState) this.topics.replace(retiredTopicState);
|
|
7239
|
+
else if (previousBinding)
|
|
7240
|
+
this.topics.restoreEndpointBinding(candidateSessionId, binding, previousBinding);
|
|
7241
|
+
},
|
|
7242
|
+
).catch(() => "rejected" as const);
|
|
7243
|
+
if (outcome === "rejected") {
|
|
7244
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
7245
|
+
session.recoveryLease = {
|
|
7246
|
+
state: "rejected",
|
|
7247
|
+
logicalSessionId: candidateSessionId,
|
|
7248
|
+
binding,
|
|
7249
|
+
token: pendingToken,
|
|
7250
|
+
};
|
|
7251
|
+
return undefined;
|
|
7252
|
+
}
|
|
7253
|
+
if (!(await this.#renewTopicLease(candidateSessionId))) {
|
|
7254
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
7255
|
+
session.recoveryLease = {
|
|
7256
|
+
state: "rejected",
|
|
7257
|
+
logicalSessionId: candidateSessionId,
|
|
7258
|
+
binding,
|
|
7259
|
+
token: pendingToken,
|
|
7260
|
+
};
|
|
7261
|
+
return undefined;
|
|
7262
|
+
}
|
|
7263
|
+
const endpointAuthority = this.#endpointAuthority(binding, session);
|
|
7264
|
+
const identitylessAdmissionAllows =
|
|
7265
|
+
identitylessAdmission === undefined ||
|
|
7266
|
+
(identitylessAdmission === "bootstrap"
|
|
7267
|
+
? (endpointAuthority.state === "none" && !this.topics.get(candidateSessionId)) ||
|
|
7268
|
+
(endpointAuthority.state === "unique" &&
|
|
7269
|
+
endpointAuthority.sessionId === candidateSessionId &&
|
|
7270
|
+
this.topics.matchesEndpoint(candidateSessionId, binding))
|
|
7271
|
+
: endpointAuthority.state === "unique" &&
|
|
6423
7272
|
endpointAuthority.sessionId === candidateSessionId &&
|
|
6424
|
-
this.topics.matchesEndpoint(candidateSessionId, binding))
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
|
|
6437
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
return
|
|
6454
|
-
}
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
7273
|
+
this.topics.matchesEndpoint(candidateSessionId, binding));
|
|
7274
|
+
if (
|
|
7275
|
+
session.recoveryLease?.token !== pendingToken ||
|
|
7276
|
+
session.recoveryLease.state !== "pending" ||
|
|
7277
|
+
!this.#ownsLiveOpenEndpoint(session, binding) ||
|
|
7278
|
+
!identitylessAdmissionAllows
|
|
7279
|
+
) {
|
|
7280
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
7281
|
+
session.recoveryLease = {
|
|
7282
|
+
state: "rejected",
|
|
7283
|
+
logicalSessionId: candidateSessionId,
|
|
7284
|
+
binding,
|
|
7285
|
+
token: pendingToken,
|
|
7286
|
+
};
|
|
7287
|
+
return undefined;
|
|
7288
|
+
}
|
|
7289
|
+
const previousSessionId = this.#logicalSessionId(session);
|
|
7290
|
+
if (previousSessionId !== candidateSessionId) await this.#terminalizeBtwTurnsForSession(session, true);
|
|
7291
|
+
if (!this.#ownsLiveOpenEndpoint(session, binding)) {
|
|
7292
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
7293
|
+
session.recoveryLease = {
|
|
7294
|
+
state: "rejected",
|
|
7295
|
+
logicalSessionId: candidateSessionId,
|
|
7296
|
+
binding,
|
|
7297
|
+
token: pendingToken,
|
|
7298
|
+
};
|
|
7299
|
+
return undefined;
|
|
7300
|
+
}
|
|
7301
|
+
this.#authorizeLease(session, candidateSessionId, binding);
|
|
7302
|
+
return { previousSessionId, hadDurableTopic };
|
|
7303
|
+
});
|
|
7304
|
+
} catch {
|
|
7305
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
7306
|
+
session.recoveryLease = {
|
|
7307
|
+
state: "rejected",
|
|
7308
|
+
logicalSessionId: candidateSessionId,
|
|
7309
|
+
binding,
|
|
7310
|
+
token: pendingToken,
|
|
7311
|
+
};
|
|
7312
|
+
return false;
|
|
7313
|
+
}
|
|
7314
|
+
if (!claim) {
|
|
7315
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
7316
|
+
session.recoveryLease = {
|
|
7317
|
+
state: "rejected",
|
|
7318
|
+
logicalSessionId: candidateSessionId,
|
|
7319
|
+
binding,
|
|
7320
|
+
token: pendingToken,
|
|
7321
|
+
};
|
|
7322
|
+
return false;
|
|
7323
|
+
}
|
|
6459
7324
|
const { previousSessionId } = claim;
|
|
6460
7325
|
if (preserveTransportTopic && previousSessionId !== candidateSessionId) {
|
|
6461
7326
|
this.legacyTopicOwners.set(previousSessionId, session);
|
|
@@ -6464,7 +7329,7 @@ export class TelegramNotificationDaemon {
|
|
|
6464
7329
|
if (candidateSessionId === session.sessionId)
|
|
6465
7330
|
void (async () => {
|
|
6466
7331
|
const topic = this.topics.get(candidateSessionId);
|
|
6467
|
-
if (
|
|
7332
|
+
if (topic?.authorityState !== "active" || topic.bindingMalformed) return;
|
|
6468
7333
|
const topicLease = this.topicAuthorityLeaseFromRegistry(candidateSessionId);
|
|
6469
7334
|
if (topicLease?.topicId === topic.topicId)
|
|
6470
7335
|
await this.flushPendingThreadedFrames(candidateSessionId, topicLease);
|
|
@@ -6539,12 +7404,12 @@ export class TelegramNotificationDaemon {
|
|
|
6539
7404
|
const identityKey = this.topicIdentityKey(msg);
|
|
6540
7405
|
const remembered = identityKey ? this.topicOwnerByIdentity.get(identityKey) : undefined;
|
|
6541
7406
|
const rememberedTopic = remembered ? this.topics.get(remembered) : undefined;
|
|
6542
|
-
if (remembered && rememberedTopic && rememberedTopic.authorityState
|
|
7407
|
+
if (remembered && rememberedTopic && rememberedTopic.authorityState === "active") return remembered;
|
|
6543
7408
|
if (!identityKey) return undefined;
|
|
6544
7409
|
const base = this.topicIdentityBase(msg);
|
|
6545
7410
|
for (const sessionId of this.topics.sessionIds()) {
|
|
6546
7411
|
const topic = this.topics.get(sessionId);
|
|
6547
|
-
if (topic?.authorityState
|
|
7412
|
+
if (topic?.authorityState !== "active") continue;
|
|
6548
7413
|
const nameMatchesLegacyIdentity =
|
|
6549
7414
|
base !== undefined && (topic?.name === base || topic?.name?.startsWith(`${base} - `));
|
|
6550
7415
|
if (topic?.identityKey === identityKey || nameMatchesLegacyIdentity) {
|
|
@@ -6823,21 +7688,29 @@ export class TelegramNotificationDaemon {
|
|
|
6823
7688
|
}
|
|
6824
7689
|
|
|
6825
7690
|
private async topicAuthorityLease(sessionId: string): Promise<TopicAuthorityLease | undefined> {
|
|
6826
|
-
if (!(await this.
|
|
7691
|
+
if (!(await this.pairedChatAllowsTopics())) return undefined;
|
|
6827
7692
|
return this.topicAuthorityLeaseFromRegistry(sessionId);
|
|
6828
7693
|
}
|
|
6829
7694
|
|
|
6830
7695
|
private topicAuthorityLeaseFromRegistry(sessionId: string): TopicAuthorityLease | undefined {
|
|
6831
7696
|
const topic = this.topics.get(sessionId);
|
|
6832
|
-
if (
|
|
7697
|
+
if (
|
|
7698
|
+
!topic ||
|
|
7699
|
+
!this.topics.isActiveUnambiguous(sessionId) ||
|
|
7700
|
+
topic.bindingMalformed ||
|
|
7701
|
+
topic.leaseOwner !== this.installationHostId ||
|
|
7702
|
+
(topic.leaseExpiresAt ?? 0) <= this.runtime.now()
|
|
7703
|
+
)
|
|
7704
|
+
return undefined;
|
|
6833
7705
|
return { sessionId, topicId: topic.topicId, authorityEpoch: topic.authorityEpoch ?? 0 };
|
|
6834
7706
|
}
|
|
6835
|
-
|
|
6836
7707
|
private topicLeaseIsCurrent(lease: TopicAuthorityLease): boolean {
|
|
6837
7708
|
const topic = this.topics.get(lease.sessionId);
|
|
6838
7709
|
return (
|
|
6839
7710
|
this.topics.isActiveUnambiguous(lease.sessionId) &&
|
|
6840
7711
|
!topic?.bindingMalformed &&
|
|
7712
|
+
topic?.leaseOwner === this.installationHostId &&
|
|
7713
|
+
(topic.leaseExpiresAt ?? 0) > this.runtime.now() &&
|
|
6841
7714
|
topic?.topicId === lease.topicId &&
|
|
6842
7715
|
(topic.authorityEpoch ?? 0) === lease.authorityEpoch
|
|
6843
7716
|
);
|
|
@@ -6884,7 +7757,7 @@ export class TelegramNotificationDaemon {
|
|
|
6884
7757
|
): void {
|
|
6885
7758
|
const logicalSessionId = this.#logicalSessionId(session);
|
|
6886
7759
|
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
6887
|
-
if (!socketLease) {
|
|
7760
|
+
if (!socketLease && !session.logicalSessionIdTrusted) {
|
|
6888
7761
|
this.failLegacyToolStart(toolActivity);
|
|
6889
7762
|
return;
|
|
6890
7763
|
}
|
|
@@ -6898,6 +7771,7 @@ export class TelegramNotificationDaemon {
|
|
|
6898
7771
|
send,
|
|
6899
7772
|
msg,
|
|
6900
7773
|
logicalSessionId,
|
|
7774
|
+
session,
|
|
6901
7775
|
socketLease,
|
|
6902
7776
|
...(toolActivity ? { toolActivity } : {}),
|
|
6903
7777
|
});
|
|
@@ -6913,15 +7787,17 @@ export class TelegramNotificationDaemon {
|
|
|
6913
7787
|
if (!frames || frames.length === 0) return;
|
|
6914
7788
|
this.pendingThreadedFrames.delete(sessionId);
|
|
6915
7789
|
for (const frame of frames) {
|
|
7790
|
+
const socketLease = frame.socketLease ?? this.#socketLease(frame.session, sessionId);
|
|
6916
7791
|
if (
|
|
6917
7792
|
frame.logicalSessionId !== sessionId ||
|
|
6918
|
-
!
|
|
7793
|
+
!socketLease ||
|
|
7794
|
+
!this.#leaseTokenAllows(socketLease) ||
|
|
6919
7795
|
(frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled !== true)
|
|
6920
7796
|
) {
|
|
6921
7797
|
this.failLegacyToolStart(frame.toolActivity);
|
|
6922
7798
|
continue;
|
|
6923
7799
|
}
|
|
6924
|
-
await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity,
|
|
7800
|
+
await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, socketLease);
|
|
6925
7801
|
}
|
|
6926
7802
|
}
|
|
6927
7803
|
|
|
@@ -6937,15 +7813,16 @@ export class TelegramNotificationDaemon {
|
|
|
6937
7813
|
session?: SessionSocket,
|
|
6938
7814
|
creationLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
6939
7815
|
): Promise<string | undefined> {
|
|
6940
|
-
if (!(await this.
|
|
7816
|
+
if (!(await this.pairedChatAllowsTopics())) return undefined;
|
|
6941
7817
|
if (session && sessionId === session.sessionId && this.#logicalSessionId(session) !== sessionId) return undefined;
|
|
6942
7818
|
const capturedCreationLease = creationLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
|
|
6943
7819
|
if (session?.logicalSessionIdTrusted && !capturedCreationLease) return undefined;
|
|
6944
7820
|
const creationEndpointKey = session?.endpointDigest ?? session?.endpointKey ?? "unbound";
|
|
6945
|
-
|
|
7821
|
+
const malformedCreateEndpoint = this.#malformedTopicCreateEndpoints.get(sessionId);
|
|
7822
|
+
if (malformedCreateEndpoint === "unbound" || malformedCreateEndpoint === creationEndpointKey)
|
|
6946
7823
|
throw new Error("createForumTopic: invalid message_thread_id");
|
|
6947
7824
|
const existing = this.topics.get(sessionId);
|
|
6948
|
-
if (existing
|
|
7825
|
+
if (existing && (existing.authorityState !== "active" || existing.bindingMalformed)) return undefined;
|
|
6949
7826
|
if (existing) return existing.topicId;
|
|
6950
7827
|
if (
|
|
6951
7828
|
session &&
|
|
@@ -6958,9 +7835,9 @@ export class TelegramNotificationDaemon {
|
|
|
6958
7835
|
const creationLeaseEpoch = this.topics.authorityEpoch(sessionId);
|
|
6959
7836
|
let acceptedTopicId: string | undefined;
|
|
6960
7837
|
let acceptedTopicCompensated = false;
|
|
6961
|
-
let
|
|
7838
|
+
let acceptedTopicArchiveAttempted = false;
|
|
6962
7839
|
let creationSuppressed = false;
|
|
6963
|
-
|
|
7840
|
+
let creationRejected = false;
|
|
6964
7841
|
let adoptedTopicId: number | undefined;
|
|
6965
7842
|
const adoptionIntentCandidate = this.#adoptionIntents.bySession(sessionId);
|
|
6966
7843
|
try {
|
|
@@ -7010,57 +7887,52 @@ export class TelegramNotificationDaemon {
|
|
|
7010
7887
|
};
|
|
7011
7888
|
const tid = response.result?.message_thread_id;
|
|
7012
7889
|
if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0) {
|
|
7013
|
-
|
|
7890
|
+
creationRejected = response.ok === false;
|
|
7891
|
+
if (!creationRejected) this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey);
|
|
7014
7892
|
throw new Error("createForumTopic: invalid message_thread_id");
|
|
7015
7893
|
}
|
|
7016
7894
|
acceptedTopicId = String(tid);
|
|
7017
7895
|
this.#malformedTopicCreateEndpoints.delete(sessionId);
|
|
7018
7896
|
if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
|
|
7019
|
-
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7897
|
+
if (
|
|
7898
|
+
!this.topics.fenceAcceptedCreateForLease(
|
|
7899
|
+
sessionId,
|
|
7900
|
+
acceptedTopicId,
|
|
7901
|
+
creationLeaseEpoch,
|
|
7902
|
+
this.installationHostId,
|
|
7903
|
+
this.opts.now,
|
|
7904
|
+
name,
|
|
7905
|
+
creationBinding,
|
|
7906
|
+
undefined,
|
|
7907
|
+
String(this.opts.chatId),
|
|
7908
|
+
)
|
|
7909
|
+
)
|
|
7910
|
+
throw new Error("topic authority was revoked during creation");
|
|
7031
7911
|
try {
|
|
7032
7912
|
await this.persistTopics();
|
|
7033
|
-
}
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
|
|
7043
|
-
// the fence supervised instead of stranding a cleared memory state
|
|
7044
|
-
// against a `delete_pending` disk state.
|
|
7045
|
-
if (topicDeleteSettled(deletion)) {
|
|
7046
|
-
const settled = this.topics.settleDelete(sessionId, acceptedTopicId, fencedCreateEpoch);
|
|
7047
|
-
if (!settled) {
|
|
7048
|
-
this.#superviseCompensationFence(sessionId);
|
|
7049
|
-
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7050
|
-
} else
|
|
7051
|
-
try {
|
|
7052
|
-
await this.persistTopics();
|
|
7053
|
-
this.topics.commitSettledDelete(settled);
|
|
7054
|
-
acceptedTopicCompensated = true;
|
|
7055
|
-
} catch {
|
|
7056
|
-
this.topics.rollbackSettledDelete(settled);
|
|
7057
|
-
this.#superviseCompensationFence(sessionId);
|
|
7058
|
-
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7059
|
-
}
|
|
7060
|
-
} else {
|
|
7061
|
-
this.#superviseCompensationFence(sessionId);
|
|
7062
|
-
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7913
|
+
} catch (error) {
|
|
7914
|
+
this.topics.scheduleArchiveRetry(
|
|
7915
|
+
sessionId,
|
|
7916
|
+
this.runtime.now(),
|
|
7917
|
+
"archive fence persistence failed",
|
|
7918
|
+
);
|
|
7919
|
+
try {
|
|
7920
|
+
await this.#persistTopicsWithRetry();
|
|
7921
|
+
} catch {
|
|
7922
|
+
await this.#superviseCompensationFence(sessionId);
|
|
7063
7923
|
}
|
|
7924
|
+
throw error;
|
|
7925
|
+
}
|
|
7926
|
+
if (
|
|
7927
|
+
this.#acceptedCreateArchiveFenceAllows(
|
|
7928
|
+
sessionId,
|
|
7929
|
+
acceptedTopicId,
|
|
7930
|
+
creationLeaseEpoch,
|
|
7931
|
+
creationBinding,
|
|
7932
|
+
)
|
|
7933
|
+
) {
|
|
7934
|
+
acceptedTopicArchiveAttempted = true;
|
|
7935
|
+
acceptedTopicCompensated = (await this.archiveTopic(sessionId, undefined, true)) === "settled";
|
|
7064
7936
|
}
|
|
7065
7937
|
throw new Error("topic authority was revoked during creation");
|
|
7066
7938
|
}
|
|
@@ -7073,9 +7945,8 @@ export class TelegramNotificationDaemon {
|
|
|
7073
7945
|
session,
|
|
7074
7946
|
adoptionIntentCandidate ? "user_created" : undefined,
|
|
7075
7947
|
);
|
|
7076
|
-
// Adoption commit success
|
|
7077
|
-
//
|
|
7078
|
-
// stale sidecar is left for startup reconciliation to remove safely.
|
|
7948
|
+
// Adoption commit success removes only the sidecar. The committed
|
|
7949
|
+
// user-created topic remains retained even when sidecar cleanup fails.
|
|
7079
7950
|
if (adoptedTopicId !== undefined) {
|
|
7080
7951
|
if (adoptionIntentCandidate)
|
|
7081
7952
|
logger.info(
|
|
@@ -7090,6 +7961,10 @@ export class TelegramNotificationDaemon {
|
|
|
7090
7961
|
);
|
|
7091
7962
|
}
|
|
7092
7963
|
}
|
|
7964
|
+
// Publish the durable host lease before rechecking the captured socket
|
|
7965
|
+
// lease. The recheck consults the registry once a record exists, so the
|
|
7966
|
+
// newly-created record must already authorize this host.
|
|
7967
|
+
if (!(await this.#renewTopicLease(sessionId))) return undefined;
|
|
7093
7968
|
// getOrCreateTopic deduplicates callers, so an accepted create can be
|
|
7094
7969
|
// observed by a successor after its initiating socket was revoked. Check
|
|
7095
7970
|
// the immutable lease again before exposing that record to frame delivery.
|
|
@@ -7111,78 +7986,100 @@ export class TelegramNotificationDaemon {
|
|
|
7111
7986
|
sessionId,
|
|
7112
7987
|
rec.topicId,
|
|
7113
7988
|
creationLeaseEpoch,
|
|
7989
|
+
this.installationHostId,
|
|
7114
7990
|
this.opts.now,
|
|
7115
7991
|
name,
|
|
7116
7992
|
creationBinding,
|
|
7993
|
+
undefined,
|
|
7994
|
+
String(this.opts.chatId),
|
|
7117
7995
|
)
|
|
7118
|
-
)
|
|
7119
|
-
|
|
7996
|
+
) {
|
|
7997
|
+
try {
|
|
7998
|
+
await this.persistTopics();
|
|
7999
|
+
} catch {
|
|
8000
|
+
this.#superviseCompensationFence(sessionId);
|
|
8001
|
+
return undefined;
|
|
8002
|
+
}
|
|
8003
|
+
if (this.#acceptedCreateArchiveFenceAllows(sessionId, rec.topicId, creationLeaseEpoch, creationBinding))
|
|
8004
|
+
await this.archiveTopic(sessionId, undefined, true);
|
|
8005
|
+
}
|
|
7120
8006
|
return undefined;
|
|
7121
8007
|
}
|
|
7122
8008
|
return rec.topicId;
|
|
7123
8009
|
} catch (err) {
|
|
7124
|
-
if (
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
|
|
8010
|
+
if (adoptedTopicId !== undefined) this.#adoptionIntents.releaseClaim(adoptedTopicId, sessionId);
|
|
8011
|
+
if (adoptionIntentCandidate) this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch);
|
|
8012
|
+
if (creationSuppressed || creationRejected || err instanceof ThreadedModeCapabilityRefusal) {
|
|
8013
|
+
if (this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch)) await this.persistTopics();
|
|
8014
|
+
return undefined;
|
|
8015
|
+
}
|
|
8016
|
+
const revokedAcceptedRecord = acceptedTopicId ? this.topics.get(sessionId) : undefined;
|
|
8017
|
+
if (
|
|
8018
|
+
revokedAcceptedRecord !== undefined &&
|
|
8019
|
+
revokedAcceptedRecord.topicId === acceptedTopicId &&
|
|
8020
|
+
revokedAcceptedRecord.authorityState === "archive_pending" &&
|
|
8021
|
+
!this.topics.archiveAuthorityAllows(
|
|
8022
|
+
sessionId,
|
|
8023
|
+
this.installationHostId,
|
|
8024
|
+
this.opts.chatId,
|
|
8025
|
+
this.runtime.now(),
|
|
8026
|
+
)
|
|
8027
|
+
) {
|
|
8028
|
+
if (
|
|
8029
|
+
revokedAcceptedRecord.chatId === undefined &&
|
|
8030
|
+
revokedAcceptedRecord.endpointKey === undefined &&
|
|
8031
|
+
revokedAcceptedRecord.endpointDigest === undefined
|
|
8032
|
+
)
|
|
8033
|
+
revokedAcceptedRecord.chatId = String(this.opts.chatId);
|
|
8034
|
+
this.topics.beginArchive(sessionId, this.installationHostId, this.runtime.now());
|
|
8035
|
+
await this.persistTopics();
|
|
7129
8036
|
}
|
|
7130
8037
|
if (
|
|
7131
8038
|
acceptedTopicId &&
|
|
7132
8039
|
!acceptedTopicCompensated &&
|
|
7133
|
-
!
|
|
7134
|
-
this.topics.get(sessionId)?.authorityState !== "
|
|
8040
|
+
!acceptedTopicArchiveAttempted &&
|
|
8041
|
+
this.topics.get(sessionId)?.authorityState !== "archive_pending"
|
|
7135
8042
|
) {
|
|
7136
8043
|
// A failed initial commit must never make compensation conditional on
|
|
7137
8044
|
// successfully publishing its fence.
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
8045
|
+
if (
|
|
8046
|
+
this.topics.fenceAcceptedCreateForLease(
|
|
8047
|
+
sessionId,
|
|
8048
|
+
acceptedTopicId,
|
|
8049
|
+
creationLeaseEpoch,
|
|
8050
|
+
this.installationHostId,
|
|
8051
|
+
this.opts.now,
|
|
8052
|
+
name,
|
|
8053
|
+
creationBinding,
|
|
8054
|
+
undefined,
|
|
8055
|
+
String(this.opts.chatId),
|
|
8056
|
+
)
|
|
8057
|
+
) {
|
|
7150
8058
|
try {
|
|
7151
8059
|
await this.#persistTopicsWithRetry();
|
|
7152
8060
|
} catch {
|
|
7153
|
-
this.#superviseCompensationFence(sessionId);
|
|
8061
|
+
await this.#superviseCompensationFence(sessionId);
|
|
7154
8062
|
}
|
|
7155
8063
|
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7163
|
-
|
|
7164
|
-
|
|
7165
|
-
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
await this.persistTopics();
|
|
7169
|
-
this.topics.commitSettledDelete(settled);
|
|
7170
|
-
} catch {
|
|
7171
|
-
this.topics.rollbackSettledDelete(settled);
|
|
7172
|
-
this.#superviseCompensationFence(sessionId);
|
|
7173
|
-
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7174
|
-
}
|
|
7175
|
-
} else {
|
|
8064
|
+
if (
|
|
8065
|
+
this.#acceptedCreateArchiveFenceAllows(
|
|
8066
|
+
sessionId,
|
|
8067
|
+
acceptedTopicId,
|
|
8068
|
+
creationLeaseEpoch,
|
|
8069
|
+
creationBinding,
|
|
8070
|
+
)
|
|
8071
|
+
) {
|
|
8072
|
+
try {
|
|
8073
|
+
acceptedTopicArchiveAttempted = true;
|
|
8074
|
+
acceptedTopicCompensated = (await this.archiveTopic(sessionId, undefined, true)) === "settled";
|
|
8075
|
+
} catch {
|
|
7176
8076
|
this.#superviseCompensationFence(sessionId);
|
|
7177
8077
|
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7178
8078
|
}
|
|
7179
|
-
} catch {
|
|
7180
|
-
this.#superviseCompensationFence(sessionId);
|
|
7181
|
-
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7182
8079
|
}
|
|
7183
8080
|
}
|
|
7184
8081
|
}
|
|
7185
|
-
if (acceptedTopicId && !acceptedTopicCompensated &&
|
|
8082
|
+
if (acceptedTopicId && !acceptedTopicCompensated && acceptedTopicArchiveAttempted) {
|
|
7186
8083
|
this.#superviseCompensationFence(sessionId);
|
|
7187
8084
|
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7188
8085
|
}
|
|
@@ -7211,24 +8108,75 @@ export class TelegramNotificationDaemon {
|
|
|
7211
8108
|
if (!this.topicPastOrphanGrace(sessionId)) return;
|
|
7212
8109
|
const currentOwner = this.logicalSessionOwners.get(sessionId);
|
|
7213
8110
|
if (currentOwner && this.#leaseAllows(currentOwner, sessionId)) return;
|
|
7214
|
-
await this.
|
|
8111
|
+
await this.archiveTopic(sessionId);
|
|
7215
8112
|
});
|
|
7216
8113
|
}
|
|
7217
8114
|
|
|
7218
8115
|
/** Best-effort delete of a session topic once its local notification endpoint shuts down. */
|
|
7219
|
-
|
|
8116
|
+
/** Join all close, compensation, orphan, and restart callers for one session. */
|
|
8117
|
+
private archiveTopic(
|
|
7220
8118
|
sessionId: string,
|
|
7221
8119
|
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
7222
|
-
|
|
8120
|
+
archiveFenceAlreadyPublished = false,
|
|
7223
8121
|
): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> {
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
8122
|
+
const active = this.archiveFlights.get(sessionId);
|
|
8123
|
+
if (active) return active;
|
|
8124
|
+
const flight = this.#archiveTopicOnce(sessionId, socketLease, archiveFenceAlreadyPublished);
|
|
8125
|
+
this.archiveFlights.set(sessionId, flight);
|
|
8126
|
+
void flight.then(
|
|
8127
|
+
() => {
|
|
8128
|
+
if (this.archiveFlights.get(sessionId) === flight) this.archiveFlights.delete(sessionId);
|
|
8129
|
+
},
|
|
8130
|
+
() => {
|
|
8131
|
+
if (this.archiveFlights.get(sessionId) === flight) this.archiveFlights.delete(sessionId);
|
|
8132
|
+
},
|
|
8133
|
+
);
|
|
8134
|
+
return flight;
|
|
8135
|
+
}
|
|
8136
|
+
|
|
8137
|
+
/** One durable archive fence and at most one remote close dispatch per flight. */
|
|
8138
|
+
async #archiveTopicOnce(
|
|
8139
|
+
sessionId: string,
|
|
8140
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
8141
|
+
archiveFenceAlreadyPublished = false,
|
|
8142
|
+
): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> {
|
|
8143
|
+
if (!(await this.pairedChatAllowsTopics())) return "pre_dispatch_cancelled";
|
|
8144
|
+
const existing = this.topics.get(sessionId);
|
|
8145
|
+
// A completed archive retains an inactive record as historical evidence.
|
|
8146
|
+
// Re-archiving it would duplicate the irreversible remote close request.
|
|
8147
|
+
if (existing?.authorityState === "inactive") return "settled";
|
|
8148
|
+
if (existing?.topicOrigin === "user_created") {
|
|
8149
|
+
if (existing.authorityState === "archive_pending") {
|
|
8150
|
+
const archiveSnapshot = this.topics.captureArchiveAuthority(sessionId);
|
|
8151
|
+
const authorityEpoch = existing.authorityEpoch ?? Number.MAX_SAFE_INTEGER;
|
|
8152
|
+
if (!this.topics.settleArchive(sessionId, existing.topicId, authorityEpoch)) return "post_dispatch_pending";
|
|
8153
|
+
try {
|
|
8154
|
+
await this.persistTopics();
|
|
8155
|
+
} catch {
|
|
8156
|
+
this.topics.restoreArchiveFence(archiveSnapshot);
|
|
8157
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
8158
|
+
return "post_dispatch_pending";
|
|
8159
|
+
}
|
|
8160
|
+
}
|
|
8161
|
+
return "settled";
|
|
8162
|
+
}
|
|
8163
|
+
const archiveSnapshot = this.topics.captureArchiveAuthority(sessionId);
|
|
8164
|
+
let record = archiveFenceAlreadyPublished
|
|
8165
|
+
? existing
|
|
8166
|
+
: this.topics.beginArchive(sessionId, this.installationHostId, this.runtime.now());
|
|
7229
8167
|
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
7230
|
-
|
|
8168
|
+
if (
|
|
8169
|
+
record &&
|
|
8170
|
+
!this.topics.archiveAuthorityAllows(sessionId, this.installationHostId, this.opts.chatId, this.runtime.now())
|
|
8171
|
+
)
|
|
8172
|
+
return "pre_dispatch_cancelled";
|
|
8173
|
+
if (!archiveFenceAlreadyPublished) await this.persistTopics();
|
|
7231
8174
|
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
8175
|
+
if (
|
|
8176
|
+
record &&
|
|
8177
|
+
!this.topics.archiveAuthorityAllows(sessionId, this.installationHostId, this.opts.chatId, this.runtime.now())
|
|
8178
|
+
)
|
|
8179
|
+
return "pre_dispatch_cancelled";
|
|
7232
8180
|
await this.#revokeAskAuthority(sessionId);
|
|
7233
8181
|
this.deleteMessageRoutes(sessionId);
|
|
7234
8182
|
this.#clearModelChoiceAliases(sessionId);
|
|
@@ -7237,7 +8185,17 @@ export class TelegramNotificationDaemon {
|
|
|
7237
8185
|
record = this.topics.get(sessionId);
|
|
7238
8186
|
await this.persistTopics();
|
|
7239
8187
|
if (!record) return "settled";
|
|
8188
|
+
if (
|
|
8189
|
+
!this.topics.archiveAuthorityAllows(
|
|
8190
|
+
sessionId,
|
|
8191
|
+
this.installationHostId,
|
|
8192
|
+
this.opts.chatId,
|
|
8193
|
+
this.runtime.now(),
|
|
8194
|
+
)
|
|
8195
|
+
)
|
|
8196
|
+
return "pre_dispatch_cancelled";
|
|
7240
8197
|
}
|
|
8198
|
+
const dispatchedAuthorityEpoch = record.authorityEpoch ?? Number.MAX_SAFE_INTEGER;
|
|
7241
8199
|
const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
|
|
7242
8200
|
for (const item of removed) {
|
|
7243
8201
|
if (item.payload.selectedAck)
|
|
@@ -7247,43 +8205,26 @@ export class TelegramNotificationDaemon {
|
|
|
7247
8205
|
try {
|
|
7248
8206
|
await this.flushPool();
|
|
7249
8207
|
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
7250
|
-
if (
|
|
7251
|
-
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
|
|
7255
|
-
|
|
7256
|
-
|
|
7257
|
-
|
|
7258
|
-
|
|
7259
|
-
|
|
7260
|
-
this.liveMessages.delete(k);
|
|
7261
|
-
this.toolActivityOwners.delete(k);
|
|
7262
|
-
}
|
|
7263
|
-
this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
|
|
7264
|
-
if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
|
|
7265
|
-
});
|
|
7266
|
-
this.pendingThreadedFrames.delete(sessionId);
|
|
7267
|
-
try {
|
|
7268
|
-
await this.persistTopics();
|
|
7269
|
-
this.topics.commitSettledDelete(settled);
|
|
7270
|
-
return "settled";
|
|
7271
|
-
} catch {
|
|
7272
|
-
this.topics.rollbackSettledDelete(settled);
|
|
7273
|
-
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7274
|
-
return "post_dispatch_pending";
|
|
7275
|
-
}
|
|
7276
|
-
}
|
|
7277
|
-
const res = (await this.botApi.call("deleteForumTopic", {
|
|
8208
|
+
if (
|
|
8209
|
+
!this.topics.archiveAuthorityAllows(
|
|
8210
|
+
sessionId,
|
|
8211
|
+
this.installationHostId,
|
|
8212
|
+
this.opts.chatId,
|
|
8213
|
+
this.runtime.now(),
|
|
8214
|
+
)
|
|
8215
|
+
)
|
|
8216
|
+
return "pre_dispatch_cancelled";
|
|
8217
|
+
const res = (await this.botApi.call("closeForumTopic", {
|
|
7278
8218
|
chat_id: this.opts.chatId,
|
|
7279
8219
|
message_thread_id: Number(record.topicId),
|
|
7280
8220
|
})) as { ok?: boolean };
|
|
7281
|
-
if (!
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
8221
|
+
if (!topicArchiveSettled(res)) {
|
|
8222
|
+
this.topics.scheduleArchiveRetry(sessionId, this.runtime.now(), "archive result was not definitive");
|
|
8223
|
+
await this.persistTopics();
|
|
7285
8224
|
return "post_dispatch_pending";
|
|
7286
8225
|
}
|
|
8226
|
+
if (!this.topics.settleArchive(sessionId, record.topicId, dispatchedAuthorityEpoch))
|
|
8227
|
+
return "post_dispatch_pending";
|
|
7287
8228
|
for (const k of [...this.liveMessages.keys()])
|
|
7288
8229
|
if (k.startsWith(`${sessionId}:`)) {
|
|
7289
8230
|
this.liveMessages.delete(k);
|
|
@@ -7295,32 +8236,89 @@ export class TelegramNotificationDaemon {
|
|
|
7295
8236
|
this.pendingThreadedFrames.delete(sessionId);
|
|
7296
8237
|
try {
|
|
7297
8238
|
await this.persistTopics();
|
|
7298
|
-
this.topics.commitSettledDelete(settled);
|
|
7299
8239
|
return "settled";
|
|
7300
8240
|
} catch {
|
|
7301
|
-
this.topics.
|
|
8241
|
+
this.topics.restoreArchiveFence(archiveSnapshot);
|
|
7302
8242
|
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7303
8243
|
return "post_dispatch_pending";
|
|
7304
8244
|
}
|
|
7305
8245
|
} catch {
|
|
7306
|
-
// Once Telegram dispatch starts, retain the persisted
|
|
8246
|
+
// Once Telegram dispatch starts, retain the persisted archive fence: the
|
|
7307
8247
|
// remote result is ambiguous and may not restore stale routing authority.
|
|
8248
|
+
this.topics.scheduleArchiveRetry(sessionId, this.runtime.now(), "archive transport failed");
|
|
8249
|
+
await this.persistTopics().catch(() => undefined);
|
|
7308
8250
|
return "post_dispatch_pending";
|
|
7309
8251
|
}
|
|
7310
8252
|
}
|
|
7311
8253
|
|
|
7312
8254
|
/** Serialize a mutation and its durable snapshot so rollback precedes later writers. */
|
|
7313
8255
|
#persistTopicMutation<T>(mutation: () => T, rollback: () => void): Promise<T> {
|
|
8256
|
+
if (this.validationMode()) return Promise.resolve().then(mutation);
|
|
7314
8257
|
const pending = this.topicsPersistQueue.then(async () => {
|
|
7315
|
-
|
|
8258
|
+
let result = mutation();
|
|
8259
|
+
const authority = this.opts.topicRegistryAuthority;
|
|
8260
|
+
let sharedCommitted = false;
|
|
8261
|
+
let latestWinner: TopicRegistryState | undefined;
|
|
7316
8262
|
try {
|
|
7317
8263
|
const snapshot = this.#topicStateForPersistence();
|
|
7318
|
-
|
|
7319
|
-
|
|
7320
|
-
|
|
8264
|
+
if (authority) {
|
|
8265
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
8266
|
+
const expectedGeneration = this.topics.registryVersion();
|
|
8267
|
+
const snapshot = this.#topicStateForPersistence();
|
|
8268
|
+
const nextGeneration = expectedGeneration + 1;
|
|
8269
|
+
snapshot.registryGeneration = nextGeneration;
|
|
8270
|
+
let accepted = false;
|
|
8271
|
+
try {
|
|
8272
|
+
accepted = await authority.compareAndSet(expectedGeneration, snapshot);
|
|
8273
|
+
} catch {
|
|
8274
|
+
throw new Error("shared topic authority unavailable");
|
|
8275
|
+
}
|
|
8276
|
+
if (accepted) {
|
|
8277
|
+
this.topics.markRegistryPublished(nextGeneration);
|
|
8278
|
+
sharedCommitted = true;
|
|
8279
|
+
break;
|
|
8280
|
+
}
|
|
8281
|
+
const winner = parseTopicRegistryState(await authority.read().catch(() => undefined));
|
|
8282
|
+
if (!winner) throw new Error("shared topic authority conflict");
|
|
8283
|
+
latestWinner = winner;
|
|
8284
|
+
this.#replaceTopicAuthority(winner);
|
|
8285
|
+
if (attempt === 2) throw new Error("shared topic authority conflict");
|
|
8286
|
+
result = mutation();
|
|
8287
|
+
}
|
|
8288
|
+
}
|
|
8289
|
+
if (!authority) {
|
|
8290
|
+
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
8291
|
+
await ensureDir(this.fsImpl, paths.dir);
|
|
8292
|
+
const expectedDestination = await captureTopicRegistryDestination(
|
|
8293
|
+
this.fsImpl,
|
|
8294
|
+
path.join(paths.dir, "telegram-topics.json"),
|
|
8295
|
+
process.platform,
|
|
8296
|
+
);
|
|
8297
|
+
await writeTopicRegistryAtomic(
|
|
8298
|
+
this.fsImpl,
|
|
8299
|
+
path.join(paths.dir, "telegram-topics.json"),
|
|
8300
|
+
snapshot,
|
|
8301
|
+
process.platform,
|
|
8302
|
+
exactReplacePath,
|
|
8303
|
+
expectedDestination,
|
|
8304
|
+
);
|
|
8305
|
+
}
|
|
7321
8306
|
return result;
|
|
7322
8307
|
} catch (error) {
|
|
7323
|
-
|
|
8308
|
+
if (sharedCommitted && authority) {
|
|
8309
|
+
const committed = await authority.read().catch(() => undefined);
|
|
8310
|
+
const state = parseTopicRegistryState(committed);
|
|
8311
|
+
if (state) {
|
|
8312
|
+
this.topics.replace(state);
|
|
8313
|
+
this.closedEndpointKeys.clear();
|
|
8314
|
+
for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {}))
|
|
8315
|
+
if (binding) this.closedEndpointKeys.set(sessionId, binding);
|
|
8316
|
+
}
|
|
8317
|
+
} else if (latestWinner) {
|
|
8318
|
+
this.#replaceTopicAuthority(latestWinner);
|
|
8319
|
+
} else {
|
|
8320
|
+
rollback();
|
|
8321
|
+
}
|
|
7324
8322
|
throw error;
|
|
7325
8323
|
}
|
|
7326
8324
|
});
|
|
@@ -7339,8 +8337,9 @@ export class TelegramNotificationDaemon {
|
|
|
7339
8337
|
}
|
|
7340
8338
|
}
|
|
7341
8339
|
|
|
7342
|
-
#superviseCompensationFence(sessionId: string): void {
|
|
7343
|
-
|
|
8340
|
+
async #superviseCompensationFence(sessionId: string): Promise<void> {
|
|
8341
|
+
const existing = this.compensationFenceRetries.get(sessionId);
|
|
8342
|
+
if (existing) return await existing;
|
|
7344
8343
|
const retry = this.effects.track(
|
|
7345
8344
|
(async () => {
|
|
7346
8345
|
for (;;) {
|
|
@@ -7354,35 +8353,143 @@ export class TelegramNotificationDaemon {
|
|
|
7354
8353
|
})(),
|
|
7355
8354
|
);
|
|
7356
8355
|
this.compensationFenceRetries.set(sessionId, retry);
|
|
7357
|
-
|
|
8356
|
+
try {
|
|
8357
|
+
await retry;
|
|
8358
|
+
} finally {
|
|
8359
|
+
if (this.compensationFenceRetries.get(sessionId) === retry) this.compensationFenceRetries.delete(sessionId);
|
|
8360
|
+
}
|
|
7358
8361
|
}
|
|
7359
8362
|
|
|
7360
8363
|
private persistTopics(): Promise<void> {
|
|
8364
|
+
if (this.validationMode()) return Promise.resolve();
|
|
7361
8365
|
const pending = this.topicsPersistQueue.then(async () => {
|
|
7362
8366
|
// Resolve implicit snapshots inside the serialization queue. Callers that
|
|
7363
8367
|
// mutate the registry before waiting cannot overwrite a newer authority
|
|
7364
8368
|
// binding with an invocation-time snapshot.
|
|
7365
8369
|
const snapshot = this.#topicStateForPersistence();
|
|
7366
|
-
const
|
|
7367
|
-
|
|
7368
|
-
|
|
8370
|
+
const authority = this.opts.topicRegistryAuthority;
|
|
8371
|
+
if (authority) {
|
|
8372
|
+
const expectedGeneration = this.topics.registryVersion();
|
|
8373
|
+
const nextGeneration = expectedGeneration + 1;
|
|
8374
|
+
snapshot.registryGeneration = nextGeneration;
|
|
8375
|
+
let accepted = false;
|
|
8376
|
+
try {
|
|
8377
|
+
accepted = await authority.compareAndSet(expectedGeneration, snapshot);
|
|
8378
|
+
} catch {
|
|
8379
|
+
throw new Error("shared topic authority unavailable");
|
|
8380
|
+
}
|
|
8381
|
+
if (!accepted) {
|
|
8382
|
+
let winner: TopicRegistryState | undefined;
|
|
8383
|
+
try {
|
|
8384
|
+
winner = parseTopicRegistryState(await authority.read());
|
|
8385
|
+
} catch {
|
|
8386
|
+
throw new Error("shared topic authority unavailable");
|
|
8387
|
+
}
|
|
8388
|
+
if (!winner) throw new Error("shared topic authority conflict");
|
|
8389
|
+
this.#replaceTopicAuthority(winner);
|
|
8390
|
+
throw new Error("shared topic authority conflict");
|
|
8391
|
+
}
|
|
8392
|
+
this.topics.markRegistryPublished(nextGeneration);
|
|
8393
|
+
}
|
|
8394
|
+
if (!authority) {
|
|
8395
|
+
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
8396
|
+
await ensureDir(this.fsImpl, paths.dir);
|
|
8397
|
+
const topicPath = path.join(paths.dir, "telegram-topics.json");
|
|
8398
|
+
const expectedDestination = await captureTopicRegistryDestination(this.fsImpl, topicPath, process.platform);
|
|
8399
|
+
await writeTopicRegistryAtomic(
|
|
8400
|
+
this.fsImpl,
|
|
8401
|
+
topicPath,
|
|
8402
|
+
snapshot,
|
|
8403
|
+
process.platform,
|
|
8404
|
+
exactReplacePath,
|
|
8405
|
+
expectedDestination,
|
|
8406
|
+
);
|
|
8407
|
+
}
|
|
7369
8408
|
});
|
|
7370
8409
|
this.topicsPersistQueue = pending.catch(() => undefined);
|
|
7371
8410
|
return pending;
|
|
7372
8411
|
}
|
|
7373
8412
|
|
|
8413
|
+
#acceptedCreateArchiveFenceAllows(
|
|
8414
|
+
sessionId: string,
|
|
8415
|
+
topicId: string,
|
|
8416
|
+
creationLeaseEpoch: number,
|
|
8417
|
+
binding: TopicEndpointBinding | undefined,
|
|
8418
|
+
): boolean {
|
|
8419
|
+
const record = this.topics.get(sessionId);
|
|
8420
|
+
const bindingMatches = binding
|
|
8421
|
+
? record?.chatId === binding.chatId &&
|
|
8422
|
+
record.endpointKey === binding.endpointKey &&
|
|
8423
|
+
record.endpointDigest === binding.endpointDigest &&
|
|
8424
|
+
record.endpointGeneration === binding.endpointGeneration
|
|
8425
|
+
: record?.chatId === String(this.opts.chatId) &&
|
|
8426
|
+
record.endpointKey === undefined &&
|
|
8427
|
+
record.endpointDigest === undefined;
|
|
8428
|
+
return (
|
|
8429
|
+
record?.topicId === topicId &&
|
|
8430
|
+
record.creationLeaseEpoch === creationLeaseEpoch &&
|
|
8431
|
+
bindingMatches &&
|
|
8432
|
+
this.topics.archiveAuthorityAllows(sessionId, this.installationHostId, this.opts.chatId, this.runtime.now())
|
|
8433
|
+
);
|
|
8434
|
+
}
|
|
8435
|
+
|
|
8436
|
+
#replaceTopicAuthority(state: TopicRegistryState): void {
|
|
8437
|
+
this.topics.replace(state);
|
|
8438
|
+
this.closedEndpointKeys.clear();
|
|
8439
|
+
for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {}))
|
|
8440
|
+
if (binding) this.closedEndpointKeys.set(sessionId, binding);
|
|
8441
|
+
}
|
|
8442
|
+
|
|
7374
8443
|
#topicStateForPersistence(): TopicRegistryState {
|
|
7375
|
-
return {
|
|
8444
|
+
return {
|
|
8445
|
+
...this.topics.serialize(),
|
|
8446
|
+
closedEndpoints: Object.fromEntries(this.closedEndpointKeys),
|
|
8447
|
+
...(this.opts.topicRegistryAuthority ? {} : { installationHostId: this.installationHostId }),
|
|
8448
|
+
};
|
|
7376
8449
|
}
|
|
7377
8450
|
|
|
7378
8451
|
async loadTopics(): Promise<void> {
|
|
8452
|
+
if (this.validationMode()) return;
|
|
7379
8453
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
7380
|
-
const
|
|
8454
|
+
const topicPath = path.join(paths.dir, "telegram-topics.json");
|
|
8455
|
+
let raw = await readJson<unknown>(this.fsImpl, topicPath);
|
|
8456
|
+
if (this.opts.topicRegistryAuthority) {
|
|
8457
|
+
try {
|
|
8458
|
+
raw = await this.opts.topicRegistryAuthority.read();
|
|
8459
|
+
} catch {
|
|
8460
|
+
throw new Error("shared topic authority unavailable");
|
|
8461
|
+
}
|
|
8462
|
+
if (!raw) throw new Error("shared topic authority unavailable");
|
|
8463
|
+
}
|
|
8464
|
+
const legacySnapshot =
|
|
8465
|
+
!!raw && typeof raw === "object" && !Array.isArray(raw) && !Object.hasOwn(raw as object, "version");
|
|
8466
|
+
if (legacySnapshot && raw !== undefined) {
|
|
8467
|
+
const legacyDigest = crypto.createHash("sha256").update(JSON.stringify(raw)).digest("hex");
|
|
8468
|
+
const quarantinePath = path.join(paths.dir, `telegram-topics.legacy-quarantine.${legacyDigest}.json`);
|
|
8469
|
+
if ((await readJson<unknown>(this.fsImpl, quarantinePath)) === undefined) {
|
|
8470
|
+
await ensureDir(this.fsImpl, paths.dir);
|
|
8471
|
+
await writeTopicRegistryAtomic(this.fsImpl, quarantinePath, raw);
|
|
8472
|
+
}
|
|
8473
|
+
}
|
|
8474
|
+
const state = parseTopicRegistryState(raw);
|
|
7381
8475
|
// Restore the full serialized registry (topicId + identitySent + name) so a
|
|
7382
8476
|
// fresh daemon after reload does not resend identity headers or lose renames.
|
|
7383
|
-
if (
|
|
7384
|
-
|
|
7385
|
-
|
|
8477
|
+
if (state) {
|
|
8478
|
+
const missingHostId =
|
|
8479
|
+
!this.opts.topicRegistryAuthority &&
|
|
8480
|
+
(typeof state.installationHostId !== "string" || state.installationHostId.length === 0);
|
|
8481
|
+
const missingSessionUuid = Object.values(state.topics).some(
|
|
8482
|
+
record => !!record && typeof record === "object" && typeof record.sessionUuid !== "string",
|
|
8483
|
+
);
|
|
8484
|
+
this.topics.load(state);
|
|
8485
|
+
if (!this.opts.topicRegistryAuthority && typeof state.installationHostId === "string")
|
|
8486
|
+
this.installationHostId = state.installationHostId;
|
|
8487
|
+
let reconciledCreateClaim = false;
|
|
8488
|
+
for (const claim of this.topics.pendingCreateClaims()) {
|
|
8489
|
+
const topic = this.topics.get(claim.sessionId);
|
|
8490
|
+
reconciledCreateClaim = this.topics.reconcileCreateClaim(claim.sessionId, topic) || reconciledCreateClaim;
|
|
8491
|
+
}
|
|
8492
|
+
for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {})) {
|
|
7386
8493
|
if (
|
|
7387
8494
|
binding &&
|
|
7388
8495
|
typeof binding.chatId === "string" &&
|
|
@@ -7393,14 +8500,14 @@ export class TelegramNotificationDaemon {
|
|
|
7393
8500
|
)
|
|
7394
8501
|
this.closedEndpointKeys.set(sessionId, binding);
|
|
7395
8502
|
}
|
|
8503
|
+
if (legacySnapshot || missingHostId || missingSessionUuid || reconciledCreateClaim) await this.persistTopics();
|
|
7396
8504
|
}
|
|
7397
8505
|
}
|
|
7398
8506
|
|
|
7399
8507
|
/**
|
|
7400
|
-
* Rehydrate durable adoption intents after restart
|
|
7401
|
-
*
|
|
7402
|
-
*
|
|
7403
|
-
* and is removed safely without touching the user topic.
|
|
8508
|
+
* Rehydrate durable adoption intents after restart. A sidecar whose topic
|
|
8509
|
+
* already committed is stale evidence and can be removed without touching
|
|
8510
|
+
* the retained user-created topic.
|
|
7404
8511
|
*/
|
|
7405
8512
|
async loadAdoptionIntents(): Promise<void> {
|
|
7406
8513
|
await this.#adoptionIntents.rehydrate();
|
|
@@ -7423,7 +8530,6 @@ export class TelegramNotificationDaemon {
|
|
|
7423
8530
|
}
|
|
7424
8531
|
}
|
|
7425
8532
|
|
|
7426
|
-
/** Periodic sweep of expired adoption-intent sidecars (files only; no Telegram API). */
|
|
7427
8533
|
private startAdoptionSweepTimer(): void {
|
|
7428
8534
|
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
7429
8535
|
this.#adoptionSweepTimer = setIntervalImpl(() => {
|
|
@@ -7446,9 +8552,10 @@ export class TelegramNotificationDaemon {
|
|
|
7446
8552
|
}
|
|
7447
8553
|
}
|
|
7448
8554
|
|
|
7449
|
-
/** Retry crash-interrupted topic
|
|
8555
|
+
/** Retry crash-interrupted topic archives only when the durable backoff permits it. */
|
|
7450
8556
|
private async reconcilePendingTopicDeletes(): Promise<void> {
|
|
7451
|
-
for (const sessionId of this.topics.
|
|
8557
|
+
for (const sessionId of this.topics.archivePendingSessionIds(this.runtime.now()))
|
|
8558
|
+
await this.archiveTopic(sessionId);
|
|
7452
8559
|
}
|
|
7453
8560
|
|
|
7454
8561
|
/** Download one Telegram file with the Bot API's 20 MiB ceiling and one end-to-end deadline. */
|
|
@@ -7892,7 +8999,7 @@ export class TelegramNotificationDaemon {
|
|
|
7892
8999
|
continue;
|
|
7893
9000
|
}
|
|
7894
9001
|
const topicId = topicLease?.topicId;
|
|
7895
|
-
if (topicId && !(await this.
|
|
9002
|
+
if (topicId && !(await this.pairedChatAllowsTopics())) {
|
|
7896
9003
|
this.pool.settle(item.itemId!, "rejected");
|
|
7897
9004
|
this.failLegacyToolStart(toolActivity);
|
|
7898
9005
|
continue;
|
|
@@ -8055,7 +9162,7 @@ export class TelegramNotificationDaemon {
|
|
|
8055
9162
|
);
|
|
8056
9163
|
// Index the sent rich message so an inbound reply to it can restore
|
|
8057
9164
|
// the original markdown as context (Telegram does not echo it back).
|
|
8058
|
-
if (richMessageId !== undefined) {
|
|
9165
|
+
if (richMessageId !== undefined && !this.validationMode()) {
|
|
8059
9166
|
await this.replyStore.record({
|
|
8060
9167
|
chatId: this.opts.chatId,
|
|
8061
9168
|
messageId: richMessageId,
|
|
@@ -8362,29 +9469,41 @@ export class TelegramNotificationDaemon {
|
|
|
8362
9469
|
}
|
|
8363
9470
|
|
|
8364
9471
|
/**
|
|
8365
|
-
* Resolve
|
|
8366
|
-
*
|
|
8367
|
-
* indeterminate `getChat` result fails closed for this attempt and is retried
|
|
8368
|
-
* later.
|
|
9472
|
+
* Resolve and cache the outbound delivery boundary. The validation forum is
|
|
9473
|
+
* explicit and topic-only; all inbound and flat paths remain private-only.
|
|
8369
9474
|
*/
|
|
9475
|
+
private validationMode(): boolean {
|
|
9476
|
+
return this.opts.validationTestSupergroupChatId !== undefined;
|
|
9477
|
+
}
|
|
9478
|
+
|
|
8370
9479
|
private async resolvePairedChatPrivacy(): Promise<PairedChatPrivacy> {
|
|
8371
|
-
if (this.
|
|
9480
|
+
if (this.pairedChatPrivacy !== undefined) return this.pairedChatPrivacy;
|
|
9481
|
+
const chatId = this.opts.validationTestSupergroupChatId ?? this.opts.chatId;
|
|
8372
9482
|
try {
|
|
8373
|
-
const res = (await this.botApi.call("getChat", { chat_id:
|
|
9483
|
+
const res = (await this.botApi.call("getChat", { chat_id: chatId })) as {
|
|
8374
9484
|
ok?: unknown;
|
|
8375
|
-
result?: { type?: unknown };
|
|
9485
|
+
result?: { id?: unknown; type?: unknown; is_forum?: unknown };
|
|
8376
9486
|
};
|
|
8377
9487
|
if (res === undefined) return "indeterminate";
|
|
8378
9488
|
if (res?.ok !== true) {
|
|
8379
9489
|
logger.warn("notifications: getChat privacy check indeterminate (non-success response)");
|
|
8380
9490
|
return "indeterminate";
|
|
8381
9491
|
}
|
|
8382
|
-
if (
|
|
8383
|
-
this.
|
|
9492
|
+
if (
|
|
9493
|
+
this.validationMode() &&
|
|
9494
|
+
String(res.result?.id) === this.opts.validationTestSupergroupChatId &&
|
|
9495
|
+
res.result?.type === "supergroup" &&
|
|
9496
|
+
res.result.is_forum === true
|
|
9497
|
+
) {
|
|
9498
|
+
this.pairedChatPrivacy = "validation-forum";
|
|
9499
|
+
return "validation-forum";
|
|
9500
|
+
}
|
|
9501
|
+
if (!this.validationMode() && res.result?.type === "private") {
|
|
9502
|
+
this.pairedChatPrivacy = "private";
|
|
8384
9503
|
return "private";
|
|
8385
9504
|
}
|
|
8386
9505
|
if (res.result?.type === "group" || res.result?.type === "supergroup" || res.result?.type === "channel") {
|
|
8387
|
-
this.
|
|
9506
|
+
this.pairedChatPrivacy = "non-private";
|
|
8388
9507
|
return "non-private";
|
|
8389
9508
|
}
|
|
8390
9509
|
logger.warn("notifications: getChat privacy check indeterminate (missing or invalid chat type)");
|
|
@@ -8395,7 +9514,12 @@ export class TelegramNotificationDaemon {
|
|
|
8395
9514
|
}
|
|
8396
9515
|
}
|
|
8397
9516
|
|
|
8398
|
-
|
|
9517
|
+
private async pairedChatAllowsTopics(): Promise<boolean> {
|
|
9518
|
+
const privacy = await this.resolvePairedChatPrivacy();
|
|
9519
|
+
return privacy === "private" || privacy === "validation-forum";
|
|
9520
|
+
}
|
|
9521
|
+
|
|
9522
|
+
/** Keep all flat delivery and inbound control paths private-only. */
|
|
8399
9523
|
private async pairedChatIsPrivate(): Promise<boolean> {
|
|
8400
9524
|
return (await this.resolvePairedChatPrivacy()) === "private";
|
|
8401
9525
|
}
|
|
@@ -8594,14 +9718,18 @@ export class TelegramNotificationDaemon {
|
|
|
8594
9718
|
);
|
|
8595
9719
|
if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return false;
|
|
8596
9720
|
try {
|
|
8597
|
-
const response = await this.botApi.call(
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
9721
|
+
const response = await this.botApi.call(
|
|
9722
|
+
"sendMessage",
|
|
9723
|
+
{
|
|
9724
|
+
chat_id: this.opts.chatId,
|
|
9725
|
+
message_thread_id: Number(topicId),
|
|
9726
|
+
text: rendered.text,
|
|
9727
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
9728
|
+
reply_markup: { inline_keyboard },
|
|
9729
|
+
...(telegramDisableNotification(this.opts.sound, "ask") === true ? { disable_notification: true } : {}),
|
|
9730
|
+
},
|
|
9731
|
+
{ noRetry: true },
|
|
9732
|
+
);
|
|
8605
9733
|
if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
|
|
8606
9734
|
for (const alias of aliases) this.#modelChoiceAliases.delete(alias);
|
|
8607
9735
|
logger.warn("notifications: failed to send model selection keyboard");
|
|
@@ -8689,10 +9817,13 @@ export class TelegramNotificationDaemon {
|
|
|
8689
9817
|
endpointAuthority.sessionId === session.sessionId &&
|
|
8690
9818
|
ownsLiveOpenEndpoint &&
|
|
8691
9819
|
this.topics.matchesEndpoint(session.sessionId, endpointBinding);
|
|
9820
|
+
const inactiveSuccessor =
|
|
9821
|
+
this.topics.get(session.sessionId)?.authorityState === "inactive" &&
|
|
9822
|
+
this.topics.get(session.sessionId)?.endpointDigest !== endpointBinding.endpointDigest;
|
|
8692
9823
|
const canBootstrapTransport =
|
|
8693
|
-
endpointAuthority.state === "none" &&
|
|
9824
|
+
(endpointAuthority.state === "none" || inactiveSuccessor) &&
|
|
8694
9825
|
ownsLiveOpenEndpoint &&
|
|
8695
|
-
!this.topics.get(session.sessionId) &&
|
|
9826
|
+
(!this.topics.get(session.sessionId) || inactiveSuccessor) &&
|
|
8696
9827
|
!this.preservedInitiatorTopics.has(session.sessionId);
|
|
8697
9828
|
const replayCandidateSessionId =
|
|
8698
9829
|
replayIdentitySessionId ?? (canResumeTransport || canBootstrapTransport ? session.sessionId : undefined);
|
|
@@ -8704,6 +9835,7 @@ export class TelegramNotificationDaemon {
|
|
|
8704
9835
|
true,
|
|
8705
9836
|
replayIdentitySessionId ? undefined : canBootstrapTransport ? "bootstrap" : "resume",
|
|
8706
9837
|
);
|
|
9838
|
+
if (this.sessions.get(session.sessionId) !== session) return;
|
|
8707
9839
|
if (!recovered) {
|
|
8708
9840
|
if (session.hostGeneration === msg.generation && session.recoveryLease?.state !== "pending")
|
|
8709
9841
|
this.dropSession(session, "recovery_rejected");
|
|
@@ -9116,10 +10248,8 @@ export class TelegramNotificationDaemon {
|
|
|
9116
10248
|
abandonStaleToolStart();
|
|
9117
10249
|
return;
|
|
9118
10250
|
}
|
|
9119
|
-
|
|
9120
|
-
|
|
9121
|
-
this.topics.get(logicalSessionId)?.bindingMalformed
|
|
9122
|
-
) {
|
|
10251
|
+
const topicRecord = this.topics.get(logicalSessionId);
|
|
10252
|
+
if (topicRecord && (topicRecord.authorityState !== "active" || topicRecord.bindingMalformed)) {
|
|
9123
10253
|
this.failLegacyToolStart(toolActivity);
|
|
9124
10254
|
return;
|
|
9125
10255
|
}
|
|
@@ -9132,10 +10262,8 @@ export class TelegramNotificationDaemon {
|
|
|
9132
10262
|
(await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session));
|
|
9133
10263
|
const topicLease = await this.topicAuthorityLease(logicalSessionId);
|
|
9134
10264
|
if (!topicId || !topicLease || topicLease.topicId !== topicId) {
|
|
9135
|
-
|
|
9136
|
-
|
|
9137
|
-
this.topics.get(logicalSessionId)?.bindingMalformed
|
|
9138
|
-
) {
|
|
10265
|
+
const topicRecord = this.topics.get(logicalSessionId);
|
|
10266
|
+
if (topicRecord && (topicRecord.authorityState !== "active" || topicRecord.bindingMalformed)) {
|
|
9139
10267
|
this.failLegacyToolStart(toolActivity);
|
|
9140
10268
|
return;
|
|
9141
10269
|
}
|
|
@@ -9195,12 +10323,13 @@ export class TelegramNotificationDaemon {
|
|
|
9195
10323
|
const logicalSessionId = this.#logicalSessionId(session);
|
|
9196
10324
|
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
9197
10325
|
if (!socketLease) return;
|
|
9198
|
-
|
|
9199
|
-
if (
|
|
9200
|
-
|
|
9201
|
-
this.
|
|
9202
|
-
|
|
9203
|
-
|
|
10326
|
+
const pendingAction = msg.kind === "ask" ? { sessionId: logicalSessionId, actionId: msg.id } : undefined;
|
|
10327
|
+
if (pendingAction) {
|
|
10328
|
+
session.pending.set(msg.id, pendingAction);
|
|
10329
|
+
await this.reissuePendingAction(logicalSessionId, msg.id);
|
|
10330
|
+
}
|
|
10331
|
+
const topicRecord = this.topics.get(logicalSessionId);
|
|
10332
|
+
if (topicRecord && (topicRecord.authorityState !== "active" || topicRecord.bindingMalformed)) return;
|
|
9204
10333
|
const topicId = await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session);
|
|
9205
10334
|
const topicLease = topicId ? this.topicAuthorityLeaseFromRegistry(logicalSessionId) : undefined;
|
|
9206
10335
|
if (topicId && (!topicLease || topicLease.topicId !== topicId)) return;
|
|
@@ -9258,81 +10387,245 @@ export class TelegramNotificationDaemon {
|
|
|
9258
10387
|
controls,
|
|
9259
10388
|
summary: msg.summary,
|
|
9260
10389
|
});
|
|
10390
|
+
const kind = msg.kind === "idle" ? "idle" : "ask";
|
|
10391
|
+
const callbackDispatchAuthorityIsCurrent = (): boolean =>
|
|
10392
|
+
this.#leaseTokenAllows(socketLease) &&
|
|
10393
|
+
(!topicLease || this.topicLeaseIsCurrent(topicLease)) &&
|
|
10394
|
+
(kind !== "ask" || (pendingAction !== undefined && session.pending.get(msg.id) === pendingAction));
|
|
10395
|
+
const createdAliases: Array<[string, CallbackRoute]> = [];
|
|
10396
|
+
const createAlias = (answer: CallbackRoute["answer"]): string => {
|
|
10397
|
+
const alias = this.aliasTable.allocate(candidate => this.reissueBacklog.has(candidate));
|
|
10398
|
+
createdAliases.push([
|
|
10399
|
+
alias,
|
|
10400
|
+
{
|
|
10401
|
+
sessionId: logicalSessionId,
|
|
10402
|
+
actionId: msg.id,
|
|
10403
|
+
answer,
|
|
10404
|
+
},
|
|
10405
|
+
]);
|
|
10406
|
+
return alias;
|
|
10407
|
+
};
|
|
9261
10408
|
const inline_keyboard = [
|
|
9262
|
-
...buildCompactChoiceGrid(displayOptions, (i: number) =>
|
|
9263
|
-
this.aliasTable.put({ sessionId: logicalSessionId, actionId: msg.id, answer: i }),
|
|
9264
|
-
),
|
|
10409
|
+
...buildCompactChoiceGrid(displayOptions, (i: number) => createAlias(i)),
|
|
9265
10410
|
...controls.map(control => [
|
|
9266
10411
|
{
|
|
9267
10412
|
text: control.label,
|
|
9268
|
-
callback_data:
|
|
9269
|
-
sessionId: logicalSessionId,
|
|
9270
|
-
actionId: msg.id,
|
|
9271
|
-
answer: { controlId: control.id },
|
|
9272
|
-
}),
|
|
10413
|
+
callback_data: createAlias({ controlId: control.id }),
|
|
9273
10414
|
},
|
|
9274
10415
|
]),
|
|
9275
10416
|
];
|
|
9276
|
-
|
|
10417
|
+
if (createdAliases.length > 0) {
|
|
10418
|
+
for (const [alias, route] of createdAliases) {
|
|
10419
|
+
if (!this.aliasTable.activate(alias, route)) throw new Error("callback alias reservation conflicted");
|
|
10420
|
+
}
|
|
10421
|
+
// Reserve every exposed callback token durably before Telegram can
|
|
10422
|
+
// accept a keyboard. A restart loads these non-receipt routes only
|
|
10423
|
+
// into the non-routable reissue backlog.
|
|
10424
|
+
await this.persistAliases();
|
|
10425
|
+
}
|
|
9277
10426
|
// HTML delivery: one sendMessage per chunk, keyboard on the last chunk;
|
|
9278
|
-
// returns the last chunk's message_id (the reply-routable message).
|
|
10427
|
+
// returns the last chunk's authoritative message_id (the reply-routable message).
|
|
10428
|
+
let callbackDelivery: "accepted" | "rejected" | "ambiguous" = "rejected";
|
|
9279
10429
|
const sendHtmlChunks = async (): Promise<number | undefined> => {
|
|
9280
10430
|
const chunks = splitTelegramHtml(rendered.text);
|
|
9281
|
-
let
|
|
10431
|
+
let acceptedMessageId: number | undefined;
|
|
9282
10432
|
for (let i = 0; i < chunks.length; i++) {
|
|
9283
|
-
|
|
10433
|
+
const exposesCallbacks = i === chunks.length - 1 && inline_keyboard.length > 0;
|
|
10434
|
+
if (!callbackDispatchAuthorityIsCurrent()) return undefined;
|
|
10435
|
+
let result: { ok?: unknown; result?: { message_id?: unknown } } | undefined;
|
|
10436
|
+
try {
|
|
10437
|
+
result = (await this.botApi.call(
|
|
10438
|
+
"sendMessage",
|
|
10439
|
+
{
|
|
10440
|
+
chat_id: this.opts.chatId,
|
|
10441
|
+
...threadField,
|
|
10442
|
+
text: chunks[i]!,
|
|
10443
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
10444
|
+
...(telegramDisableNotification(this.opts.sound, kind, i === chunks.length - 1) === true
|
|
10445
|
+
? { disable_notification: true }
|
|
10446
|
+
: {}),
|
|
10447
|
+
...(exposesCallbacks ? { reply_markup: { inline_keyboard } } : {}),
|
|
10448
|
+
},
|
|
10449
|
+
{ noRetry: true },
|
|
10450
|
+
)) as { ok?: unknown; result?: { message_id?: unknown } } | undefined;
|
|
10451
|
+
} catch {
|
|
10452
|
+
if (exposesCallbacks) callbackDelivery = "ambiguous";
|
|
9284
10453
|
return undefined;
|
|
9285
|
-
|
|
9286
|
-
|
|
9287
|
-
|
|
9288
|
-
|
|
9289
|
-
|
|
9290
|
-
|
|
9291
|
-
|
|
9292
|
-
|
|
9293
|
-
|
|
9294
|
-
}
|
|
9295
|
-
if (
|
|
10454
|
+
}
|
|
10455
|
+
if (result?.ok !== true) {
|
|
10456
|
+
if (exposesCallbacks && result?.ok !== false) callbackDelivery = "ambiguous";
|
|
10457
|
+
return undefined;
|
|
10458
|
+
}
|
|
10459
|
+
const candidate = result.result?.message_id;
|
|
10460
|
+
if (typeof candidate !== "number" || !Number.isSafeInteger(candidate) || candidate <= 0) {
|
|
10461
|
+
if (exposesCallbacks) callbackDelivery = "ambiguous";
|
|
10462
|
+
return undefined;
|
|
10463
|
+
}
|
|
10464
|
+
if (exposesCallbacks) callbackDelivery = "accepted";
|
|
10465
|
+
acceptedMessageId = candidate;
|
|
9296
10466
|
}
|
|
9297
|
-
return
|
|
10467
|
+
return acceptedMessageId;
|
|
9298
10468
|
};
|
|
10469
|
+
let messageId: number | undefined;
|
|
9299
10470
|
if (this.opts.rich?.enabled !== false) {
|
|
9300
10471
|
// Rich (default on): promote to sendRichMessage with a top-level
|
|
9301
10472
|
// reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
|
|
9302
|
-
|
|
9303
|
-
|
|
9304
|
-
|
|
9305
|
-
|
|
9306
|
-
|
|
9307
|
-
|
|
9308
|
-
|
|
9309
|
-
|
|
9310
|
-
|
|
9311
|
-
|
|
9312
|
-
|
|
9313
|
-
|
|
9314
|
-
|
|
9315
|
-
|
|
9316
|
-
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
|
|
9323
|
-
|
|
9324
|
-
|
|
9325
|
-
|
|
9326
|
-
|
|
9327
|
-
|
|
9328
|
-
|
|
9329
|
-
|
|
10473
|
+
if (callbackDispatchAuthorityIsCurrent()) {
|
|
10474
|
+
const outcome = await deliverRichActionWithFallback(
|
|
10475
|
+
this.botApi,
|
|
10476
|
+
{
|
|
10477
|
+
chat_id: this.opts.chatId,
|
|
10478
|
+
...threadField,
|
|
10479
|
+
...(telegramDisableNotification(this.opts.sound, kind) === true
|
|
10480
|
+
? { disable_notification: true }
|
|
10481
|
+
: {}),
|
|
10482
|
+
},
|
|
10483
|
+
{
|
|
10484
|
+
markdown: buildActionMarkdown({
|
|
10485
|
+
kind,
|
|
10486
|
+
question: msg.question,
|
|
10487
|
+
options: displayOptions,
|
|
10488
|
+
recommendedIndex: msg.recommendedIndex,
|
|
10489
|
+
summary: msg.summary,
|
|
10490
|
+
}),
|
|
10491
|
+
replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
|
|
10492
|
+
},
|
|
10493
|
+
AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
|
|
10494
|
+
sendHtmlChunks,
|
|
10495
|
+
logger,
|
|
10496
|
+
);
|
|
10497
|
+
messageId = outcome.messageId;
|
|
10498
|
+
if (createdAliases.length > 0 && messageId !== undefined) callbackDelivery = "accepted";
|
|
10499
|
+
else if (createdAliases.length > 0 && !outcome.usedFallback) callbackDelivery = "ambiguous";
|
|
10500
|
+
}
|
|
9330
10501
|
} else {
|
|
9331
10502
|
// Rich disabled: deliver through the HTML chunk path.
|
|
9332
|
-
|
|
9333
|
-
|
|
9334
|
-
|
|
9335
|
-
|
|
10503
|
+
messageId = await sendHtmlChunks();
|
|
10504
|
+
}
|
|
10505
|
+
const pendingActionIsCurrent = pendingAction !== undefined && session.pending.get(msg.id) === pendingAction;
|
|
10506
|
+
const mayActivate =
|
|
10507
|
+
kind === "ask" &&
|
|
10508
|
+
messageId !== undefined &&
|
|
10509
|
+
pendingActionIsCurrent &&
|
|
10510
|
+
this.#leaseTokenAllows(socketLease) &&
|
|
10511
|
+
(!topicLease || this.topicLeaseIsCurrent(topicLease));
|
|
10512
|
+
if (mayActivate) {
|
|
10513
|
+
for (const [alias, route] of createdAliases) {
|
|
10514
|
+
if (
|
|
10515
|
+
!this.aliasTable.update(alias, {
|
|
10516
|
+
...route,
|
|
10517
|
+
chatId: String(this.opts.chatId),
|
|
10518
|
+
messageId,
|
|
10519
|
+
ownerId: this.opts.ownerId,
|
|
10520
|
+
generation: DAEMON_GENERATION,
|
|
10521
|
+
})
|
|
10522
|
+
)
|
|
10523
|
+
throw new Error("callback alias receipt update conflicted");
|
|
10524
|
+
}
|
|
10525
|
+
this.messageRoutes.set(String(messageId), { sessionId: logicalSessionId, actionId: msg.id });
|
|
10526
|
+
const activation = this.#stageCallbackActivation(
|
|
10527
|
+
createdAliases.map(([alias]) => alias),
|
|
10528
|
+
pendingAction,
|
|
10529
|
+
socketLease,
|
|
10530
|
+
topicLease,
|
|
10531
|
+
);
|
|
10532
|
+
try {
|
|
10533
|
+
// A receipt is durable before an alias gets a routable socket lease.
|
|
10534
|
+
await this.persistAliases();
|
|
10535
|
+
} catch {
|
|
10536
|
+
for (const [alias] of createdAliases) {
|
|
10537
|
+
this.#stagedCallbackActivations.delete(alias);
|
|
10538
|
+
this.#revokeCallbackAlias(alias);
|
|
10539
|
+
}
|
|
10540
|
+
this.deleteMessageRoutes(logicalSessionId, msg.id);
|
|
10541
|
+
try {
|
|
10542
|
+
await this.persistAliases();
|
|
10543
|
+
} catch {
|
|
10544
|
+
// The in-memory backlog retains the accepted receipt for shutdown terminalization.
|
|
10545
|
+
}
|
|
10546
|
+
activation.resolve(false);
|
|
10547
|
+
throw new Error("callback alias receipt persistence failed");
|
|
10548
|
+
}
|
|
10549
|
+
if (
|
|
10550
|
+
session.pending.get(msg.id) !== pendingAction ||
|
|
10551
|
+
!this.#leaseTokenAllows(socketLease) ||
|
|
10552
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
10553
|
+
) {
|
|
10554
|
+
for (const [alias] of createdAliases) {
|
|
10555
|
+
this.#revokeCallbackAlias(alias);
|
|
10556
|
+
this.#stagedCallbackActivations.delete(alias);
|
|
10557
|
+
}
|
|
10558
|
+
this.deleteMessageRoutes(logicalSessionId, msg.id);
|
|
10559
|
+
let revocationPersistenceError: unknown;
|
|
10560
|
+
try {
|
|
10561
|
+
await this.persistAliases();
|
|
10562
|
+
} catch (error) {
|
|
10563
|
+
revocationPersistenceError = error;
|
|
10564
|
+
} finally {
|
|
10565
|
+
activation.resolve(false);
|
|
10566
|
+
}
|
|
10567
|
+
try {
|
|
10568
|
+
await this.botApi.call("editMessageReplyMarkup", {
|
|
10569
|
+
chat_id: this.opts.chatId,
|
|
10570
|
+
message_id: messageId,
|
|
10571
|
+
reply_markup: { inline_keyboard: [] },
|
|
10572
|
+
});
|
|
10573
|
+
} catch {
|
|
10574
|
+
// The accepted receipt remains in memory for shutdown terminalization if persistence failed.
|
|
10575
|
+
}
|
|
10576
|
+
if (revocationPersistenceError !== undefined)
|
|
10577
|
+
throw new Error("callback alias revocation persistence failed", {
|
|
10578
|
+
cause: revocationPersistenceError,
|
|
10579
|
+
});
|
|
10580
|
+
return;
|
|
10581
|
+
}
|
|
10582
|
+
for (const [alias] of createdAliases) {
|
|
10583
|
+
this.#callbackAliasPending.set(alias, pendingAction);
|
|
10584
|
+
this.#callbackAliasLeases.set(alias, socketLease);
|
|
10585
|
+
if (topicLease) this.#callbackAliasTopics.set(alias, topicLease);
|
|
10586
|
+
this.#stagedCallbackActivations.delete(alias);
|
|
10587
|
+
}
|
|
10588
|
+
activation.resolve(true);
|
|
10589
|
+
} else if (kind === "ask" && messageId !== undefined) {
|
|
10590
|
+
// The send may have succeeded after authority ended. Persist every
|
|
10591
|
+
// receipt as revoked before best-effort remote terminalization.
|
|
10592
|
+
for (const [alias, route] of createdAliases) {
|
|
10593
|
+
const receipt = {
|
|
10594
|
+
...route,
|
|
10595
|
+
chatId: String(this.opts.chatId),
|
|
10596
|
+
messageId,
|
|
10597
|
+
ownerId: this.opts.ownerId,
|
|
10598
|
+
generation: DAEMON_GENERATION,
|
|
10599
|
+
};
|
|
10600
|
+
if (this.aliasTable.update(alias, receipt)) {
|
|
10601
|
+
this.#revokeCallbackAlias(alias);
|
|
10602
|
+
continue;
|
|
10603
|
+
}
|
|
10604
|
+
const revoked = this.reissueBacklog.get(alias);
|
|
10605
|
+
if (
|
|
10606
|
+
revoked &&
|
|
10607
|
+
(revoked.sessionId !== route.sessionId ||
|
|
10608
|
+
revoked.actionId !== route.actionId ||
|
|
10609
|
+
revoked.answer !== route.answer)
|
|
10610
|
+
)
|
|
10611
|
+
throw new Error("callback alias revocation update conflicted");
|
|
10612
|
+
this.reissueBacklog.set(alias, receipt);
|
|
10613
|
+
}
|
|
10614
|
+
await this.persistAliases();
|
|
10615
|
+
try {
|
|
10616
|
+
const terminalized = (await this.botApi.call("editMessageReplyMarkup", {
|
|
10617
|
+
chat_id: this.opts.chatId,
|
|
10618
|
+
message_id: messageId,
|
|
10619
|
+
reply_markup: { inline_keyboard: [] },
|
|
10620
|
+
})) as { ok?: unknown } | undefined;
|
|
10621
|
+
if (terminalized?.ok === true) for (const [alias] of createdAliases) this.reissueBacklog.delete(alias);
|
|
10622
|
+
} catch {
|
|
10623
|
+
// The accepted receipt remains durably terminalizable even if its keyboard cannot be removed.
|
|
10624
|
+
}
|
|
10625
|
+
} else if (kind === "ask" && callbackDelivery === "ambiguous") {
|
|
10626
|
+
for (const [alias] of createdAliases) this.#revokeCallbackAlias(alias);
|
|
10627
|
+
} else if (kind === "ask") {
|
|
10628
|
+
for (const [alias] of createdAliases) this.aliasTable.delete(alias);
|
|
9336
10629
|
}
|
|
9337
10630
|
await this.persistAliases();
|
|
9338
10631
|
} else if (msg.type === "action_resolved" && msg.id) {
|
|
@@ -9340,8 +10633,9 @@ export class TelegramNotificationDaemon {
|
|
|
9340
10633
|
this.deleteMessageRoutes(this.#logicalSessionId(session), msg.id);
|
|
9341
10634
|
for (const [alias, route] of this.aliasTable.entries()) {
|
|
9342
10635
|
if (route.sessionId === this.#logicalSessionId(session) && route.actionId === msg.id)
|
|
9343
|
-
this
|
|
10636
|
+
this.#revokeCallbackAlias(alias);
|
|
9344
10637
|
}
|
|
10638
|
+
await this.reissuePendingAction(this.#logicalSessionId(session), msg.id);
|
|
9345
10639
|
await this.persistAliases();
|
|
9346
10640
|
}
|
|
9347
10641
|
}
|
|
@@ -9556,14 +10850,18 @@ export class TelegramNotificationDaemon {
|
|
|
9556
10850
|
}),
|
|
9557
10851
|
}));
|
|
9558
10852
|
try {
|
|
9559
|
-
const response = await this.botApi.call(
|
|
9560
|
-
|
|
9561
|
-
|
|
9562
|
-
|
|
9563
|
-
|
|
9564
|
-
|
|
10853
|
+
const response = await this.botApi.call(
|
|
10854
|
+
"sendMessage",
|
|
10855
|
+
{
|
|
10856
|
+
chat_id: this.opts.chatId,
|
|
10857
|
+
message_thread_id: threadId,
|
|
10858
|
+
text: "Choose where to start the GJC session:",
|
|
10859
|
+
reply_markup: {
|
|
10860
|
+
inline_keyboard: aliases.map(choice => [{ text: choice.label, callback_data: choice.alias }]),
|
|
10861
|
+
},
|
|
9565
10862
|
},
|
|
9566
|
-
|
|
10863
|
+
{ noRetry: true },
|
|
10864
|
+
);
|
|
9567
10865
|
if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
|
|
9568
10866
|
for (const choice of aliases) this.#adoptionPickerAliases.delete(choice.alias);
|
|
9569
10867
|
return false;
|
|
@@ -9626,12 +10924,16 @@ export class TelegramNotificationDaemon {
|
|
|
9626
10924
|
index => aliases[index]!.alias,
|
|
9627
10925
|
);
|
|
9628
10926
|
try {
|
|
9629
|
-
const response = await this.botApi.call(
|
|
9630
|
-
|
|
9631
|
-
|
|
9632
|
-
|
|
9633
|
-
|
|
9634
|
-
|
|
10927
|
+
const response = await this.botApi.call(
|
|
10928
|
+
"sendMessage",
|
|
10929
|
+
{
|
|
10930
|
+
chat_id: this.opts.chatId,
|
|
10931
|
+
message_thread_id: threadId,
|
|
10932
|
+
text: "Pick a recent work folder to start a session here:",
|
|
10933
|
+
reply_markup: { inline_keyboard },
|
|
10934
|
+
},
|
|
10935
|
+
{ noRetry: true },
|
|
10936
|
+
);
|
|
9635
10937
|
if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
|
|
9636
10938
|
for (const a of aliases) this.#adoptionPickerAliases.delete(a.alias);
|
|
9637
10939
|
return false;
|
|
@@ -10080,6 +11382,7 @@ export class TelegramNotificationDaemon {
|
|
|
10080
11382
|
}
|
|
10081
11383
|
|
|
10082
11384
|
private async processTelegramUpdate(update: unknown): Promise<TelegramUpdateOutcome> {
|
|
11385
|
+
if (this.validationMode()) return "consumed";
|
|
10083
11386
|
const createdOutcome = await this.handleForumTopicCreatedUpdate(update);
|
|
10084
11387
|
if (createdOutcome !== "not-topic") return createdOutcome;
|
|
10085
11388
|
const topicOutcome = await this.handleForumTopicEdited(update);
|
|
@@ -10088,16 +11391,13 @@ export class TelegramNotificationDaemon {
|
|
|
10088
11391
|
await this.handleTelegramUpdate(update);
|
|
10089
11392
|
} catch (err) {
|
|
10090
11393
|
logger.error("notifications daemon: handleTelegramUpdate failed", { error: String(err) });
|
|
11394
|
+
return "retry";
|
|
10091
11395
|
}
|
|
10092
11396
|
return "consumed";
|
|
10093
11397
|
}
|
|
10094
11398
|
|
|
10095
11399
|
async handleTelegramUpdate(update: unknown): Promise<void> {
|
|
10096
|
-
|
|
10097
|
-
// authenticates the chat/user, shows the folder-source picker, and emits the
|
|
10098
|
-
// "already starting" dedup notice for a topic with a live adoption intent.
|
|
10099
|
-
// This mirrors the poll-loop ordering in processTelegramUpdate so direct
|
|
10100
|
-
// (test/orchestration) callers observe identical created-topic handling.
|
|
11400
|
+
if (this.validationMode()) return;
|
|
10101
11401
|
if ((await this.handleForumTopicCreatedUpdate(update)) !== "not-topic") return;
|
|
10102
11402
|
if ((await this.handleForumTopicEdited(update)) !== "not-topic") return;
|
|
10103
11403
|
// A raw path is accepted only after the explicit direct-entry choice. The exact
|
|
@@ -10259,7 +11559,17 @@ export class TelegramNotificationDaemon {
|
|
|
10259
11559
|
// update_id dedupe are all enforced by decideThreadedInbound.
|
|
10260
11560
|
const raw = update as {
|
|
10261
11561
|
callback_query?: unknown;
|
|
10262
|
-
message?: {
|
|
11562
|
+
message?: {
|
|
11563
|
+
text?: unknown;
|
|
11564
|
+
reply_to_message?: { message_id?: unknown };
|
|
11565
|
+
photo?: unknown;
|
|
11566
|
+
document?: unknown;
|
|
11567
|
+
video?: unknown;
|
|
11568
|
+
audio?: unknown;
|
|
11569
|
+
chat?: { id?: unknown };
|
|
11570
|
+
message_thread_id?: unknown;
|
|
11571
|
+
message_id?: unknown;
|
|
11572
|
+
};
|
|
10263
11573
|
};
|
|
10264
11574
|
// A reply to a known ask message routes to that ask (below). Any OTHER
|
|
10265
11575
|
// message in a topic (plain text, or a reply to a non-ask message) is a
|
|
@@ -10269,6 +11579,23 @@ export class TelegramNotificationDaemon {
|
|
|
10269
11579
|
typeof raw.message?.text === "string" ? parseBtwCommand(raw.message.text, this.botUsername) : undefined;
|
|
10270
11580
|
const isAskReply =
|
|
10271
11581
|
replyTo !== undefined && (this.messageRoutes.has(String(replyTo)) || this.messageRoutes.has(Number(replyTo)));
|
|
11582
|
+
if (
|
|
11583
|
+
reservedBtw?.kind === "question" &&
|
|
11584
|
+
(raw.message?.photo || raw.message?.document || raw.message?.video || raw.message?.audio) &&
|
|
11585
|
+
typeof raw.message?.message_thread_id === "number" &&
|
|
11586
|
+
typeof raw.message.message_id === "number" &&
|
|
11587
|
+
String(raw.message.chat?.id) === this.opts.chatId &&
|
|
11588
|
+
(await this.pairedChatIsPrivate())
|
|
11589
|
+
) {
|
|
11590
|
+
if (!(await this.reserveSeenUpdateId((update as { update_id?: number }).update_id!))) return;
|
|
11591
|
+
await this.#sendBtwMessage({
|
|
11592
|
+
threadId: String(raw.message.message_thread_id),
|
|
11593
|
+
messageId: raw.message.message_id,
|
|
11594
|
+
text: BTW_USAGE_TEXT,
|
|
11595
|
+
isAuthoritative: () => true,
|
|
11596
|
+
});
|
|
11597
|
+
return;
|
|
11598
|
+
}
|
|
10272
11599
|
const directControl =
|
|
10273
11600
|
typeof raw.message?.text === "string"
|
|
10274
11601
|
? parseTelegramControlCommand(raw.message.text, this.botUsername)
|
|
@@ -10302,11 +11629,17 @@ export class TelegramNotificationDaemon {
|
|
|
10302
11629
|
return undefined;
|
|
10303
11630
|
return transportOwner.logicalSessionId;
|
|
10304
11631
|
}
|
|
11632
|
+
if (
|
|
11633
|
+
reservedBtw?.kind === "question" &&
|
|
11634
|
+
(raw.message?.photo || raw.message?.document || raw.message?.video || raw.message?.audio)
|
|
11635
|
+
)
|
|
11636
|
+
return topicSessionId;
|
|
10305
11637
|
return [...this.sessions.values()].some(
|
|
10306
11638
|
session =>
|
|
10307
|
-
session.sessionId === topicSessionId ||
|
|
10308
|
-
|
|
10309
|
-
|
|
11639
|
+
(session.sessionId === topicSessionId ||
|
|
11640
|
+
this.#logicalSessionId(session) === topicSessionId ||
|
|
11641
|
+
session.recoveryLease?.logicalSessionId === topicSessionId) &&
|
|
11642
|
+
session.ws.readyState !== WebSocket.CLOSED,
|
|
10310
11643
|
)
|
|
10311
11644
|
? undefined
|
|
10312
11645
|
: topicSessionId;
|
|
@@ -10319,6 +11652,7 @@ export class TelegramNotificationDaemon {
|
|
|
10319
11652
|
const preliminaryControl = inbound.attachment
|
|
10320
11653
|
? { kind: "none" as const }
|
|
10321
11654
|
: parseTelegramControlCommand(inbound.text, this.botUsername);
|
|
11655
|
+
const reservedBtw = parseBtwCommand(inbound.text, this.botUsername);
|
|
10322
11656
|
if (preliminaryControl.kind === "ignored") return;
|
|
10323
11657
|
const session =
|
|
10324
11658
|
this.logicalSessionOwners.get(inbound.sessionId) ??
|
|
@@ -10348,6 +11682,20 @@ export class TelegramNotificationDaemon {
|
|
|
10348
11682
|
topicLeaseAllows();
|
|
10349
11683
|
const routeLeaseAllows = (): boolean =>
|
|
10350
11684
|
topicLeaseAllows() && (!session || (!!routeLease && this.#leaseTokenAllows(routeLease)));
|
|
11685
|
+
if (
|
|
11686
|
+
/^\/btw(?:\s|$)/i.test(inbound.text) &&
|
|
11687
|
+
inbound.attachment &&
|
|
11688
|
+
(!session || session.ws.readyState !== WebSocket.OPEN || !routeLease)
|
|
11689
|
+
) {
|
|
11690
|
+
if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
|
|
11691
|
+
await this.#sendBtwMessage({
|
|
11692
|
+
threadId: inbound.threadId,
|
|
11693
|
+
messageId: inbound.messageId,
|
|
11694
|
+
text: BTW_USAGE_TEXT,
|
|
11695
|
+
isAuthoritative: () => true,
|
|
11696
|
+
});
|
|
11697
|
+
return;
|
|
11698
|
+
}
|
|
10351
11699
|
const reserveRouteUpdate = async (): Promise<boolean> => {
|
|
10352
11700
|
if (!(await this.reserveSeenUpdateId(inbound.updateId))) return false;
|
|
10353
11701
|
if (routeLeaseAllows()) return true;
|
|
@@ -10355,9 +11703,18 @@ export class TelegramNotificationDaemon {
|
|
|
10355
11703
|
return false;
|
|
10356
11704
|
};
|
|
10357
11705
|
|
|
10358
|
-
if (session && !routeLease) return;
|
|
10359
|
-
if (
|
|
10360
|
-
|
|
11706
|
+
if (session && !routeLease && !(reservedBtw?.kind === "question" && inbound.attachment)) return;
|
|
11707
|
+
if (
|
|
11708
|
+
preliminaryControl.kind === "invalid" &&
|
|
11709
|
+
session?.ws.readyState !== WebSocket.OPEN &&
|
|
11710
|
+
!(reservedBtw?.kind === "question" && inbound.attachment)
|
|
11711
|
+
)
|
|
11712
|
+
return;
|
|
11713
|
+
if (
|
|
11714
|
+
preliminaryControl.kind === "command" &&
|
|
11715
|
+
session?.ws.readyState !== WebSocket.OPEN &&
|
|
11716
|
+
!(reservedBtw?.kind === "question" && inbound.attachment)
|
|
11717
|
+
) {
|
|
10361
11718
|
if (await reserveRouteUpdate()) {
|
|
10362
11719
|
try {
|
|
10363
11720
|
await this.botApi.call("sendMessage", {
|
|
@@ -10374,18 +11731,22 @@ export class TelegramNotificationDaemon {
|
|
|
10374
11731
|
}
|
|
10375
11732
|
return;
|
|
10376
11733
|
}
|
|
10377
|
-
const reservedBtw = parseBtwCommand(inbound.text, this.botUsername);
|
|
10378
11734
|
if (reservedBtw?.kind === "ignored") {
|
|
10379
11735
|
await this.rememberSeenUpdateId(inbound.updateId);
|
|
10380
11736
|
return;
|
|
10381
11737
|
}
|
|
10382
11738
|
if (reservedBtw?.kind === "question" && (!reservedBtw.question || inbound.attachment)) {
|
|
10383
|
-
|
|
11739
|
+
const reserved =
|
|
11740
|
+
session?.ws.readyState === WebSocket.OPEN
|
|
11741
|
+
? await reserveRouteUpdate()
|
|
11742
|
+
: await this.reserveSeenUpdateId(inbound.updateId);
|
|
11743
|
+
if (!reserved) return;
|
|
11744
|
+
if (session?.ws.readyState !== WebSocket.OPEN) await this.flushPool();
|
|
10384
11745
|
await this.#sendBtwMessage({
|
|
10385
11746
|
threadId: inbound.threadId,
|
|
10386
11747
|
messageId: inbound.messageId,
|
|
10387
11748
|
text: BTW_USAGE_TEXT,
|
|
10388
|
-
isAuthoritative: routeLeaseAllows,
|
|
11749
|
+
isAuthoritative: session?.ws.readyState === WebSocket.OPEN ? routeLeaseAllows : () => true,
|
|
10389
11750
|
});
|
|
10390
11751
|
return;
|
|
10391
11752
|
}
|
|
@@ -10627,19 +11988,102 @@ export class TelegramNotificationDaemon {
|
|
|
10627
11988
|
const callbackId = (update as { callback_query?: { id?: unknown } }).callback_query?.id;
|
|
10628
11989
|
if (await this.#handleAdoptionPickerCallback(update, callbackId)) return;
|
|
10629
11990
|
if (await this.#handleModelChoiceCallback(update, callbackId)) return;
|
|
11991
|
+
const callbackAlias = (update as { callback_query?: { data?: unknown } }).callback_query?.data;
|
|
11992
|
+
const stagedActivation =
|
|
11993
|
+
typeof callbackAlias === "string" ? this.#stagedCallbackActivations.get(callbackAlias) : undefined;
|
|
11994
|
+
if (stagedActivation && !(await stagedActivation.settled)) {
|
|
11995
|
+
await this.sendStaleGuidance(callbackId);
|
|
11996
|
+
return;
|
|
11997
|
+
}
|
|
10630
11998
|
const decision = routeInboundUpdate(update, {
|
|
10631
11999
|
aliasTable: this.aliasTable,
|
|
10632
12000
|
messageRoutes: this.messageRoutes,
|
|
10633
12001
|
pairedChatId: this.opts.chatId,
|
|
10634
12002
|
});
|
|
10635
12003
|
if (decision.kind === "reply") {
|
|
12004
|
+
const callbackLease =
|
|
12005
|
+
typeof callbackAlias === "string" ? this.#callbackAliasLeases.get(callbackAlias) : undefined;
|
|
12006
|
+
const callbackPending =
|
|
12007
|
+
typeof callbackAlias === "string" ? this.#callbackAliasPending.get(callbackAlias) : undefined;
|
|
12008
|
+
const callbackTopic =
|
|
12009
|
+
typeof callbackAlias === "string" ? this.#callbackAliasTopics.get(callbackAlias) : undefined;
|
|
10636
12010
|
const session = this.logicalSessionOwners.get(decision.sessionId) ?? this.sessions.get(decision.sessionId);
|
|
12011
|
+
const pendingAction = session?.pending.get(decision.actionId);
|
|
10637
12012
|
if (
|
|
12013
|
+
(typeof callbackAlias === "string" &&
|
|
12014
|
+
(!callbackLease ||
|
|
12015
|
+
!callbackPending ||
|
|
12016
|
+
callbackPending !== pendingAction ||
|
|
12017
|
+
!this.#leaseTokenAllows(callbackLease) ||
|
|
12018
|
+
(callbackTopic
|
|
12019
|
+
? !this.topicLeaseIsCurrent(callbackTopic)
|
|
12020
|
+
: this.topics.get(decision.sessionId) !== undefined))) ||
|
|
10638
12021
|
session?.ws.readyState !== WebSocket.OPEN ||
|
|
10639
|
-
!
|
|
10640
|
-
!this.#leaseAllows(session, decision.sessionId)
|
|
10641
|
-
|
|
10642
|
-
|
|
12022
|
+
!pendingAction ||
|
|
12023
|
+
!this.#leaseAllows(session, decision.sessionId)
|
|
12024
|
+
) {
|
|
12025
|
+
if (
|
|
12026
|
+
typeof callbackAlias === "string" &&
|
|
12027
|
+
callbackPending !== undefined &&
|
|
12028
|
+
callbackPending !== pendingAction &&
|
|
12029
|
+
this.#revokeCallbackAlias(callbackAlias)
|
|
12030
|
+
)
|
|
12031
|
+
await this.persistAliases();
|
|
12032
|
+
await this.sendStaleGuidance(callbackId);
|
|
12033
|
+
return;
|
|
12034
|
+
}
|
|
12035
|
+
const consumedAliases: Array<
|
|
12036
|
+
[
|
|
12037
|
+
string,
|
|
12038
|
+
CallbackRoute,
|
|
12039
|
+
{ session: SessionSocket; token: number; logicalSessionId: string } | undefined,
|
|
12040
|
+
{ sessionId: string; actionId: string } | undefined,
|
|
12041
|
+
TopicAuthorityLease | undefined,
|
|
12042
|
+
]
|
|
12043
|
+
> = [];
|
|
12044
|
+
// Consume only controls from this exact rendered ask. Predecessor receipts
|
|
12045
|
+
// remain in the terminalization backlog until their authoritative outcome.
|
|
12046
|
+
for (const [alias, route] of this.aliasTable.entries()) {
|
|
12047
|
+
if (
|
|
12048
|
+
route.sessionId === decision.sessionId &&
|
|
12049
|
+
route.actionId === decision.actionId &&
|
|
12050
|
+
this.#callbackAliasPending.get(alias) === pendingAction
|
|
12051
|
+
) {
|
|
12052
|
+
consumedAliases.push([
|
|
12053
|
+
alias,
|
|
12054
|
+
route,
|
|
12055
|
+
this.#callbackAliasLeases.get(alias),
|
|
12056
|
+
this.#callbackAliasPending.get(alias),
|
|
12057
|
+
this.#callbackAliasTopics.get(alias),
|
|
12058
|
+
]);
|
|
12059
|
+
this.aliasTable.delete(alias);
|
|
12060
|
+
this.#callbackAliasLeases.delete(alias);
|
|
12061
|
+
this.#callbackAliasPending.delete(alias);
|
|
12062
|
+
this.#callbackAliasTopics.delete(alias);
|
|
12063
|
+
}
|
|
12064
|
+
}
|
|
12065
|
+
try {
|
|
12066
|
+
await this.persistAliases();
|
|
12067
|
+
} catch {
|
|
12068
|
+
for (const [alias, route, lease, pending, topic] of consumedAliases) {
|
|
12069
|
+
this.aliasTable.activate(alias, route);
|
|
12070
|
+
if (lease) this.#callbackAliasLeases.set(alias, lease);
|
|
12071
|
+
if (pending) this.#callbackAliasPending.set(alias, pending);
|
|
12072
|
+
if (topic) this.#callbackAliasTopics.set(alias, topic);
|
|
12073
|
+
}
|
|
12074
|
+
throw new Error("callback receipt consumption was not durably persisted");
|
|
12075
|
+
}
|
|
12076
|
+
if (
|
|
12077
|
+
session.ws.readyState !== WebSocket.OPEN ||
|
|
12078
|
+
session.pending.get(decision.actionId) !== pendingAction ||
|
|
12079
|
+
(typeof callbackAlias === "string" &&
|
|
12080
|
+
(!callbackPending ||
|
|
12081
|
+
callbackPending !== pendingAction ||
|
|
12082
|
+
!callbackLease ||
|
|
12083
|
+
!this.#leaseTokenAllows(callbackLease) ||
|
|
12084
|
+
(callbackTopic
|
|
12085
|
+
? !this.topicLeaseIsCurrent(callbackTopic)
|
|
12086
|
+
: this.topics.get(decision.sessionId) !== undefined)))
|
|
10643
12087
|
) {
|
|
10644
12088
|
await this.sendStaleGuidance(callbackId);
|
|
10645
12089
|
return;
|
|
@@ -10694,6 +12138,12 @@ export class TelegramNotificationDaemon {
|
|
|
10694
12138
|
// Runtime callers can bypass TypeScript's option type. Without a valid bot
|
|
10695
12139
|
// token, there is no authenticated daemon identity or lifecycle authority.
|
|
10696
12140
|
if (!validBotToken(this.opts.botToken)) return;
|
|
12141
|
+
if (this.validationMode()) {
|
|
12142
|
+
if (!/^-100\d+$/.test(this.opts.validationTestSupergroupChatId!))
|
|
12143
|
+
throw new Error("validation forum destination must be a negative -100... chat ID");
|
|
12144
|
+
if ((await this.resolvePairedChatPrivacy()) !== "validation-forum")
|
|
12145
|
+
throw new Error("validation forum destination must be the exact supergroup forum returned by getChat");
|
|
12146
|
+
}
|
|
10697
12147
|
let ownershipProved = false;
|
|
10698
12148
|
try {
|
|
10699
12149
|
const renewed = await renewDaemonHeartbeat({
|
|
@@ -10713,19 +12163,22 @@ export class TelegramNotificationDaemon {
|
|
|
10713
12163
|
if (!this.running) return;
|
|
10714
12164
|
// Self-heal durable notification state before any scan/poll work so
|
|
10715
12165
|
// `daemon reload` recovers dead roots + leak artifacts (#2956).
|
|
10716
|
-
|
|
10717
|
-
|
|
10718
|
-
|
|
10719
|
-
|
|
10720
|
-
|
|
10721
|
-
|
|
10722
|
-
|
|
10723
|
-
|
|
12166
|
+
if (!this.validationMode()) {
|
|
12167
|
+
try {
|
|
12168
|
+
await healTelegramDaemonNotificationState({
|
|
12169
|
+
settings: this.opts.settings,
|
|
12170
|
+
fs: this.fsImpl,
|
|
12171
|
+
now: this.opts.now,
|
|
12172
|
+
});
|
|
12173
|
+
} catch (error) {
|
|
12174
|
+
logger.warn(`notifications: startup self-heal failed: ${sanitizeDiagnostic(String(error))}`);
|
|
12175
|
+
}
|
|
10724
12176
|
}
|
|
12177
|
+
await this.loadAliases();
|
|
10725
12178
|
// Owner-only: start lifecycle control immediately after ownership proof,
|
|
10726
12179
|
// before timers or pre-poll startup work can invalidate this run.
|
|
10727
12180
|
// Best-effort; notification delivery remains available on failure.
|
|
10728
|
-
await this.startLifecycleControl();
|
|
12181
|
+
if (!this.validationMode()) await this.startLifecycleControl();
|
|
10729
12182
|
// A stop may arrive while lifecycle startup awaits its control endpoint.
|
|
10730
12183
|
// Do not re-enable runtime work after that stop; close the partial server.
|
|
10731
12184
|
if (!this.running) return;
|
|
@@ -10734,14 +12187,19 @@ export class TelegramNotificationDaemon {
|
|
|
10734
12187
|
this.startFlushTimer();
|
|
10735
12188
|
this.startScanTimer();
|
|
10736
12189
|
this.startTypingTimer();
|
|
10737
|
-
|
|
10738
|
-
|
|
10739
|
-
|
|
12190
|
+
if (!this.validationMode()) {
|
|
12191
|
+
await this.refreshBotIdentity();
|
|
12192
|
+
await this.registerBotCommands();
|
|
12193
|
+
}
|
|
10740
12194
|
await this.loadTopics();
|
|
10741
|
-
|
|
10742
|
-
|
|
10743
|
-
|
|
10744
|
-
|
|
12195
|
+
if (!this.validationMode()) {
|
|
12196
|
+
await this.loadAdoptionIntents();
|
|
12197
|
+
this.startAdoptionSweepTimer();
|
|
12198
|
+
}
|
|
12199
|
+
if (!this.validationMode()) {
|
|
12200
|
+
await this.loadSeenUpdateIds();
|
|
12201
|
+
await this.replyStore.load();
|
|
12202
|
+
}
|
|
10745
12203
|
await this.runScan();
|
|
10746
12204
|
let idleSince = this.runtime.now();
|
|
10747
12205
|
while (this.running) {
|
|
@@ -10807,37 +12265,43 @@ export class TelegramNotificationDaemon {
|
|
|
10807
12265
|
}
|
|
10808
12266
|
this.effects.beginShutdown();
|
|
10809
12267
|
this.#deliveryAbort.abort();
|
|
12268
|
+
this.stopFlushTimer();
|
|
12269
|
+
this.stopScanTimer();
|
|
12270
|
+
this.stopTypingTimer();
|
|
12271
|
+
this.stopAdoptionSweepTimer();
|
|
12272
|
+
this.stopLifecycleControl();
|
|
10810
12273
|
let persisted = false;
|
|
10811
|
-
const
|
|
10812
|
-
|
|
10813
|
-
|
|
10814
|
-
|
|
10815
|
-
|
|
10816
|
-
|
|
10817
|
-
|
|
10818
|
-
|
|
10819
|
-
|
|
10820
|
-
|
|
10821
|
-
|
|
10822
|
-
|
|
10823
|
-
|
|
10824
|
-
|
|
10825
|
-
|
|
10826
|
-
|
|
10827
|
-
|
|
10828
|
-
|
|
10829
|
-
|
|
10830
|
-
|
|
10831
|
-
|
|
10832
|
-
|
|
10833
|
-
|
|
10834
|
-
|
|
10835
|
-
|
|
10836
|
-
|
|
10837
|
-
|
|
10838
|
-
|
|
10839
|
-
|
|
10840
|
-
|
|
12274
|
+
const sessionEffectsQuiesced = await this.effects.join(BTW_SHUTDOWN_JOIN_MS);
|
|
12275
|
+
let completed = false;
|
|
12276
|
+
if (sessionEffectsQuiesced) {
|
|
12277
|
+
const shutdown = this.effects.allowTerminal(async () => {
|
|
12278
|
+
if (toolShutdownError) throw toolShutdownError;
|
|
12279
|
+
await this.#drainBtwTurns();
|
|
12280
|
+
await this.toolTerminalizationChain;
|
|
12281
|
+
await this.cleanupAllAttachmentDirs();
|
|
12282
|
+
if (!this.validationMode()) {
|
|
12283
|
+
await this.persistAliases();
|
|
12284
|
+
await this.persistSeenUpdateIds();
|
|
12285
|
+
await this.opts.control?.clear?.(this.opts.ownerId);
|
|
12286
|
+
}
|
|
12287
|
+
await this.persistTopics();
|
|
12288
|
+
persisted = true;
|
|
12289
|
+
});
|
|
12290
|
+
const deadline = Promise.withResolvers<boolean>();
|
|
12291
|
+
const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
|
|
12292
|
+
completed = await Promise.race([
|
|
12293
|
+
shutdown.then(
|
|
12294
|
+
() => true,
|
|
12295
|
+
error => {
|
|
12296
|
+
logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
|
|
12297
|
+
return false;
|
|
12298
|
+
},
|
|
12299
|
+
),
|
|
12300
|
+
deadline.promise,
|
|
12301
|
+
]);
|
|
12302
|
+
clearTimeout(deadlineTimer);
|
|
12303
|
+
}
|
|
12304
|
+
const quiesced = completed && heartbeatJoined && sessionEffectsQuiesced;
|
|
10841
12305
|
if (!quiesced || !persisted) {
|
|
10842
12306
|
logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
|
|
10843
12307
|
} else {
|