@gajae-code/coding-agent 0.12.11 → 0.12.13
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 +68 -0
- package/dist/types/cli/marketplace-hint.d.ts +28 -0
- package/dist/types/cli/notify-cli.d.ts +42 -1
- package/dist/types/cli/update-cli.d.ts +9 -3
- package/dist/types/cli.d.ts +10 -0
- package/dist/types/commands/notify.d.ts +6 -0
- package/dist/types/config/file-lock-gc.d.ts +13 -1
- package/dist/types/config/file-lock.d.ts +3 -0
- package/dist/types/config/model-registry.d.ts +6 -0
- package/dist/types/config/models-config-schema.d.ts +99 -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/gjc-runtime/deep-interview-state.d.ts +14 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +11 -0
- package/dist/types/main.d.ts +6 -2
- package/dist/types/modes/acp/acp-agent.d.ts +24 -1
- 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/html-format.d.ts +10 -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 +28 -7
- package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -17
- package/dist/types/sdk/bus/telegram-reference.d.ts +20 -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 -2
- package/dist/types/session/artifacts.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +40 -6
- package/dist/types/tools/index.d.ts +15 -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/fast-help.ts +1 -0
- package/src/cli/marketplace-hint.ts +39 -0
- package/src/cli/notify-cli.ts +226 -2
- package/src/cli/plugin-cli.ts +10 -0
- package/src/cli/update-cli.ts +90 -23
- package/src/cli.ts +23 -0
- package/src/commands/notify.ts +27 -3
- package/src/commands/sdk.ts +1 -0
- package/src/config/file-lock-gc.ts +65 -26
- package/src/config/file-lock.ts +61 -16
- package/src/config/model-profiles.ts +46 -30
- package/src/config/model-registry.ts +17 -5
- package/src/config/models-config-schema.ts +9 -4
- 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/deep-interview/SKILL.md +1 -1
- 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/deep-interview-recorder.ts +9 -5
- package/src/gjc-runtime/deep-interview-state.ts +27 -3
- package/src/gjc-runtime/gc-render.ts +8 -1
- package/src/gjc-runtime/gc-runtime.ts +15 -1
- package/src/gjc-runtime/launch-tmux.ts +53 -1
- package/src/internal-urls/docs-index.generated.ts +14 -13
- package/src/main.ts +214 -70
- package/src/modes/DESIGN.md +8 -5
- package/src/modes/acp/acp-agent.ts +123 -7
- package/src/modes/components/footer.ts +10 -18
- package/src/modes/components/model-selector.ts +52 -5
- 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/print-mode.ts +10 -0
- package/src/modes/utils/host-status.ts +38 -0
- package/src/prompts/system/project-prompt.md +19 -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/html-format.ts +19 -3
- package/src/sdk/bus/index.ts +91 -12
- 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 +28 -7
- package/src/sdk/bus/telegram-daemon.ts +2007 -509
- package/src/sdk/bus/telegram-reference.ts +114 -15
- 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 +144 -29
- package/src/sdk/startup-capability.ts +10 -1
- package/src/session/agent-session.ts +220 -13
- package/src/session/artifacts.ts +56 -13
- package/src/session/internal/managed-session-storage.ts +228 -7
- package/src/session/session-manager.ts +225 -60
- package/src/setup/provider-presets.json +3 -3
- package/src/task/index.ts +92 -44
- package/src/thinking-metadata.ts +2 -1
- package/src/tools/ask.ts +10 -0
- package/src/tools/index.ts +15 -0
- package/src/tools/todo-write.ts +4 -4
- 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";
|
|
@@ -47,6 +48,8 @@ import {
|
|
|
47
48
|
buildCompactChoiceGrid,
|
|
48
49
|
code,
|
|
49
50
|
markdownToTelegramHtml,
|
|
51
|
+
SELECTION_MARK_CHECKED,
|
|
52
|
+
SELECTION_MARK_UNCHECKED,
|
|
50
53
|
splitTelegramHtml,
|
|
51
54
|
TELEGRAM_MESSAGE_LIMIT,
|
|
52
55
|
TELEGRAM_PARSE_MODE,
|
|
@@ -107,7 +110,13 @@ import {
|
|
|
107
110
|
} from "./telegram-reference";
|
|
108
111
|
import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound";
|
|
109
112
|
import { renderThreadedFrame, supportsTelegramPhotoUpload, type ThreadedSend } from "./threaded-render";
|
|
110
|
-
import {
|
|
113
|
+
import {
|
|
114
|
+
parseTopicRegistryState,
|
|
115
|
+
type TopicEndpointBinding,
|
|
116
|
+
TopicRegistry,
|
|
117
|
+
type TopicRegistryCasAuthority,
|
|
118
|
+
type TopicRegistryState,
|
|
119
|
+
} from "./topic-registry";
|
|
111
120
|
|
|
112
121
|
export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked";
|
|
113
122
|
/** Detailed result for orchestration that must distinguish a #2028 handoff from a fresh spawn. */
|
|
@@ -146,15 +155,27 @@ export interface DaemonState {
|
|
|
146
155
|
servingEpoch?: number;
|
|
147
156
|
stoppedAt?: number;
|
|
148
157
|
}
|
|
158
|
+
interface ExactFileStat {
|
|
159
|
+
dev: bigint;
|
|
160
|
+
ino: bigint;
|
|
161
|
+
nlink: bigint;
|
|
162
|
+
size: bigint;
|
|
163
|
+
mtimeNs: bigint;
|
|
164
|
+
isFile(): boolean;
|
|
165
|
+
}
|
|
166
|
+
|
|
149
167
|
export interface TelegramDaemonFs {
|
|
150
168
|
mkdir(path: string, opts?: fs.MakeDirectoryOptions): Promise<void>;
|
|
151
169
|
readFile(path: string, encoding: BufferEncoding): Promise<string>;
|
|
152
170
|
writeFile(path: string, data: string, opts?: fs.WriteFileOptions): Promise<void>;
|
|
153
171
|
rename(oldPath: string, newPath: string): Promise<void>;
|
|
154
172
|
unlink(path: string): Promise<void>;
|
|
155
|
-
open(path: string, flags: string, mode?: number): Promise<{ close(): Promise<void> }>;
|
|
173
|
+
open(path: string, flags: string, mode?: number): Promise<{ sync?: () => Promise<void>; close(): Promise<void> }>;
|
|
156
174
|
readdir(path: string): Promise<string[]>;
|
|
157
175
|
chmod(path: string, mode: number): Promise<void>;
|
|
176
|
+
/** Crash-atomic persistence seams. Implementations without them fail closed. */
|
|
177
|
+
fsyncFile?(path: string): Promise<void>;
|
|
178
|
+
fsyncDirectory?(path: string): Promise<void>;
|
|
158
179
|
stat?(path: string): Promise<{
|
|
159
180
|
mtimeMs: number;
|
|
160
181
|
size?: number;
|
|
@@ -163,8 +184,9 @@ export interface TelegramDaemonFs {
|
|
|
163
184
|
/** Hard-link count; required to prove a staging temp has no second name. */
|
|
164
185
|
nlink?: number;
|
|
165
186
|
ctimeMs?: number;
|
|
166
|
-
isDirectory
|
|
187
|
+
isDirectory?(): boolean;
|
|
167
188
|
}>;
|
|
189
|
+
lstat?(path: string, opts: { bigint: true }): Promise<ExactFileStat>;
|
|
168
190
|
readEndpointFile?(path: string): Promise<NotificationEndpointFile>;
|
|
169
191
|
exactUnlink?(
|
|
170
192
|
path: string,
|
|
@@ -233,6 +255,22 @@ function negotiateToolActivityCapability(
|
|
|
233
255
|
|
|
234
256
|
const nodeFs: TelegramDaemonFs = {
|
|
235
257
|
...(fs.promises as unknown as TelegramDaemonFs),
|
|
258
|
+
fsyncFile: async file => {
|
|
259
|
+
const handle = await fs.promises.open(file, "r+");
|
|
260
|
+
try {
|
|
261
|
+
await handle.sync();
|
|
262
|
+
} finally {
|
|
263
|
+
await handle.close();
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
fsyncDirectory: async directory => {
|
|
267
|
+
const handle = await fs.promises.open(directory, "r");
|
|
268
|
+
try {
|
|
269
|
+
await handle.sync();
|
|
270
|
+
} finally {
|
|
271
|
+
await handle.close();
|
|
272
|
+
}
|
|
273
|
+
},
|
|
236
274
|
readEndpointFile: readNotificationEndpointFile,
|
|
237
275
|
exactUnlink: async (file, identity, quarantineName) =>
|
|
238
276
|
exactUnlinkNotificationFile(
|
|
@@ -347,6 +385,10 @@ const BTW_QUESTION_LIMIT_TEXT = "Question must be at most 4096 Unicode scalar va
|
|
|
347
385
|
type ParsedBtwCommand = { kind: "question"; question: string } | { kind: "ignored" };
|
|
348
386
|
type TelegramFileDownload = { bytes: Buffer } | { failure: "download_failed" | "too_large" };
|
|
349
387
|
class ThreadedModeCapabilityRefusal extends Error {}
|
|
388
|
+
/** Telegram answered `createForumTopic` with an explicit `ok: false` rejection. */
|
|
389
|
+
class TopicCreationRejected extends Error {}
|
|
390
|
+
/** The transport suppressed `createForumTopic` (no response body to classify). */
|
|
391
|
+
class TopicCreationSuppressed extends Error {}
|
|
350
392
|
|
|
351
393
|
async function prepareTelegramImageAttachment(frame: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
352
394
|
if (frame.type !== "image_attachment") return frame;
|
|
@@ -386,7 +428,7 @@ function isThreadedModeCapabilityRefusal(response: unknown): boolean {
|
|
|
386
428
|
return (
|
|
387
429
|
ok === false &&
|
|
388
430
|
typeof description === "string" &&
|
|
389
|
-
/(?:threaded mode|forum topics? (?:is|are) (?:disabled|not enabled)|(?:not allowed|not permitted|cannot|can't) create forum topics
|
|
431
|
+
/(?:threaded mode|forum topics? (?:is|are) (?:disabled|not enabled)|(?:not allowed|not permitted|cannot|can't) create forum topics?|chat is not a forum)/i.test(
|
|
390
432
|
description,
|
|
391
433
|
)
|
|
392
434
|
);
|
|
@@ -479,12 +521,15 @@ function endpointGenerationKey(url: string, token: string): string {
|
|
|
479
521
|
function topicRenameApplied(response: unknown): boolean {
|
|
480
522
|
return !!response && typeof response === "object" && (response as { ok?: unknown }).ok === true;
|
|
481
523
|
}
|
|
482
|
-
function
|
|
524
|
+
function topicArchiveSettled(response: unknown): boolean {
|
|
483
525
|
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
|
-
|
|
526
|
+
const result = response as { ok?: unknown; result?: unknown; error_code?: unknown; description?: unknown };
|
|
527
|
+
if (result.ok === true && result.result === true) return true;
|
|
528
|
+
if (result.ok !== false || result.error_code !== 400 || typeof result.description !== "string") return false;
|
|
529
|
+
const description = result.description.trim();
|
|
530
|
+
return /^(?:Bad Request: )?(?:TOPIC_NOT_FOUND|THREAD_NOT_FOUND|topic (?:already|is already) closed|message thread (?:not found|is not modified))$/i.test(
|
|
531
|
+
description,
|
|
532
|
+
);
|
|
488
533
|
}
|
|
489
534
|
|
|
490
535
|
/**
|
|
@@ -589,11 +634,428 @@ async function readJson<T>(fsImpl: TelegramDaemonFs, file: string): Promise<T |
|
|
|
589
634
|
}
|
|
590
635
|
}
|
|
591
636
|
|
|
592
|
-
async function writeJsonAtomic(
|
|
637
|
+
async function writeJsonAtomic(
|
|
638
|
+
fsImpl: TelegramDaemonFs,
|
|
639
|
+
file: string,
|
|
640
|
+
data: unknown,
|
|
641
|
+
options: { durable?: boolean } = {},
|
|
642
|
+
): Promise<void> {
|
|
593
643
|
const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
644
|
+
let renamed = false;
|
|
645
|
+
try {
|
|
646
|
+
await fsImpl.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
647
|
+
await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
|
|
648
|
+
if (options.durable) await syncTelegramFile(fsImpl, tmp);
|
|
649
|
+
await fsImpl.rename(tmp, file);
|
|
650
|
+
renamed = true;
|
|
651
|
+
if (options.durable) await syncTelegramDirectory(fsImpl, path.dirname(file));
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if (renamed) throw error;
|
|
654
|
+
let cleanupError: unknown;
|
|
655
|
+
try {
|
|
656
|
+
await fsImpl.unlink(tmp);
|
|
657
|
+
} catch (candidate) {
|
|
658
|
+
if ((candidate as NodeJS.ErrnoException).code !== "ENOENT") cleanupError = candidate;
|
|
659
|
+
}
|
|
660
|
+
if (cleanupError) throw new AggregateError([error, cleanupError], "Atomic JSON write and cleanup failed");
|
|
661
|
+
throw error;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function syncTelegramFile(fsImpl: TelegramDaemonFs, file: string): Promise<void> {
|
|
666
|
+
const handle = await fsImpl.open(file, "r+");
|
|
667
|
+
let syncError: unknown;
|
|
668
|
+
try {
|
|
669
|
+
if (typeof handle.sync !== "function")
|
|
670
|
+
throw new Error("Telegram alias durability requires filesystem sync support");
|
|
671
|
+
await handle.sync();
|
|
672
|
+
} catch (error) {
|
|
673
|
+
syncError = error;
|
|
674
|
+
}
|
|
675
|
+
let closeError: unknown;
|
|
676
|
+
try {
|
|
677
|
+
await handle.close();
|
|
678
|
+
} catch (error) {
|
|
679
|
+
closeError = error;
|
|
680
|
+
}
|
|
681
|
+
if (syncError && closeError)
|
|
682
|
+
throw new AggregateError([syncError, closeError], "Telegram alias file sync and close failed");
|
|
683
|
+
if (syncError) throw syncError;
|
|
684
|
+
if (closeError) throw closeError;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function syncTelegramDirectory(fsImpl: TelegramDaemonFs, directory: string): Promise<void> {
|
|
688
|
+
let handle: Awaited<ReturnType<TelegramDaemonFs["open"]>>;
|
|
689
|
+
try {
|
|
690
|
+
handle = await fsImpl.open(directory, "r");
|
|
691
|
+
} catch (error) {
|
|
692
|
+
if (process.platform === "win32" && isUnsupportedTelegramDirectoryBarrier(error)) return;
|
|
693
|
+
throw error;
|
|
694
|
+
}
|
|
695
|
+
let syncError: unknown;
|
|
696
|
+
try {
|
|
697
|
+
if (typeof handle.sync !== "function")
|
|
698
|
+
throw new Error("Telegram alias durability requires filesystem sync support");
|
|
699
|
+
await handle.sync();
|
|
700
|
+
} catch (error) {
|
|
701
|
+
if (!(process.platform === "win32" && isUnsupportedTelegramDirectoryBarrier(error))) syncError = error;
|
|
702
|
+
}
|
|
703
|
+
let closeError: unknown;
|
|
704
|
+
try {
|
|
705
|
+
await handle.close();
|
|
706
|
+
} catch (error) {
|
|
707
|
+
closeError = error;
|
|
708
|
+
}
|
|
709
|
+
if (syncError && closeError)
|
|
710
|
+
throw new AggregateError([syncError, closeError], "Telegram alias directory sync and close failed");
|
|
711
|
+
if (syncError) throw syncError;
|
|
712
|
+
if (closeError) throw closeError;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function isUnsupportedTelegramDirectoryBarrier(error: unknown): boolean {
|
|
716
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
717
|
+
return code === "EINVAL" || code === "EISDIR" || code === "ENOTSUP" || code === "EPERM";
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export class TopicRegistryDurabilityUnavailableError extends Error {
|
|
721
|
+
readonly code = "durability_unavailable";
|
|
722
|
+
|
|
723
|
+
constructor(cause: unknown) {
|
|
724
|
+
super("topic registry durability is unavailable", { cause });
|
|
725
|
+
this.name = "TopicRegistryDurabilityUnavailableError";
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
type TopicRegistryExactReplace = (
|
|
730
|
+
sourcePath: string,
|
|
731
|
+
destinationPath: string,
|
|
732
|
+
expectedSource: NativeExactFileIdentity,
|
|
733
|
+
expectedDestination: NativeExactFileIdentity,
|
|
734
|
+
) => NativeExactUnlinkResult;
|
|
735
|
+
|
|
736
|
+
function sameExactFileStat(left: ExactFileStat, right: ExactFileStat): boolean {
|
|
737
|
+
return (
|
|
738
|
+
left.dev === right.dev &&
|
|
739
|
+
left.ino === right.ino &&
|
|
740
|
+
left.nlink === right.nlink &&
|
|
741
|
+
left.size === right.size &&
|
|
742
|
+
left.mtimeNs === right.mtimeNs
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function captureExactFileSnapshot(
|
|
747
|
+
fsImpl: TelegramDaemonFs,
|
|
748
|
+
file: string,
|
|
749
|
+
): Promise<{ bytes: string; identity: NativeExactFileIdentity }> {
|
|
750
|
+
if (!fsImpl.lstat)
|
|
751
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
752
|
+
new Error("topic registry persistence requires no-follow file identity"),
|
|
753
|
+
);
|
|
754
|
+
const before = await fsImpl.lstat(file, { bigint: true });
|
|
755
|
+
const bytes = await fsImpl.readFile(file, "utf8");
|
|
756
|
+
const after = await fsImpl.lstat(file, { bigint: true });
|
|
757
|
+
const parentStat = await fsImpl.lstat(path.dirname(file), { bigint: true });
|
|
758
|
+
if (!before.isFile() || !after.isFile() || before.nlink !== 1n || !sameExactFileStat(before, after))
|
|
759
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
760
|
+
new Error("topic registry persistence requires a stable single-link regular file"),
|
|
761
|
+
);
|
|
762
|
+
return {
|
|
763
|
+
bytes,
|
|
764
|
+
identity: {
|
|
765
|
+
dev: after.dev,
|
|
766
|
+
ino: after.ino,
|
|
767
|
+
nlink: after.nlink,
|
|
768
|
+
parentDev: parentStat.dev,
|
|
769
|
+
parentIno: parentStat.ino,
|
|
770
|
+
size: after.size,
|
|
771
|
+
mtimeNs: after.mtimeNs,
|
|
772
|
+
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
async function captureTopicRegistryDestination(
|
|
777
|
+
fsImpl: TelegramDaemonFs,
|
|
778
|
+
file: string,
|
|
779
|
+
platform: NodeJS.Platform,
|
|
780
|
+
): Promise<NativeExactFileIdentity | undefined> {
|
|
781
|
+
if (platform !== "win32") return undefined;
|
|
782
|
+
try {
|
|
783
|
+
return (await captureExactFileSnapshot(fsImpl, file)).identity;
|
|
784
|
+
} catch (error) {
|
|
785
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
786
|
+
throw error;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Publish topic authority only after its staged bytes and replacement are durable. */
|
|
791
|
+
export async function writeTopicRegistryAtomic(
|
|
792
|
+
fsImpl: TelegramDaemonFs,
|
|
793
|
+
file: string,
|
|
794
|
+
data: unknown,
|
|
795
|
+
platform: NodeJS.Platform = process.platform,
|
|
796
|
+
exactReplace: TopicRegistryExactReplace = exactReplacePath,
|
|
797
|
+
expectedDestination?: NativeExactFileIdentity,
|
|
798
|
+
): Promise<void> {
|
|
799
|
+
if (!fsImpl.fsyncFile)
|
|
800
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
801
|
+
new Error("topic registry persistence requires file fsync crash-atomicity"),
|
|
802
|
+
);
|
|
803
|
+
if (platform !== "win32" && !fsImpl.fsyncDirectory)
|
|
804
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
805
|
+
new Error("topic registry persistence requires directory fsync crash-atomicity"),
|
|
806
|
+
);
|
|
807
|
+
const parent = path.dirname(file);
|
|
808
|
+
const serialized = `${JSON.stringify(data, null, 2)}\n`;
|
|
809
|
+
let destinationExists = true;
|
|
810
|
+
try {
|
|
811
|
+
await fsImpl.readFile(file, "utf8");
|
|
812
|
+
} catch (error) {
|
|
813
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
814
|
+
destinationExists = false;
|
|
815
|
+
}
|
|
816
|
+
if (!destinationExists && expectedDestination)
|
|
817
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
818
|
+
new Error("topic registry destination disappeared after generation validation"),
|
|
819
|
+
);
|
|
820
|
+
if (!destinationExists) {
|
|
821
|
+
try {
|
|
822
|
+
await fsImpl.writeFile(file, serialized, { mode: 0o600, flag: "wx" });
|
|
823
|
+
} catch (error) {
|
|
824
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST")
|
|
825
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
826
|
+
new Error("topic registry appeared during exclusive creation"),
|
|
827
|
+
);
|
|
828
|
+
throw error;
|
|
829
|
+
}
|
|
830
|
+
await fsImpl.chmod(file, 0o600).catch(() => undefined);
|
|
831
|
+
await fsImpl.fsyncFile(file);
|
|
832
|
+
if (platform !== "win32") await fsImpl.fsyncDirectory!(parent);
|
|
833
|
+
const written = await fsImpl.readFile(file, "utf8");
|
|
834
|
+
if (written !== serialized)
|
|
835
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
836
|
+
new Error("exclusive topic registry creation verification failed"),
|
|
837
|
+
);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
842
|
+
try {
|
|
843
|
+
await fsImpl.writeFile(tmp, serialized, { mode: 0o600, flag: "wx" });
|
|
844
|
+
await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
|
|
845
|
+
await fsImpl.fsyncFile(tmp);
|
|
846
|
+
if (platform === "win32") {
|
|
847
|
+
if (typeof exactReplace !== "function")
|
|
848
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
849
|
+
new Error("native Windows exact replacement is unavailable"),
|
|
850
|
+
);
|
|
851
|
+
if (!expectedDestination)
|
|
852
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
853
|
+
new Error("native Windows replacement requires the validated destination identity"),
|
|
854
|
+
);
|
|
855
|
+
const expectedSource = (await captureExactFileSnapshot(fsImpl, tmp)).identity;
|
|
856
|
+
const outcome = exactReplace(tmp, file, expectedSource, expectedDestination);
|
|
857
|
+
if (!outcome.ok)
|
|
858
|
+
throw new TopicRegistryDurabilityUnavailableError(
|
|
859
|
+
new Error(`native Windows topic replacement failed: ${outcome.code ?? "unknown"}`),
|
|
860
|
+
);
|
|
861
|
+
} else {
|
|
862
|
+
await fsImpl.rename(tmp, file);
|
|
863
|
+
await fsImpl.fsyncDirectory!(parent);
|
|
864
|
+
}
|
|
865
|
+
const written = await fsImpl.readFile(file, "utf8");
|
|
866
|
+
if (written !== serialized)
|
|
867
|
+
throw new TopicRegistryDurabilityUnavailableError(new Error("topic registry replacement verification failed"));
|
|
868
|
+
} catch (error) {
|
|
869
|
+
await fsImpl.unlink(tmp).catch(() => undefined);
|
|
870
|
+
throw error;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Shared-volume topic authority backed by the existing cross-process file lock.
|
|
875
|
+
* A missing authority is the only valid bootstrap state; malformed and future
|
|
876
|
+
* snapshots are never interpreted as empty state.
|
|
877
|
+
*/
|
|
878
|
+
export class FilesystemTopicRegistryCasAuthority implements TopicRegistryCasAuthority {
|
|
879
|
+
private readonly fsImpl: TelegramDaemonFs;
|
|
880
|
+
private readonly platform: NodeJS.Platform;
|
|
881
|
+
private readonly exactReplace: TopicRegistryExactReplace;
|
|
882
|
+
private readonly installationHostId: string;
|
|
883
|
+
|
|
884
|
+
constructor(
|
|
885
|
+
private readonly file: string,
|
|
886
|
+
input: {
|
|
887
|
+
installationHostId: string;
|
|
888
|
+
fs?: TelegramDaemonFs;
|
|
889
|
+
platform?: NodeJS.Platform;
|
|
890
|
+
exactReplace?: TopicRegistryExactReplace;
|
|
891
|
+
},
|
|
892
|
+
) {
|
|
893
|
+
if (!input.installationHostId) throw new Error("installationHostId must be non-empty");
|
|
894
|
+
this.installationHostId = input.installationHostId;
|
|
895
|
+
this.fsImpl = input.fs ?? nodeFs;
|
|
896
|
+
this.platform = input.platform ?? process.platform;
|
|
897
|
+
this.exactReplace = input.exactReplace ?? exactReplacePath;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async read(): Promise<TopicRegistryState> {
|
|
901
|
+
return await withFileLock(this.file, async () => await this.readLocked(), {
|
|
902
|
+
staleMs: 10_000,
|
|
903
|
+
ownerHostId: this.installationHostId,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async compareAndSet(expectedGeneration: number, next: TopicRegistryState): Promise<boolean> {
|
|
908
|
+
if (!Number.isSafeInteger(expectedGeneration) || expectedGeneration < 0)
|
|
909
|
+
throw new Error("invalid expected topic registry generation");
|
|
910
|
+
if (
|
|
911
|
+
next.version !== 2 ||
|
|
912
|
+
next.registryGeneration !== expectedGeneration + 1 ||
|
|
913
|
+
parseTopicRegistryState(next) === undefined
|
|
914
|
+
)
|
|
915
|
+
throw new Error("invalid next topic registry generation");
|
|
916
|
+
return await withFileLock(
|
|
917
|
+
this.file,
|
|
918
|
+
async () => {
|
|
919
|
+
const { state: current, legacyRaw, expectedDestination } = await this.#readLockedWithLegacy();
|
|
920
|
+
if (current.registryGeneration !== expectedGeneration) return false;
|
|
921
|
+
await ensureDir(this.fsImpl, path.dirname(this.file));
|
|
922
|
+
if (legacyRaw !== undefined) await this.#quarantineLegacyLocked(legacyRaw);
|
|
923
|
+
await writeTopicRegistryAtomic(
|
|
924
|
+
this.fsImpl,
|
|
925
|
+
this.file,
|
|
926
|
+
next,
|
|
927
|
+
this.platform,
|
|
928
|
+
this.exactReplace,
|
|
929
|
+
expectedDestination,
|
|
930
|
+
);
|
|
931
|
+
return true;
|
|
932
|
+
},
|
|
933
|
+
{ staleMs: 10_000, ownerHostId: this.installationHostId },
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
private async readLocked(): Promise<TopicRegistryState> {
|
|
938
|
+
return (await this.#readLockedWithLegacy()).state;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async #readLockedWithLegacy(): Promise<{
|
|
942
|
+
state: TopicRegistryState;
|
|
943
|
+
legacyRaw?: unknown;
|
|
944
|
+
expectedDestination?: NativeExactFileIdentity;
|
|
945
|
+
}> {
|
|
946
|
+
let raw: unknown;
|
|
947
|
+
let expectedDestination: NativeExactFileIdentity | undefined;
|
|
948
|
+
try {
|
|
949
|
+
if (this.platform === "win32") {
|
|
950
|
+
const snapshot = await captureExactFileSnapshot(this.fsImpl, this.file);
|
|
951
|
+
raw = JSON.parse(snapshot.bytes);
|
|
952
|
+
expectedDestination = snapshot.identity;
|
|
953
|
+
} else {
|
|
954
|
+
raw = JSON.parse(await this.fsImpl.readFile(this.file, "utf8"));
|
|
955
|
+
}
|
|
956
|
+
} catch (error) {
|
|
957
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
958
|
+
return { state: { version: 2, registryGeneration: 0, topics: {} } };
|
|
959
|
+
throw new Error("shared topic authority is malformed or unavailable");
|
|
960
|
+
}
|
|
961
|
+
const legacyRaw =
|
|
962
|
+
raw && typeof raw === "object" && !Array.isArray(raw) && !Object.hasOwn(raw as object, "version")
|
|
963
|
+
? raw
|
|
964
|
+
: undefined;
|
|
965
|
+
const state = parseTopicRegistryState(raw);
|
|
966
|
+
const generation = state?.registryGeneration;
|
|
967
|
+
if (state?.version !== 2 || generation === undefined || !Number.isSafeInteger(generation) || generation < 0)
|
|
968
|
+
throw new Error("shared topic authority is malformed or unsupported");
|
|
969
|
+
return {
|
|
970
|
+
state: { ...state, registryGeneration: generation },
|
|
971
|
+
...(legacyRaw === undefined ? {} : { legacyRaw }),
|
|
972
|
+
...(expectedDestination === undefined ? {} : { expectedDestination }),
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async #quarantineLegacyLocked(raw: unknown): Promise<void> {
|
|
977
|
+
const digest = crypto.createHash("sha256").update(JSON.stringify(raw)).digest("hex");
|
|
978
|
+
const quarantinePath = `${this.file}.legacy-quarantine.${digest}.json`;
|
|
979
|
+
try {
|
|
980
|
+
await this.fsImpl.readFile(quarantinePath, "utf8");
|
|
981
|
+
return;
|
|
982
|
+
} catch (error) {
|
|
983
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
|
|
984
|
+
throw new Error("shared topic authority is malformed or unavailable");
|
|
985
|
+
}
|
|
986
|
+
await writeTopicRegistryAtomic(this.fsImpl, quarantinePath, raw, this.platform, this.exactReplace);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export interface MachineIdentityDeps {
|
|
991
|
+
platform?: NodeJS.Platform;
|
|
992
|
+
readFile?: (file: string) => Promise<string>;
|
|
993
|
+
runCommand?: (command: string, args: readonly string[]) => { exitCode: number; stdout: Uint8Array };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function normalizedMachineIdentity(value: string, pattern: RegExp): string | undefined {
|
|
997
|
+
const normalized = value.trim().toLowerCase();
|
|
998
|
+
if (!pattern.test(normalized) || /^0+$/.test(normalized.replace(/-/g, ""))) return undefined;
|
|
999
|
+
return normalized;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** @internal */
|
|
1003
|
+
export function parseWindowsMachineGuid(output: string): string | undefined {
|
|
1004
|
+
const match = /^\s*MachineGuid\s+REG_\w+\s+(\S+)\s*$/im.exec(output);
|
|
1005
|
+
return match
|
|
1006
|
+
? normalizedMachineIdentity(match[1], /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
|
|
1007
|
+
: undefined;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** @internal */
|
|
1011
|
+
export function parseMacPlatformUuid(output: string): string | undefined {
|
|
1012
|
+
const match = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(output);
|
|
1013
|
+
return match
|
|
1014
|
+
? normalizedMachineIdentity(match[1], /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
|
|
1015
|
+
: undefined;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function hashMachineIdentity(rawId: string): string {
|
|
1019
|
+
return crypto
|
|
1020
|
+
.createHash("sha256")
|
|
1021
|
+
.update("gajae-code:telegram-daemon:machine-identity:v1\0")
|
|
1022
|
+
.update(rawId)
|
|
1023
|
+
.digest("hex");
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** Loads a verified machine-local identity without persisting the underlying machine ID. */
|
|
1027
|
+
export async function loadInstallationHostId(deps: MachineIdentityDeps = {}): Promise<string> {
|
|
1028
|
+
const platform = deps.platform ?? process.platform;
|
|
1029
|
+
const readFile = deps.readFile ?? (async (file: string) => await fs.promises.readFile(file, "utf8"));
|
|
1030
|
+
const runCommand =
|
|
1031
|
+
deps.runCommand ??
|
|
1032
|
+
((command: string, args: readonly string[]) =>
|
|
1033
|
+
Bun.spawnSync([command, ...args], { stdout: "pipe", stderr: "ignore" }));
|
|
1034
|
+
|
|
1035
|
+
let rawId: string | undefined;
|
|
1036
|
+
if (platform === "linux") {
|
|
1037
|
+
for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
1038
|
+
try {
|
|
1039
|
+
const value = normalizedMachineIdentity(await readFile(file), /^[0-9a-f]{32}$/);
|
|
1040
|
+
if (!value) throw new Error("machine-local identity is unavailable or malformed");
|
|
1041
|
+
rawId = value;
|
|
1042
|
+
break;
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
} else if (platform === "win32") {
|
|
1048
|
+
const result = runCommand("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"]);
|
|
1049
|
+
if (result.exitCode === 0) rawId = parseWindowsMachineGuid(new TextDecoder().decode(result.stdout));
|
|
1050
|
+
} else if (platform === "darwin") {
|
|
1051
|
+
const result = runCommand("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]);
|
|
1052
|
+
if (result.exitCode === 0) rawId = parseMacPlatformUuid(new TextDecoder().decode(result.stdout));
|
|
1053
|
+
} else {
|
|
1054
|
+
throw new Error(`machine-local identity is unsupported on ${platform}`);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (!rawId) throw new Error("machine-local identity is unavailable or malformed");
|
|
1058
|
+
return hashMachineIdentity(rawId);
|
|
597
1059
|
}
|
|
598
1060
|
|
|
599
1061
|
function validDaemonPid(pid: unknown): pid is number {
|
|
@@ -2184,7 +2646,8 @@ export function isCurrentCompatibleOwner(input: {
|
|
|
2184
2646
|
state?.ownershipPhase === "ready" &&
|
|
2185
2647
|
typeof state.acquisitionId === "string" &&
|
|
2186
2648
|
state.acquisitionId.length > 0 &&
|
|
2187
|
-
|
|
2649
|
+
state.generation === DAEMON_GENERATION &&
|
|
2650
|
+
state.servingEpoch === SERVING_EPOCH,
|
|
2188
2651
|
);
|
|
2189
2652
|
}
|
|
2190
2653
|
|
|
@@ -3122,6 +3585,8 @@ export interface TelegramSpawnOwnerInput {
|
|
|
3122
3585
|
roots?: string[];
|
|
3123
3586
|
tokenFingerprint: string;
|
|
3124
3587
|
chatId: string;
|
|
3588
|
+
/** Ephemeral outbound-only validation destination for this owner launch. */
|
|
3589
|
+
validationTestSupergroupChatId?: string;
|
|
3125
3590
|
}
|
|
3126
3591
|
|
|
3127
3592
|
export interface TelegramSpawnAcquisition {
|
|
@@ -3149,7 +3614,12 @@ export type TelegramSpawnOwnerResult =
|
|
|
3149
3614
|
* Source mode prepends the entry script so the respawn loads edited source;
|
|
3150
3615
|
* a compiled binary self-spawns its own subcommand directly.
|
|
3151
3616
|
*/
|
|
3152
|
-
export function buildTelegramDaemonSpawnArgs(input: {
|
|
3617
|
+
export function buildTelegramDaemonSpawnArgs(input: {
|
|
3618
|
+
execPath?: string;
|
|
3619
|
+
ownerId: string;
|
|
3620
|
+
agentDir: string;
|
|
3621
|
+
validationTestSupergroupChatId?: string;
|
|
3622
|
+
}): {
|
|
3153
3623
|
command: string;
|
|
3154
3624
|
args: string[];
|
|
3155
3625
|
runtime: DaemonRuntimeInfo;
|
|
@@ -3163,6 +3633,9 @@ export function buildTelegramDaemonSpawnArgs(input: { execPath?: string; ownerId
|
|
|
3163
3633
|
input.ownerId,
|
|
3164
3634
|
"--agent-dir",
|
|
3165
3635
|
input.agentDir,
|
|
3636
|
+
...(input.validationTestSupergroupChatId !== undefined
|
|
3637
|
+
? ["--validation-test-supergroup-chat-id", input.validationTestSupergroupChatId]
|
|
3638
|
+
: []),
|
|
3166
3639
|
];
|
|
3167
3640
|
const runtime: DaemonRuntimeInfo = {
|
|
3168
3641
|
mode: rt.mode,
|
|
@@ -3238,6 +3711,7 @@ export async function spawnTelegramDaemonOwner(
|
|
|
3238
3711
|
execPath,
|
|
3239
3712
|
ownerId: provisionalAcquisition.ownerId,
|
|
3240
3713
|
agentDir,
|
|
3714
|
+
validationTestSupergroupChatId: input.validationTestSupergroupChatId,
|
|
3241
3715
|
});
|
|
3242
3716
|
const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
|
|
3243
3717
|
const child = spawnImpl(command, args, {
|
|
@@ -3419,13 +3893,17 @@ async function ensureTelegramDaemonRunningDetailedOnce(
|
|
|
3419
3893
|
// unproven cooperative handoff into destructive cleanup or a replacement spawn.
|
|
3420
3894
|
if ((deps.platform ?? process.platform) === "win32") {
|
|
3421
3895
|
const parentState: unknown = await readDaemonState(input.settings, deps.fs);
|
|
3422
|
-
if (
|
|
3896
|
+
if (
|
|
3897
|
+
isParentDaemonState(parentState) &&
|
|
3898
|
+
(deps.pidAlive ?? defaultPidAlive)(parentState.pid) &&
|
|
3899
|
+
(!hasSafeDaemonStateShape(parentState) || ownerProvenanceMatches(parentState, deps.pidIncarnation))
|
|
3900
|
+
)
|
|
3423
3901
|
return "blocked_identity";
|
|
3424
3902
|
}
|
|
3425
3903
|
// Windows can retain dead launcher metadata without an ownership lock; reclaim
|
|
3426
3904
|
// its dead discovery records before the replacement can publish a new owner.
|
|
3427
3905
|
if ((deps.platform ?? process.platform) === "win32" && !deps.fs) {
|
|
3428
|
-
|
|
3906
|
+
let preflight = await reclaimDeadDaemonOwner({
|
|
3429
3907
|
settings: input.settings,
|
|
3430
3908
|
endpointDir: path.join(root, "sdk"),
|
|
3431
3909
|
fs: deps.fs,
|
|
@@ -3433,6 +3911,28 @@ async function ensureTelegramDaemonRunningDetailedOnce(
|
|
|
3433
3911
|
pidAlive: deps.pidAlive,
|
|
3434
3912
|
pidIncarnation: deps.pidIncarnation,
|
|
3435
3913
|
});
|
|
3914
|
+
// A just-reused PID can be reported alive for the first probe. Recheck once
|
|
3915
|
+
// before acquisition only when stale discovery records need fenced cleanup.
|
|
3916
|
+
// reclaimDeadDaemonOwner still requires exact state, lock, incarnation,
|
|
3917
|
+
// transition-lock, and endpoint-identity proofs before deleting anything.
|
|
3918
|
+
const endpointNames =
|
|
3919
|
+
preflight.reason === "not-confirmed-dead"
|
|
3920
|
+
? await (deps.fs ?? nodeFs).readdir(path.join(root, "sdk")).catch(() => undefined)
|
|
3921
|
+
: undefined;
|
|
3922
|
+
if (
|
|
3923
|
+
!preflight.recovered &&
|
|
3924
|
+
preflight.reason === "not-confirmed-dead" &&
|
|
3925
|
+
endpointNames?.some(name => name.endsWith(".json"))
|
|
3926
|
+
) {
|
|
3927
|
+
preflight = await reclaimDeadDaemonOwner({
|
|
3928
|
+
settings: input.settings,
|
|
3929
|
+
endpointDir: path.join(root, "sdk"),
|
|
3930
|
+
fs: deps.fs,
|
|
3931
|
+
now: deps.now,
|
|
3932
|
+
pidAlive: deps.pidAlive,
|
|
3933
|
+
pidIncarnation: deps.pidIncarnation,
|
|
3934
|
+
});
|
|
3935
|
+
}
|
|
3436
3936
|
if (!preflight.recovered && preflight.reason !== "not-confirmed-dead") {
|
|
3437
3937
|
logger.warn(
|
|
3438
3938
|
`notifications: startup recovery unsafe (${preflight.reason}); run \`gjc notify recovery\` for diagnostics`,
|
|
@@ -3849,7 +4349,7 @@ export class TelegramBotTransport implements BotApi {
|
|
|
3849
4349
|
}
|
|
3850
4350
|
}
|
|
3851
4351
|
|
|
3852
|
-
type PairedChatPrivacy = "private" | "non-private" | "indeterminate";
|
|
4352
|
+
type PairedChatPrivacy = "private" | "validation-forum" | "non-private" | "indeterminate";
|
|
3853
4353
|
|
|
3854
4354
|
export type TelegramUpdateOutcome = "consumed" | "retry";
|
|
3855
4355
|
|
|
@@ -4065,6 +4565,12 @@ export interface TelegramDaemonOptions {
|
|
|
4065
4565
|
ownerId: string;
|
|
4066
4566
|
botToken: string;
|
|
4067
4567
|
chatId: string;
|
|
4568
|
+
/**
|
|
4569
|
+
* Exact ephemeral forum destination supplied to the daemon-internal validation
|
|
4570
|
+
* command. Production ownership, pairing, and durable topic authority remain
|
|
4571
|
+
* bound to `chatId`.
|
|
4572
|
+
*/
|
|
4573
|
+
validationTestSupergroupChatId?: string;
|
|
4068
4574
|
apiBase?: string;
|
|
4069
4575
|
fetchImpl?: typeof fetch;
|
|
4070
4576
|
fs?: TelegramDaemonFs;
|
|
@@ -4116,6 +4622,20 @@ export interface TelegramDaemonOptions {
|
|
|
4116
4622
|
* built-in `{repo}/{branch} - {title}` composition and its fallbacks.
|
|
4117
4623
|
*/
|
|
4118
4624
|
topics?: { nameTemplate?: string };
|
|
4625
|
+
/**
|
|
4626
|
+
* Optional compare-and-set store for installations that share topic state
|
|
4627
|
+
* across hosts. When configured, every publication is fenced by it.
|
|
4628
|
+
*/
|
|
4629
|
+
topicRegistryAuthority?: TopicRegistryCasAuthority;
|
|
4630
|
+
/** Stable host-local identity. Required whenever a shared authority is configured. */
|
|
4631
|
+
installationHostId?: string;
|
|
4632
|
+
}
|
|
4633
|
+
|
|
4634
|
+
interface StagedCallbackActivation {
|
|
4635
|
+
pending: { sessionId: string; actionId: string };
|
|
4636
|
+
socketLease: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
4637
|
+
topicLease?: TopicAuthorityLease;
|
|
4638
|
+
settled: Promise<boolean>;
|
|
4119
4639
|
}
|
|
4120
4640
|
|
|
4121
4641
|
interface SessionSocket {
|
|
@@ -4226,7 +4746,8 @@ interface PendingThreadedFrame {
|
|
|
4226
4746
|
send: ThreadedSend;
|
|
4227
4747
|
msg: Record<string, unknown>;
|
|
4228
4748
|
logicalSessionId: string;
|
|
4229
|
-
|
|
4749
|
+
session: SessionSocket;
|
|
4750
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string };
|
|
4230
4751
|
toolActivity?: ToolActivityOwner;
|
|
4231
4752
|
}
|
|
4232
4753
|
|
|
@@ -4246,6 +4767,37 @@ interface BotApiCallResult {
|
|
|
4246
4767
|
response: unknown;
|
|
4247
4768
|
outcome: BotApiCallOutcome;
|
|
4248
4769
|
}
|
|
4770
|
+
function createBotApiAdapter(
|
|
4771
|
+
call: (
|
|
4772
|
+
method: string,
|
|
4773
|
+
body: unknown,
|
|
4774
|
+
callOpts?: { signal?: AbortSignal; noRetry?: boolean },
|
|
4775
|
+
) => Promise<BotApiCallResult>,
|
|
4776
|
+
): BotApi {
|
|
4777
|
+
return {
|
|
4778
|
+
call: async (method, body, callOpts) => (await call(method, body, callOpts)).response,
|
|
4779
|
+
};
|
|
4780
|
+
}
|
|
4781
|
+
function createBotApiPipeline(
|
|
4782
|
+
rawBotApi: BotApi,
|
|
4783
|
+
callBotApi: (
|
|
4784
|
+
rawBotApi: BotApi,
|
|
4785
|
+
method: string,
|
|
4786
|
+
body: unknown,
|
|
4787
|
+
callOpts?: { signal?: AbortSignal; noRetry?: boolean },
|
|
4788
|
+
) => Promise<BotApiCallResult>,
|
|
4789
|
+
): {
|
|
4790
|
+
classified: (
|
|
4791
|
+
method: string,
|
|
4792
|
+
body: unknown,
|
|
4793
|
+
callOpts?: { signal?: AbortSignal; noRetry?: boolean },
|
|
4794
|
+
) => Promise<BotApiCallResult>;
|
|
4795
|
+
api: BotApi;
|
|
4796
|
+
} {
|
|
4797
|
+
const classified = (method: string, body: unknown, callOpts?: { signal?: AbortSignal; noRetry?: boolean }) =>
|
|
4798
|
+
callBotApi(rawBotApi, method, body, callOpts);
|
|
4799
|
+
return { classified, api: createBotApiAdapter(classified) };
|
|
4800
|
+
}
|
|
4249
4801
|
|
|
4250
4802
|
function classifyBotApiCallOutcome(response: unknown, cooldownSuppressed = false): BotApiCallOutcome {
|
|
4251
4803
|
if (cooldownSuppressed) return { kind: "retryable", retryAfterMs: 0 };
|
|
@@ -4329,6 +4881,7 @@ interface PendingBtwDelivery {
|
|
|
4329
4881
|
|
|
4330
4882
|
class TelegramEffectSupervisor {
|
|
4331
4883
|
#stopping = false;
|
|
4884
|
+
#admitting = true;
|
|
4332
4885
|
readonly #abort = new AbortController();
|
|
4333
4886
|
readonly #terminalContext = new AsyncLocalStorage<boolean>();
|
|
4334
4887
|
readonly #pending = new Set<Promise<unknown>>();
|
|
@@ -4350,6 +4903,16 @@ class TelegramEffectSupervisor {
|
|
|
4350
4903
|
return this.track(api.call(method, body, { ...opts, signal }));
|
|
4351
4904
|
}
|
|
4352
4905
|
|
|
4906
|
+
admit<T>(effect: () => Promise<T>): Promise<T> {
|
|
4907
|
+
if (!this.#admitting)
|
|
4908
|
+
return Promise.reject(Object.assign(new Error("Daemon is stopping"), { name: "AbortError" }));
|
|
4909
|
+
try {
|
|
4910
|
+
return this.track(effect());
|
|
4911
|
+
} catch (error) {
|
|
4912
|
+
return Promise.reject(error);
|
|
4913
|
+
}
|
|
4914
|
+
}
|
|
4915
|
+
|
|
4353
4916
|
track<T>(effect: Promise<T>): Promise<T> {
|
|
4354
4917
|
this.#pending.add(effect);
|
|
4355
4918
|
void effect.then(
|
|
@@ -4363,7 +4926,12 @@ class TelegramEffectSupervisor {
|
|
|
4363
4926
|
return this.#stopping;
|
|
4364
4927
|
}
|
|
4365
4928
|
|
|
4929
|
+
closeAdmission(): void {
|
|
4930
|
+
this.#admitting = false;
|
|
4931
|
+
}
|
|
4932
|
+
|
|
4366
4933
|
beginShutdown(): void {
|
|
4934
|
+
this.closeAdmission();
|
|
4367
4935
|
this.#stopping = true;
|
|
4368
4936
|
this.#abort.abort("daemon_shutdown");
|
|
4369
4937
|
}
|
|
@@ -4388,7 +4956,18 @@ class TelegramEffectSupervisor {
|
|
|
4388
4956
|
|
|
4389
4957
|
export class TelegramNotificationDaemon {
|
|
4390
4958
|
readonly aliasTable: AliasTable;
|
|
4959
|
+
/** Callback aliases are routable only by the immutable socket lease that rendered them. */
|
|
4960
|
+
#callbackAliasLeases = new Map<string, { session: SessionSocket; token: number; logicalSessionId: string }>();
|
|
4961
|
+
/** Each active alias is bound to the exact pending ask object that rendered it. */
|
|
4962
|
+
#callbackAliasPending = new Map<string, { sessionId: string; actionId: string }>();
|
|
4963
|
+
/** Active callbacks preserve the exact topic authority under which their keyboard was rendered. */
|
|
4964
|
+
#callbackAliasTopics = new Map<string, TopicAuthorityLease>();
|
|
4965
|
+
/** Exposed aliases wait for their accepted receipt persistence before routing. */
|
|
4966
|
+
#stagedCallbackActivations = new Map<string, StagedCallbackActivation>();
|
|
4391
4967
|
readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
|
|
4968
|
+
/** Restart-revoked aliases retained only to terminalize their old keyboards on an authoritative replay. */
|
|
4969
|
+
private readonly reissueBacklog = new Map<string, CallbackRoute>();
|
|
4970
|
+
private aliasPersistenceQueue: Promise<void> = Promise.resolve();
|
|
4392
4971
|
/** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
|
|
4393
4972
|
private readonly liveMessages = new Map<string, number>();
|
|
4394
4973
|
/** Endpoint-bound ownership for visible or dispatching tool bubbles. */
|
|
@@ -4432,6 +5011,8 @@ export class TelegramNotificationDaemon {
|
|
|
4432
5011
|
private readonly botApi: BotApi;
|
|
4433
5012
|
private readonly effects = new TelegramEffectSupervisor();
|
|
4434
5013
|
private readonly topics = new TopicRegistry();
|
|
5014
|
+
/** Stable host-local identity; never persisted in shared topic authority. */
|
|
5015
|
+
private installationHostId: string;
|
|
4435
5016
|
/**
|
|
4436
5017
|
* Telegram may accept createForumTopic while returning an unusable success
|
|
4437
5018
|
* payload. Remember that ambiguity per endpoint so later frames cannot repeat
|
|
@@ -4446,6 +5027,11 @@ export class TelegramNotificationDaemon {
|
|
|
4446
5027
|
private recoveryBindingClaimQueue: Promise<void> = Promise.resolve();
|
|
4447
5028
|
/** Durable compensation fences retry under supervision until persistence succeeds. */
|
|
4448
5029
|
private readonly compensationFenceRetries = new Map<string, Promise<void>>();
|
|
5030
|
+
/** All archive paths for one session share one durable fence and remote dispatch. */
|
|
5031
|
+
private readonly archiveFlights = new Map<
|
|
5032
|
+
string,
|
|
5033
|
+
Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled">
|
|
5034
|
+
>();
|
|
4449
5035
|
|
|
4450
5036
|
/** Daemon edit attempts that can race an accepted user service message. */
|
|
4451
5037
|
private readonly daemonRenameAttempts = new Map<string, number>();
|
|
@@ -4486,8 +5072,10 @@ export class TelegramNotificationDaemon {
|
|
|
4486
5072
|
private threadedFallbackNoticeSent = false;
|
|
4487
5073
|
/** Sessions whose identity header was already sent flat (Threaded Mode off). */
|
|
4488
5074
|
private readonly flatIdentitySent = new Set<string>();
|
|
4489
|
-
/** Cached
|
|
4490
|
-
private
|
|
5075
|
+
/** Cached delivery boundary for the private owner chat or validation forum. */
|
|
5076
|
+
private pairedChatPrivacy: PairedChatPrivacy | undefined;
|
|
5077
|
+
/** Latched once Telegram confirms this chat cannot host forum topics. */
|
|
5078
|
+
private topicCapabilityRefused = false;
|
|
4491
5079
|
/** Bot username from getMe, cached once at owner startup for group/forum command targeting. */
|
|
4492
5080
|
private botUsername: string | undefined;
|
|
4493
5081
|
/** Sessions whose agent loop is currently busy (drives the typing indicator). */
|
|
@@ -4555,6 +5143,7 @@ export class TelegramNotificationDaemon {
|
|
|
4555
5143
|
*/
|
|
4556
5144
|
requestStop(_reason?: "reload" | "stop" | "signal"): void {
|
|
4557
5145
|
this.stopRequested = true;
|
|
5146
|
+
this.effects.closeAdmission();
|
|
4558
5147
|
|
|
4559
5148
|
const toolShutdown = this.beginToolActivityShutdown();
|
|
4560
5149
|
void toolShutdown
|
|
@@ -4919,7 +5508,8 @@ export class TelegramNotificationDaemon {
|
|
|
4919
5508
|
const outcome = classifyBotApiCallOutcome(undefined, true);
|
|
4920
5509
|
return { response: undefined, outcome };
|
|
4921
5510
|
}
|
|
4922
|
-
const
|
|
5511
|
+
const outboundBody = this.validationTopicDestination(method, body);
|
|
5512
|
+
const response = await this.effects.call(rawBotApi, method, outboundBody, callOpts);
|
|
4923
5513
|
const outcome = classifyBotApiCallOutcome(response);
|
|
4924
5514
|
if (outcome.kind === "retryable")
|
|
4925
5515
|
this.botCooldownUntil = Math.max(
|
|
@@ -4928,6 +5518,20 @@ export class TelegramNotificationDaemon {
|
|
|
4928
5518
|
);
|
|
4929
5519
|
return { response, outcome };
|
|
4930
5520
|
}
|
|
5521
|
+
private validationTopicDestination(method: string, body: unknown): unknown {
|
|
5522
|
+
if (
|
|
5523
|
+
!this.validationMode() ||
|
|
5524
|
+
method === "getChat" ||
|
|
5525
|
+
!body ||
|
|
5526
|
+
typeof body !== "object" ||
|
|
5527
|
+
String((body as { chat_id?: unknown }).chat_id) !== String(this.opts.chatId)
|
|
5528
|
+
)
|
|
5529
|
+
return body;
|
|
5530
|
+
return {
|
|
5531
|
+
...(body as Record<string, unknown>),
|
|
5532
|
+
chat_id: this.opts.validationTestSupergroupChatId!,
|
|
5533
|
+
};
|
|
5534
|
+
}
|
|
4931
5535
|
|
|
4932
5536
|
private readonly callBotApiClassified: (
|
|
4933
5537
|
method: string,
|
|
@@ -4948,6 +5552,9 @@ export class TelegramNotificationDaemon {
|
|
|
4948
5552
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
4949
5553
|
this.fsImpl = opts.fs ?? nodeFs;
|
|
4950
5554
|
this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
|
|
5555
|
+
if (opts.topicRegistryAuthority && !opts.installationHostId)
|
|
5556
|
+
throw new Error("shared topic authority requires a host-local installation identity");
|
|
5557
|
+
this.installationHostId = opts.installationHostId ?? crypto.randomUUID();
|
|
4951
5558
|
this.aliasTable = createAliasTable();
|
|
4952
5559
|
this.#adoptionIntentTtlMs = opts.adoptionIntentTtlMs ?? DEFAULT_ADOPTION_INTENT_TTL_MS;
|
|
4953
5560
|
const adoptionFs: AdoptionIntentFs = {
|
|
@@ -4981,10 +5588,9 @@ export class TelegramNotificationDaemon {
|
|
|
4981
5588
|
fetchImpl: opts.fetchImpl,
|
|
4982
5589
|
setTimeoutImpl: opts.setTimeoutImpl,
|
|
4983
5590
|
});
|
|
4984
|
-
|
|
4985
|
-
this.
|
|
4986
|
-
|
|
4987
|
-
};
|
|
5591
|
+
const botApiPipeline = createBotApiPipeline(rawBotApi, this.callBotApi.bind(this));
|
|
5592
|
+
this.callBotApiClassified = botApiPipeline.classified;
|
|
5593
|
+
this.botApi = botApiPipeline.api;
|
|
4988
5594
|
this.runtime = new NotificationOperatorRuntime({
|
|
4989
5595
|
now: opts.now,
|
|
4990
5596
|
setTimeoutImpl: opts.setTimeoutImpl,
|
|
@@ -5058,7 +5664,6 @@ export class TelegramNotificationDaemon {
|
|
|
5058
5664
|
if (!socketLease) return;
|
|
5059
5665
|
const topicLease = this.topicAuthorityLeaseFromRegistry(logicalSessionId);
|
|
5060
5666
|
if (
|
|
5061
|
-
this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
|
|
5062
5667
|
this.topics.get(logicalSessionId)?.bindingMalformed ||
|
|
5063
5668
|
(mode === "recovery" && (!topicLease || msg.sessionId !== logicalSessionId))
|
|
5064
5669
|
) {
|
|
@@ -5217,40 +5822,49 @@ export class TelegramNotificationDaemon {
|
|
|
5217
5822
|
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
5218
5823
|
|
|
5219
5824
|
const closedBinding = this.#endpointBinding(session);
|
|
5220
|
-
const
|
|
5825
|
+
const closedBindingMatches = () => {
|
|
5826
|
+
const current = this.closedEndpointKeys.get(session.sessionId);
|
|
5827
|
+
return (
|
|
5828
|
+
current?.chatId === closedBinding.chatId &&
|
|
5829
|
+
current.endpointKey === closedBinding.endpointKey &&
|
|
5830
|
+
current.endpointDigest === closedBinding.endpointDigest &&
|
|
5831
|
+
current.endpointGeneration === closedBinding.endpointGeneration
|
|
5832
|
+
);
|
|
5833
|
+
};
|
|
5834
|
+
const closeTopicAuthority = this.topics.captureArchiveAuthority(logicalSessionId);
|
|
5221
5835
|
const previousClosedBinding = this.closedEndpointKeys.get(session.sessionId);
|
|
5222
5836
|
await this.#persistTopicMutation(
|
|
5223
5837
|
() => {
|
|
5224
5838
|
this.closedEndpointKeys.set(session.sessionId, closedBinding);
|
|
5225
|
-
this.topics.
|
|
5839
|
+
this.topics.beginArchive(logicalSessionId, this.installationHostId, this.runtime.now());
|
|
5226
5840
|
},
|
|
5227
5841
|
() => {
|
|
5228
|
-
this.topics.
|
|
5842
|
+
this.topics.restoreArchiveAuthority(closeTopicAuthority);
|
|
5229
5843
|
if (previousClosedBinding === undefined) this.closedEndpointKeys.delete(session.sessionId);
|
|
5230
|
-
else if (
|
|
5844
|
+
else if (closedBindingMatches())
|
|
5231
5845
|
this.closedEndpointKeys.set(session.sessionId, previousClosedBinding);
|
|
5232
5846
|
},
|
|
5233
5847
|
);
|
|
5234
5848
|
if (
|
|
5235
|
-
|
|
5236
|
-
(
|
|
5237
|
-
session.recoveryLease?.token !== socketLease.token ||
|
|
5238
|
-
|
|
5239
|
-
|
|
5849
|
+
this.sessions.get(session.sessionId) !== session ||
|
|
5850
|
+
(socketLease &&
|
|
5851
|
+
(session.recoveryLease?.token !== socketLease.token ||
|
|
5852
|
+
session.recoveryLease.state !== "authorized" ||
|
|
5853
|
+
this.logicalSessionOwners.get(logicalSessionId) !== session))
|
|
5240
5854
|
) {
|
|
5241
5855
|
// A replacement won after the close fence committed. Restore the exact
|
|
5242
5856
|
// pre-close authority and remove the predecessor tombstone together.
|
|
5243
5857
|
const restoreCloseAuthority = (): Promise<boolean> =>
|
|
5244
5858
|
this.#persistTopicMutation(
|
|
5245
5859
|
() => {
|
|
5246
|
-
|
|
5247
|
-
if (
|
|
5248
|
-
this.closedEndpointKeys.delete(session.sessionId);
|
|
5860
|
+
this.topics.restoreArchiveAuthority(closeTopicAuthority);
|
|
5861
|
+
if (closedBindingMatches()) this.closedEndpointKeys.delete(session.sessionId);
|
|
5249
5862
|
return true;
|
|
5250
5863
|
},
|
|
5251
5864
|
() => {
|
|
5252
|
-
this.topics.
|
|
5253
|
-
this.
|
|
5865
|
+
this.topics.restoreArchiveFence(closeTopicAuthority);
|
|
5866
|
+
if (this.sessions.get(session.sessionId) === session)
|
|
5867
|
+
this.closedEndpointKeys.set(session.sessionId, closedBinding);
|
|
5254
5868
|
},
|
|
5255
5869
|
);
|
|
5256
5870
|
try {
|
|
@@ -5260,7 +5874,7 @@ export class TelegramNotificationDaemon {
|
|
|
5260
5874
|
}
|
|
5261
5875
|
return;
|
|
5262
5876
|
}
|
|
5263
|
-
const deleteOutcome = await this.
|
|
5877
|
+
const deleteOutcome = await this.archiveTopic(logicalSessionId, socketLease, true);
|
|
5264
5878
|
if (
|
|
5265
5879
|
deleteOutcome === "pre_dispatch_cancelled" &&
|
|
5266
5880
|
socketLease &&
|
|
@@ -5268,12 +5882,12 @@ export class TelegramNotificationDaemon {
|
|
|
5268
5882
|
) {
|
|
5269
5883
|
await this.#persistTopicMutation(
|
|
5270
5884
|
() => {
|
|
5271
|
-
if (!this.topics.
|
|
5885
|
+
if (!this.topics.restoreArchiveAuthority(closeTopicAuthority))
|
|
5272
5886
|
throw new Error("close authority changed before compensation");
|
|
5273
5887
|
this.closedEndpointKeys.delete(session.sessionId);
|
|
5274
5888
|
},
|
|
5275
5889
|
() => {
|
|
5276
|
-
this.topics.
|
|
5890
|
+
this.topics.restoreArchiveFence(closeTopicAuthority);
|
|
5277
5891
|
this.closedEndpointKeys.set(session.sessionId, closedBinding);
|
|
5278
5892
|
},
|
|
5279
5893
|
);
|
|
@@ -5286,17 +5900,40 @@ export class TelegramNotificationDaemon {
|
|
|
5286
5900
|
}
|
|
5287
5901
|
|
|
5288
5902
|
async loadAliases(): Promise<void> {
|
|
5903
|
+
if (this.validationMode()) return;
|
|
5289
5904
|
const raw = await readJson<unknown>(this.fsImpl, daemonPaths(this.opts.settings.getAgentDir()).aliases);
|
|
5290
|
-
if (raw)
|
|
5905
|
+
if (!raw) return;
|
|
5906
|
+
const persisted = raw as { revokedRoutes?: unknown };
|
|
5907
|
+
const revoked = createAliasTable();
|
|
5908
|
+
revoked.load({ version: 2, next: 1, routes: persisted.revokedRoutes });
|
|
5909
|
+
for (const [alias, route] of revoked.entries()) this.reissueBacklog.set(alias, route);
|
|
5910
|
+
this.aliasTable.load(raw);
|
|
5911
|
+
for (const [alias, route] of this.aliasTable.entries()) this.reissueBacklog.set(alias, route);
|
|
5912
|
+
this.aliasTable.clear();
|
|
5913
|
+
await this.persistAliases();
|
|
5291
5914
|
}
|
|
5292
5915
|
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5916
|
+
persistAliases(): Promise<void> {
|
|
5917
|
+
if (this.validationMode()) return Promise.resolve();
|
|
5918
|
+
const pending = this.aliasPersistenceQueue.then(async () => {
|
|
5919
|
+
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
5920
|
+
await ensureDir(this.fsImpl, paths.dir);
|
|
5921
|
+
await writeJsonAtomic(
|
|
5922
|
+
this.fsImpl,
|
|
5923
|
+
paths.aliases,
|
|
5924
|
+
{
|
|
5925
|
+
...this.aliasTable.serialize(),
|
|
5926
|
+
revokedRoutes: Object.fromEntries(this.reissueBacklog),
|
|
5927
|
+
},
|
|
5928
|
+
{ durable: true },
|
|
5929
|
+
);
|
|
5930
|
+
});
|
|
5931
|
+
this.aliasPersistenceQueue = pending.catch(() => undefined);
|
|
5932
|
+
return pending;
|
|
5297
5933
|
}
|
|
5298
5934
|
|
|
5299
5935
|
async loadSeenUpdateIds(): Promise<void> {
|
|
5936
|
+
if (this.validationMode()) return;
|
|
5300
5937
|
const raw = await readJson<{ updateIds?: unknown }>(
|
|
5301
5938
|
this.fsImpl,
|
|
5302
5939
|
daemonPaths(this.opts.settings.getAgentDir()).seenUpdates,
|
|
@@ -5312,6 +5949,7 @@ export class TelegramNotificationDaemon {
|
|
|
5312
5949
|
}
|
|
5313
5950
|
|
|
5314
5951
|
async persistSeenUpdateIds(): Promise<void> {
|
|
5952
|
+
if (this.validationMode()) return;
|
|
5315
5953
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
5316
5954
|
await ensureDir(this.fsImpl, paths.dir);
|
|
5317
5955
|
await writeJsonAtomic(this.fsImpl, paths.seenUpdates, {
|
|
@@ -5341,6 +5979,12 @@ export class TelegramNotificationDaemon {
|
|
|
5341
5979
|
}
|
|
5342
5980
|
}
|
|
5343
5981
|
private async reserveSeenUpdateId(updateId: number): Promise<boolean> {
|
|
5982
|
+
if (this.validationMode()) {
|
|
5983
|
+
if (this.dispatchState.seenUpdateIds.has(updateId)) return false;
|
|
5984
|
+
this.dispatchState.seenUpdateIds.add(updateId);
|
|
5985
|
+
this.pruneSeenUpdateIds();
|
|
5986
|
+
return true;
|
|
5987
|
+
}
|
|
5344
5988
|
if (!Number.isSafeInteger(updateId) || updateId < 0) return false;
|
|
5345
5989
|
const candidate = new Set(this.dispatchState.seenUpdateIds);
|
|
5346
5990
|
candidate.add(updateId);
|
|
@@ -5362,6 +6006,10 @@ export class TelegramNotificationDaemon {
|
|
|
5362
6006
|
}
|
|
5363
6007
|
|
|
5364
6008
|
private async releaseSeenUpdateId(updateId: number): Promise<void> {
|
|
6009
|
+
if (this.validationMode()) {
|
|
6010
|
+
this.dispatchState.seenUpdateIds.delete(updateId);
|
|
6011
|
+
return;
|
|
6012
|
+
}
|
|
5365
6013
|
if (!this.dispatchState.seenUpdateIds.has(updateId)) return;
|
|
5366
6014
|
const candidate = new Set(this.dispatchState.seenUpdateIds);
|
|
5367
6015
|
candidate.delete(updateId);
|
|
@@ -5447,28 +6095,31 @@ export class TelegramNotificationDaemon {
|
|
|
5447
6095
|
} catch {}
|
|
5448
6096
|
}
|
|
5449
6097
|
}
|
|
5450
|
-
if (
|
|
6098
|
+
if (!this.validationMode()) {
|
|
6099
|
+
if (permanentlyMissingRoots.length > 0) {
|
|
6100
|
+
try {
|
|
6101
|
+
await pruneMissingNotificationRoots({
|
|
6102
|
+
settings: this.opts.settings,
|
|
6103
|
+
fs: this.fsImpl,
|
|
6104
|
+
candidates: permanentlyMissingRoots,
|
|
6105
|
+
});
|
|
6106
|
+
} catch (error) {
|
|
6107
|
+
logger.warn(`notifications: dead-root prune failed: ${sanitizeDiagnostic(String(error))}`);
|
|
6108
|
+
}
|
|
6109
|
+
}
|
|
6110
|
+
// Best-effort periodic reap of retained exact-unlink quarantines (#2956).
|
|
5451
6111
|
try {
|
|
5452
|
-
await
|
|
6112
|
+
await reapStaleNotificationArtifacts({
|
|
5453
6113
|
settings: this.opts.settings,
|
|
5454
6114
|
fs: this.fsImpl,
|
|
5455
|
-
|
|
6115
|
+
now: this.opts.now,
|
|
6116
|
+
pidAlive: this.opts.pidAlive,
|
|
5456
6117
|
});
|
|
5457
6118
|
} catch (error) {
|
|
5458
|
-
logger.warn(`notifications:
|
|
6119
|
+
logger.warn(`notifications: leak-artifact reap failed: ${sanitizeDiagnostic(String(error))}`);
|
|
5459
6120
|
}
|
|
5460
6121
|
}
|
|
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
|
-
}
|
|
6122
|
+
|
|
5472
6123
|
if (allRootsReadable) {
|
|
5473
6124
|
for (const sessionId of this.topics.sessionIds()) {
|
|
5474
6125
|
const owner = this.logicalSessionOwners.get(sessionId);
|
|
@@ -5521,7 +6172,7 @@ export class TelegramNotificationDaemon {
|
|
|
5521
6172
|
replayQueue: [],
|
|
5522
6173
|
};
|
|
5523
6174
|
this.sessions.set(sessionId, session);
|
|
5524
|
-
if (this.topics.get(sessionId)) this.preservedInitiatorTopics.add(sessionId);
|
|
6175
|
+
if (this.topics.get(sessionId)?.authorityState === "active") this.preservedInitiatorTopics.add(sessionId);
|
|
5525
6176
|
|
|
5526
6177
|
// Bidirectional capability advertisement: announce client_ping_pong once the
|
|
5527
6178
|
// socket is open. Sent on "open" only — a real WHATWG WebSocket cannot send
|
|
@@ -5566,7 +6217,9 @@ export class TelegramNotificationDaemon {
|
|
|
5566
6217
|
void (async () => {
|
|
5567
6218
|
if (this.#logicalSessionId(session) !== sessionId) return;
|
|
5568
6219
|
const topic = this.topics.get(sessionId);
|
|
5569
|
-
if (!topic || topic.
|
|
6220
|
+
if (!topic || topic.bindingMalformed) return;
|
|
6221
|
+
if (topic.authorityState === "disconnect_grace" && !(await this.#renewTopicLease(sessionId))) return;
|
|
6222
|
+
if (topic.authorityState !== "active") return;
|
|
5570
6223
|
const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
|
|
5571
6224
|
if (topicLease?.topicId === topic.topicId) await this.flushPendingThreadedFrames(sessionId, topicLease);
|
|
5572
6225
|
})().catch(err =>
|
|
@@ -5579,11 +6232,13 @@ export class TelegramNotificationDaemon {
|
|
|
5579
6232
|
// Identity guard: a delayed frame from a superseded socket must not act
|
|
5580
6233
|
// through the replacement session.
|
|
5581
6234
|
if (this.sessions.get(sessionId) !== session) return;
|
|
5582
|
-
void this.
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
6235
|
+
void this.effects
|
|
6236
|
+
.admit(() => this.handleSessionMessage(session, JSON.parse(String(ev.data))))
|
|
6237
|
+
.catch(err => {
|
|
6238
|
+
// Surface frame-handling failures (e.g. a rejected ask sendMessage) to
|
|
6239
|
+
// the daemon log instead of an invisible unhandled rejection.
|
|
6240
|
+
logger.error("notifications daemon: handleSessionMessage failed", { error: String(err) });
|
|
6241
|
+
});
|
|
5587
6242
|
});
|
|
5588
6243
|
ws.addEventListener("close", () => {
|
|
5589
6244
|
this.dropSession(session, "socket_closed");
|
|
@@ -5610,6 +6265,11 @@ export class TelegramNotificationDaemon {
|
|
|
5610
6265
|
this.dropSession(session, "liveness_timeout");
|
|
5611
6266
|
return;
|
|
5612
6267
|
}
|
|
6268
|
+
const logicalSessionId = this.#logicalSessionId(session);
|
|
6269
|
+
if (session.logicalSessionIdTrusted)
|
|
6270
|
+
void this.#renewTopicLease(logicalSessionId).then(renewed => {
|
|
6271
|
+
if (!renewed) this.dropSession(session, "topic_lease_lost");
|
|
6272
|
+
});
|
|
5613
6273
|
if (session.ws.readyState === WebSocket.OPEN) {
|
|
5614
6274
|
const nonce = `${session.sessionId}:${t}:${Math.random().toString(36).slice(2)}`;
|
|
5615
6275
|
session.awaitingNonce = nonce;
|
|
@@ -5753,12 +6413,24 @@ export class TelegramNotificationDaemon {
|
|
|
5753
6413
|
}
|
|
5754
6414
|
|
|
5755
6415
|
private dropSession(session: SessionSocket, reason: string): void {
|
|
5756
|
-
//
|
|
6416
|
+
// Capture the exact callback lease before revoking recovery authority. A
|
|
6417
|
+
// predecessor transport can remain keyed by its own session id after a
|
|
6418
|
+
// successor becomes the logical owner, so logical-session revocation
|
|
6419
|
+
// would incorrectly delete the successor's freshly accepted aliases.
|
|
6420
|
+
const callbackLease = {
|
|
6421
|
+
session,
|
|
6422
|
+
token: session.recoveryLease?.token ?? 0,
|
|
6423
|
+
logicalSessionId: this.#logicalSessionId(session),
|
|
6424
|
+
};
|
|
6425
|
+
// A dropped socket must lose authority before any teardown-triggered asynchronous
|
|
5757
6426
|
// work can observe it. Its immutable lease token remains captured by queued work,
|
|
5758
6427
|
// but is no longer usable.
|
|
5759
6428
|
if (session.recoveryLease) session.recoveryLease = { ...session.recoveryLease, state: "rejected" };
|
|
5760
6429
|
const isCurrentSession = this.sessions.get(session.sessionId) === session;
|
|
5761
|
-
if (isCurrentSession)
|
|
6430
|
+
if (isCurrentSession) {
|
|
6431
|
+
this.revokeCallbackAliases(callbackLease);
|
|
6432
|
+
this.droppedSessions.add(session);
|
|
6433
|
+
}
|
|
5762
6434
|
if (isCurrentSession) this.cancelLegacyToolStartsForSession(session);
|
|
5763
6435
|
if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
|
|
5764
6436
|
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
@@ -5771,6 +6443,7 @@ export class TelegramNotificationDaemon {
|
|
|
5771
6443
|
} else {
|
|
5772
6444
|
void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
|
|
5773
6445
|
}
|
|
6446
|
+
if (isCurrentSession && session.logicalSessionIdTrusted) this.#releaseTopicLease(this.#logicalSessionId(session));
|
|
5774
6447
|
if (isCurrentSession || reason === "session_closed") {
|
|
5775
6448
|
this.deleteMessageRoutes(session.sessionId);
|
|
5776
6449
|
}
|
|
@@ -6094,6 +6767,82 @@ export class TelegramNotificationDaemon {
|
|
|
6094
6767
|
}
|
|
6095
6768
|
}
|
|
6096
6769
|
}
|
|
6770
|
+
#revokeCallbackAlias(alias: string): CallbackRoute | undefined {
|
|
6771
|
+
const route = this.aliasTable.get(alias);
|
|
6772
|
+
if (!route) return undefined;
|
|
6773
|
+
this.reissueBacklog.set(alias, route);
|
|
6774
|
+
this.aliasTable.delete(alias);
|
|
6775
|
+
this.#callbackAliasLeases.delete(alias);
|
|
6776
|
+
this.#callbackAliasPending.delete(alias);
|
|
6777
|
+
this.#callbackAliasTopics.delete(alias);
|
|
6778
|
+
return route;
|
|
6779
|
+
}
|
|
6780
|
+
#stageCallbackActivation(
|
|
6781
|
+
aliases: Iterable<string>,
|
|
6782
|
+
pending: { sessionId: string; actionId: string },
|
|
6783
|
+
socketLease: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
6784
|
+
topicLease: TopicAuthorityLease | undefined,
|
|
6785
|
+
): PromiseWithResolvers<boolean> {
|
|
6786
|
+
const activation = Promise.withResolvers<boolean>();
|
|
6787
|
+
const staged: StagedCallbackActivation = { pending, socketLease, topicLease, settled: activation.promise };
|
|
6788
|
+
for (const alias of aliases) this.#stagedCallbackActivations.set(alias, staged);
|
|
6789
|
+
return activation;
|
|
6790
|
+
}
|
|
6791
|
+
|
|
6792
|
+
revokeCallbackAliases(socketLease: { session: SessionSocket; token: number; logicalSessionId: string }): void {
|
|
6793
|
+
const actions = new Set<string>();
|
|
6794
|
+
for (const [alias, aliasLease] of this.#callbackAliasLeases) {
|
|
6795
|
+
if (
|
|
6796
|
+
aliasLease.session === socketLease.session &&
|
|
6797
|
+
aliasLease.token === socketLease.token &&
|
|
6798
|
+
aliasLease.logicalSessionId === socketLease.logicalSessionId
|
|
6799
|
+
) {
|
|
6800
|
+
const route = this.#revokeCallbackAlias(alias);
|
|
6801
|
+
if (route) actions.add(route.actionId);
|
|
6802
|
+
}
|
|
6803
|
+
}
|
|
6804
|
+
if (actions.size === 0) return;
|
|
6805
|
+
const terminalization = this.effects.track(
|
|
6806
|
+
(async () => {
|
|
6807
|
+
await this.persistAliases();
|
|
6808
|
+
for (const actionId of actions) await this.reissuePendingAction(socketLease.logicalSessionId, actionId);
|
|
6809
|
+
})(),
|
|
6810
|
+
);
|
|
6811
|
+
void terminalization.catch(() => undefined);
|
|
6812
|
+
}
|
|
6813
|
+
|
|
6814
|
+
private async reissuePendingAction(sessionId: string, actionId: string): Promise<void> {
|
|
6815
|
+
const oldEntries = [...this.reissueBacklog.entries()].filter(
|
|
6816
|
+
([, route]) => route.sessionId === sessionId && route.actionId === actionId,
|
|
6817
|
+
);
|
|
6818
|
+
const messageIds = new Set<number>();
|
|
6819
|
+
for (const [, route] of oldEntries) {
|
|
6820
|
+
if (String(route.chatId) === String(this.opts.chatId) && Number.isSafeInteger(route.messageId))
|
|
6821
|
+
messageIds.add(route.messageId!);
|
|
6822
|
+
}
|
|
6823
|
+
const terminalizedMessageIds = new Set<number>();
|
|
6824
|
+
for (const messageId of messageIds) {
|
|
6825
|
+
try {
|
|
6826
|
+
const terminalized = (await this.botApi.call("editMessageReplyMarkup", {
|
|
6827
|
+
chat_id: this.opts.chatId,
|
|
6828
|
+
message_id: messageId,
|
|
6829
|
+
reply_markup: { inline_keyboard: [] },
|
|
6830
|
+
})) as { ok?: unknown } | undefined;
|
|
6831
|
+
if (terminalized?.ok === true) terminalizedMessageIds.add(messageId);
|
|
6832
|
+
} catch {
|
|
6833
|
+
// Retain the durable receipt so a later replay can retry terminalization.
|
|
6834
|
+
}
|
|
6835
|
+
}
|
|
6836
|
+
for (const [alias, route] of oldEntries) {
|
|
6837
|
+
if (
|
|
6838
|
+
String(route.chatId) === String(this.opts.chatId) &&
|
|
6839
|
+
Number.isSafeInteger(route.messageId) &&
|
|
6840
|
+
terminalizedMessageIds.has(route.messageId!)
|
|
6841
|
+
)
|
|
6842
|
+
this.reissueBacklog.delete(alias);
|
|
6843
|
+
}
|
|
6844
|
+
await this.persistAliases();
|
|
6845
|
+
}
|
|
6097
6846
|
|
|
6098
6847
|
#logicalSessionId(session: SessionSocket): string {
|
|
6099
6848
|
return session.logicalSessionId ?? session.sessionId;
|
|
@@ -6123,8 +6872,10 @@ export class TelegramNotificationDaemon {
|
|
|
6123
6872
|
lease.binding.endpointDigest === session.endpointDigest &&
|
|
6124
6873
|
lease.binding.endpointGeneration === session.hostGeneration &&
|
|
6125
6874
|
(!record ||
|
|
6126
|
-
(record.authorityState
|
|
6875
|
+
(record.authorityState === "active" &&
|
|
6127
6876
|
!record.bindingMalformed &&
|
|
6877
|
+
record.leaseOwner === this.installationHostId &&
|
|
6878
|
+
(record.leaseExpiresAt ?? 0) > this.runtime.now() &&
|
|
6128
6879
|
record.chatId === lease.binding.chatId &&
|
|
6129
6880
|
record.endpointKey === lease.binding.endpointKey &&
|
|
6130
6881
|
record.endpointDigest === lease.binding.endpointDigest &&
|
|
@@ -6208,14 +6959,86 @@ export class TelegramNotificationDaemon {
|
|
|
6208
6959
|
? undefined
|
|
6209
6960
|
: { session, token: 0, logicalSessionId };
|
|
6210
6961
|
}
|
|
6962
|
+
async #renewTopicLease(sessionId: string): Promise<boolean> {
|
|
6963
|
+
const record = this.topics.get(sessionId);
|
|
6964
|
+
if (!record) return true;
|
|
6965
|
+
const previous = {
|
|
6966
|
+
leaseOwner: record.leaseOwner,
|
|
6967
|
+
leaseHeartbeatAt: record.leaseHeartbeatAt,
|
|
6968
|
+
leaseExpiresAt: record.leaseExpiresAt,
|
|
6969
|
+
authorityState: record.authorityState,
|
|
6970
|
+
orphanedAt: record.orphanedAt,
|
|
6971
|
+
disconnectGraceExpiresAt: record.disconnectGraceExpiresAt,
|
|
6972
|
+
};
|
|
6973
|
+
return this.#persistTopicMutation(
|
|
6974
|
+
() =>
|
|
6975
|
+
this.topics.acquireLease(
|
|
6976
|
+
sessionId,
|
|
6977
|
+
this.installationHostId,
|
|
6978
|
+
this.runtime.now(),
|
|
6979
|
+
HEARTBEAT_TTL_MS,
|
|
6980
|
+
ORPHAN_TOPIC_GRACE_MS,
|
|
6981
|
+
),
|
|
6982
|
+
() => {
|
|
6983
|
+
const current = this.topics.get(sessionId);
|
|
6984
|
+
if (!current) return;
|
|
6985
|
+
Object.assign(current, previous);
|
|
6986
|
+
if (previous.leaseOwner === undefined) delete current.leaseOwner;
|
|
6987
|
+
if (previous.leaseHeartbeatAt === undefined) delete current.leaseHeartbeatAt;
|
|
6988
|
+
if (previous.leaseExpiresAt === undefined) delete current.leaseExpiresAt;
|
|
6989
|
+
if (previous.orphanedAt === undefined) delete current.orphanedAt;
|
|
6990
|
+
if (previous.disconnectGraceExpiresAt === undefined) delete current.disconnectGraceExpiresAt;
|
|
6991
|
+
},
|
|
6992
|
+
);
|
|
6993
|
+
}
|
|
6994
|
+
#releaseTopicLease(sessionId: string): void {
|
|
6995
|
+
const record = this.topics.get(sessionId);
|
|
6996
|
+
if (!record) return;
|
|
6997
|
+
const previous = {
|
|
6998
|
+
leaseHeartbeatAt: record.leaseHeartbeatAt,
|
|
6999
|
+
leaseExpiresAt: record.leaseExpiresAt,
|
|
7000
|
+
authorityState: record.authorityState,
|
|
7001
|
+
orphanedAt: record.orphanedAt,
|
|
7002
|
+
disconnectGraceExpiresAt: record.disconnectGraceExpiresAt,
|
|
7003
|
+
};
|
|
7004
|
+
void this.#persistTopicMutation(
|
|
7005
|
+
() => {
|
|
7006
|
+
const owner = this.logicalSessionOwners.get(sessionId);
|
|
7007
|
+
return owner === undefined
|
|
7008
|
+
? this.topics.releaseLeaseToGrace(
|
|
7009
|
+
sessionId,
|
|
7010
|
+
this.installationHostId,
|
|
7011
|
+
this.runtime.now(),
|
|
7012
|
+
ORPHAN_TOPIC_GRACE_MS,
|
|
7013
|
+
)
|
|
7014
|
+
: false;
|
|
7015
|
+
},
|
|
7016
|
+
() => {
|
|
7017
|
+
const current = this.topics.get(sessionId);
|
|
7018
|
+
if (!current) return;
|
|
7019
|
+
Object.assign(current, previous);
|
|
7020
|
+
if (previous.leaseHeartbeatAt === undefined) delete current.leaseHeartbeatAt;
|
|
7021
|
+
if (previous.leaseExpiresAt === undefined) delete current.leaseExpiresAt;
|
|
7022
|
+
if (previous.orphanedAt === undefined) delete current.orphanedAt;
|
|
7023
|
+
if (previous.disconnectGraceExpiresAt === undefined) delete current.disconnectGraceExpiresAt;
|
|
7024
|
+
},
|
|
7025
|
+
).catch(() => undefined);
|
|
7026
|
+
}
|
|
6211
7027
|
|
|
6212
7028
|
#authorizeLease(session: SessionSocket, logicalSessionId: string, binding: TopicEndpointBinding): void {
|
|
6213
7029
|
const previousSessionId = this.#logicalSessionId(session);
|
|
6214
7030
|
if (previousSessionId !== logicalSessionId && this.logicalSessionOwners.get(previousSessionId) === session)
|
|
6215
7031
|
this.logicalSessionOwners.delete(previousSessionId);
|
|
6216
7032
|
const previousOwner = this.logicalSessionOwners.get(logicalSessionId);
|
|
6217
|
-
if (previousOwner && previousOwner !== session && previousOwner.recoveryLease)
|
|
7033
|
+
if (previousOwner && previousOwner !== session && previousOwner.recoveryLease) {
|
|
7034
|
+
const predecessorLease = {
|
|
7035
|
+
session: previousOwner,
|
|
7036
|
+
token: previousOwner.recoveryLease.token,
|
|
7037
|
+
logicalSessionId,
|
|
7038
|
+
};
|
|
6218
7039
|
previousOwner.recoveryLease = { ...previousOwner.recoveryLease, state: "rejected" };
|
|
7040
|
+
this.revokeCallbackAliases(predecessorLease);
|
|
7041
|
+
}
|
|
6219
7042
|
this.logicalSessionOwners.set(logicalSessionId, session);
|
|
6220
7043
|
this.preservedInitiatorTopics.delete(logicalSessionId);
|
|
6221
7044
|
session.logicalSessionId = logicalSessionId;
|
|
@@ -6229,8 +7052,18 @@ export class TelegramNotificationDaemon {
|
|
|
6229
7052
|
}
|
|
6230
7053
|
|
|
6231
7054
|
async #revokeAskAuthority(sessionId: string): Promise<void> {
|
|
7055
|
+
const actionIds = new Set<string>();
|
|
6232
7056
|
for (const [alias, route] of this.aliasTable.entries()) {
|
|
6233
|
-
if (route.sessionId
|
|
7057
|
+
if (route.sessionId !== sessionId) continue;
|
|
7058
|
+
this.reissueBacklog.set(alias, route);
|
|
7059
|
+
this.aliasTable.delete(alias);
|
|
7060
|
+
this.#callbackAliasLeases.delete(alias);
|
|
7061
|
+
this.#callbackAliasPending.delete(alias);
|
|
7062
|
+
this.#callbackAliasTopics.delete(alias);
|
|
7063
|
+
actionIds.add(route.actionId);
|
|
7064
|
+
}
|
|
7065
|
+
for (const route of this.reissueBacklog.values()) {
|
|
7066
|
+
if (route.sessionId === sessionId) actionIds.add(route.actionId);
|
|
6234
7067
|
}
|
|
6235
7068
|
for (const session of this.sessions.values()) {
|
|
6236
7069
|
if (session.sessionId === sessionId || this.#logicalSessionId(session) === sessionId) {
|
|
@@ -6240,6 +7073,7 @@ export class TelegramNotificationDaemon {
|
|
|
6240
7073
|
}
|
|
6241
7074
|
}
|
|
6242
7075
|
await this.persistAliases();
|
|
7076
|
+
for (const actionId of actionIds) await this.reissuePendingAction(sessionId, actionId);
|
|
6243
7077
|
}
|
|
6244
7078
|
|
|
6245
7079
|
#clearModelChoiceAliasesForSocket(session: SessionSocket): void {
|
|
@@ -6378,84 +7212,123 @@ export class TelegramNotificationDaemon {
|
|
|
6378
7212
|
const binding = this.#endpointBinding(session);
|
|
6379
7213
|
const pendingToken = this.nextSocketLeaseToken++;
|
|
6380
7214
|
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
|
-
|
|
7215
|
+
let claim: { previousSessionId: string; hadDurableTopic: boolean } | undefined;
|
|
7216
|
+
try {
|
|
7217
|
+
claim = await this.#withRecoveryBindingClaim(async () => {
|
|
7218
|
+
const existing = this.topics.get(candidateSessionId);
|
|
7219
|
+
const hadDurableTopic = existing !== undefined;
|
|
7220
|
+
const previousBinding = existing
|
|
7221
|
+
? {
|
|
7222
|
+
chatId: existing.chatId,
|
|
7223
|
+
endpointKey: existing.endpointKey,
|
|
7224
|
+
endpointDigest: existing.endpointDigest,
|
|
7225
|
+
endpointGeneration: existing.endpointGeneration,
|
|
7226
|
+
endpointIncarnation: existing.endpointIncarnation,
|
|
7227
|
+
}
|
|
7228
|
+
: undefined;
|
|
7229
|
+
let retiredTopicState: TopicRegistryState | undefined;
|
|
7230
|
+
const outcome = await this.#persistTopicMutation(
|
|
7231
|
+
() => {
|
|
7232
|
+
if (allowEndpointRotation && this.topics.get(candidateSessionId)?.authorityState === "inactive") {
|
|
7233
|
+
retiredTopicState = this.topics.serialize();
|
|
7234
|
+
this.topics.retireInactiveEndpointForSuccessor(candidateSessionId, binding);
|
|
7235
|
+
}
|
|
7236
|
+
return this.topics.get(candidateSessionId)
|
|
7237
|
+
? this.topics.bindEndpoint(
|
|
7238
|
+
candidateSessionId,
|
|
7239
|
+
binding,
|
|
7240
|
+
this.#activeEndpointKeysFor(candidateSessionId, session),
|
|
7241
|
+
allowEndpointRotation,
|
|
7242
|
+
)
|
|
7243
|
+
: "unchanged";
|
|
7244
|
+
},
|
|
7245
|
+
() => {
|
|
7246
|
+
if (retiredTopicState) this.topics.replace(retiredTopicState);
|
|
7247
|
+
else if (previousBinding)
|
|
7248
|
+
this.topics.restoreEndpointBinding(candidateSessionId, binding, previousBinding);
|
|
7249
|
+
},
|
|
7250
|
+
).catch(() => "rejected" as const);
|
|
7251
|
+
if (outcome === "rejected") {
|
|
7252
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
7253
|
+
session.recoveryLease = {
|
|
7254
|
+
state: "rejected",
|
|
7255
|
+
logicalSessionId: candidateSessionId,
|
|
7256
|
+
binding,
|
|
7257
|
+
token: pendingToken,
|
|
7258
|
+
};
|
|
7259
|
+
return undefined;
|
|
7260
|
+
}
|
|
7261
|
+
if (!(await this.#renewTopicLease(candidateSessionId))) {
|
|
7262
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
7263
|
+
session.recoveryLease = {
|
|
7264
|
+
state: "rejected",
|
|
7265
|
+
logicalSessionId: candidateSessionId,
|
|
7266
|
+
binding,
|
|
7267
|
+
token: pendingToken,
|
|
7268
|
+
};
|
|
7269
|
+
return undefined;
|
|
7270
|
+
}
|
|
7271
|
+
const endpointAuthority = this.#endpointAuthority(binding, session);
|
|
7272
|
+
const identitylessAdmissionAllows =
|
|
7273
|
+
identitylessAdmission === undefined ||
|
|
7274
|
+
(identitylessAdmission === "bootstrap"
|
|
7275
|
+
? (endpointAuthority.state === "none" && !this.topics.get(candidateSessionId)) ||
|
|
7276
|
+
(endpointAuthority.state === "unique" &&
|
|
7277
|
+
endpointAuthority.sessionId === candidateSessionId &&
|
|
7278
|
+
this.topics.matchesEndpoint(candidateSessionId, binding))
|
|
7279
|
+
: endpointAuthority.state === "unique" &&
|
|
6423
7280
|
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
|
-
|
|
7281
|
+
this.topics.matchesEndpoint(candidateSessionId, binding));
|
|
7282
|
+
if (
|
|
7283
|
+
session.recoveryLease?.token !== pendingToken ||
|
|
7284
|
+
session.recoveryLease.state !== "pending" ||
|
|
7285
|
+
!this.#ownsLiveOpenEndpoint(session, binding) ||
|
|
7286
|
+
!identitylessAdmissionAllows
|
|
7287
|
+
) {
|
|
7288
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
7289
|
+
session.recoveryLease = {
|
|
7290
|
+
state: "rejected",
|
|
7291
|
+
logicalSessionId: candidateSessionId,
|
|
7292
|
+
binding,
|
|
7293
|
+
token: pendingToken,
|
|
7294
|
+
};
|
|
7295
|
+
return undefined;
|
|
7296
|
+
}
|
|
7297
|
+
const previousSessionId = this.#logicalSessionId(session);
|
|
7298
|
+
if (previousSessionId !== candidateSessionId) await this.#terminalizeBtwTurnsForSession(session, true);
|
|
7299
|
+
if (!this.#ownsLiveOpenEndpoint(session, binding)) {
|
|
7300
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
7301
|
+
session.recoveryLease = {
|
|
7302
|
+
state: "rejected",
|
|
7303
|
+
logicalSessionId: candidateSessionId,
|
|
7304
|
+
binding,
|
|
7305
|
+
token: pendingToken,
|
|
7306
|
+
};
|
|
7307
|
+
return undefined;
|
|
7308
|
+
}
|
|
7309
|
+
this.#authorizeLease(session, candidateSessionId, binding);
|
|
7310
|
+
return { previousSessionId, hadDurableTopic };
|
|
7311
|
+
});
|
|
7312
|
+
} catch {
|
|
7313
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
7314
|
+
session.recoveryLease = {
|
|
7315
|
+
state: "rejected",
|
|
7316
|
+
logicalSessionId: candidateSessionId,
|
|
7317
|
+
binding,
|
|
7318
|
+
token: pendingToken,
|
|
7319
|
+
};
|
|
7320
|
+
return false;
|
|
7321
|
+
}
|
|
7322
|
+
if (!claim) {
|
|
7323
|
+
if (session.recoveryLease?.token === pendingToken)
|
|
7324
|
+
session.recoveryLease = {
|
|
7325
|
+
state: "rejected",
|
|
7326
|
+
logicalSessionId: candidateSessionId,
|
|
7327
|
+
binding,
|
|
7328
|
+
token: pendingToken,
|
|
7329
|
+
};
|
|
7330
|
+
return false;
|
|
7331
|
+
}
|
|
6459
7332
|
const { previousSessionId } = claim;
|
|
6460
7333
|
if (preserveTransportTopic && previousSessionId !== candidateSessionId) {
|
|
6461
7334
|
this.legacyTopicOwners.set(previousSessionId, session);
|
|
@@ -6464,7 +7337,7 @@ export class TelegramNotificationDaemon {
|
|
|
6464
7337
|
if (candidateSessionId === session.sessionId)
|
|
6465
7338
|
void (async () => {
|
|
6466
7339
|
const topic = this.topics.get(candidateSessionId);
|
|
6467
|
-
if (
|
|
7340
|
+
if (topic?.authorityState !== "active" || topic.bindingMalformed) return;
|
|
6468
7341
|
const topicLease = this.topicAuthorityLeaseFromRegistry(candidateSessionId);
|
|
6469
7342
|
if (topicLease?.topicId === topic.topicId)
|
|
6470
7343
|
await this.flushPendingThreadedFrames(candidateSessionId, topicLease);
|
|
@@ -6539,12 +7412,12 @@ export class TelegramNotificationDaemon {
|
|
|
6539
7412
|
const identityKey = this.topicIdentityKey(msg);
|
|
6540
7413
|
const remembered = identityKey ? this.topicOwnerByIdentity.get(identityKey) : undefined;
|
|
6541
7414
|
const rememberedTopic = remembered ? this.topics.get(remembered) : undefined;
|
|
6542
|
-
if (remembered && rememberedTopic && rememberedTopic.authorityState
|
|
7415
|
+
if (remembered && rememberedTopic && rememberedTopic.authorityState === "active") return remembered;
|
|
6543
7416
|
if (!identityKey) return undefined;
|
|
6544
7417
|
const base = this.topicIdentityBase(msg);
|
|
6545
7418
|
for (const sessionId of this.topics.sessionIds()) {
|
|
6546
7419
|
const topic = this.topics.get(sessionId);
|
|
6547
|
-
if (topic?.authorityState
|
|
7420
|
+
if (topic?.authorityState !== "active") continue;
|
|
6548
7421
|
const nameMatchesLegacyIdentity =
|
|
6549
7422
|
base !== undefined && (topic?.name === base || topic?.name?.startsWith(`${base} - `));
|
|
6550
7423
|
if (topic?.identityKey === identityKey || nameMatchesLegacyIdentity) {
|
|
@@ -6823,21 +7696,29 @@ export class TelegramNotificationDaemon {
|
|
|
6823
7696
|
}
|
|
6824
7697
|
|
|
6825
7698
|
private async topicAuthorityLease(sessionId: string): Promise<TopicAuthorityLease | undefined> {
|
|
6826
|
-
if (!(await this.
|
|
7699
|
+
if (!(await this.pairedChatAllowsTopics())) return undefined;
|
|
6827
7700
|
return this.topicAuthorityLeaseFromRegistry(sessionId);
|
|
6828
7701
|
}
|
|
6829
7702
|
|
|
6830
7703
|
private topicAuthorityLeaseFromRegistry(sessionId: string): TopicAuthorityLease | undefined {
|
|
6831
7704
|
const topic = this.topics.get(sessionId);
|
|
6832
|
-
if (
|
|
7705
|
+
if (
|
|
7706
|
+
!topic ||
|
|
7707
|
+
!this.topics.isActiveUnambiguous(sessionId) ||
|
|
7708
|
+
topic.bindingMalformed ||
|
|
7709
|
+
topic.leaseOwner !== this.installationHostId ||
|
|
7710
|
+
(topic.leaseExpiresAt ?? 0) <= this.runtime.now()
|
|
7711
|
+
)
|
|
7712
|
+
return undefined;
|
|
6833
7713
|
return { sessionId, topicId: topic.topicId, authorityEpoch: topic.authorityEpoch ?? 0 };
|
|
6834
7714
|
}
|
|
6835
|
-
|
|
6836
7715
|
private topicLeaseIsCurrent(lease: TopicAuthorityLease): boolean {
|
|
6837
7716
|
const topic = this.topics.get(lease.sessionId);
|
|
6838
7717
|
return (
|
|
6839
7718
|
this.topics.isActiveUnambiguous(lease.sessionId) &&
|
|
6840
7719
|
!topic?.bindingMalformed &&
|
|
7720
|
+
topic?.leaseOwner === this.installationHostId &&
|
|
7721
|
+
(topic.leaseExpiresAt ?? 0) > this.runtime.now() &&
|
|
6841
7722
|
topic?.topicId === lease.topicId &&
|
|
6842
7723
|
(topic.authorityEpoch ?? 0) === lease.authorityEpoch
|
|
6843
7724
|
);
|
|
@@ -6884,7 +7765,7 @@ export class TelegramNotificationDaemon {
|
|
|
6884
7765
|
): void {
|
|
6885
7766
|
const logicalSessionId = this.#logicalSessionId(session);
|
|
6886
7767
|
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
6887
|
-
if (!socketLease) {
|
|
7768
|
+
if (!socketLease && !session.logicalSessionIdTrusted) {
|
|
6888
7769
|
this.failLegacyToolStart(toolActivity);
|
|
6889
7770
|
return;
|
|
6890
7771
|
}
|
|
@@ -6898,6 +7779,7 @@ export class TelegramNotificationDaemon {
|
|
|
6898
7779
|
send,
|
|
6899
7780
|
msg,
|
|
6900
7781
|
logicalSessionId,
|
|
7782
|
+
session,
|
|
6901
7783
|
socketLease,
|
|
6902
7784
|
...(toolActivity ? { toolActivity } : {}),
|
|
6903
7785
|
});
|
|
@@ -6913,15 +7795,17 @@ export class TelegramNotificationDaemon {
|
|
|
6913
7795
|
if (!frames || frames.length === 0) return;
|
|
6914
7796
|
this.pendingThreadedFrames.delete(sessionId);
|
|
6915
7797
|
for (const frame of frames) {
|
|
7798
|
+
const socketLease = frame.socketLease ?? this.#socketLease(frame.session, sessionId);
|
|
6916
7799
|
if (
|
|
6917
7800
|
frame.logicalSessionId !== sessionId ||
|
|
6918
|
-
!
|
|
7801
|
+
!socketLease ||
|
|
7802
|
+
!this.#leaseTokenAllows(socketLease) ||
|
|
6919
7803
|
(frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled !== true)
|
|
6920
7804
|
) {
|
|
6921
7805
|
this.failLegacyToolStart(frame.toolActivity);
|
|
6922
7806
|
continue;
|
|
6923
7807
|
}
|
|
6924
|
-
await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity,
|
|
7808
|
+
await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, socketLease);
|
|
6925
7809
|
}
|
|
6926
7810
|
}
|
|
6927
7811
|
|
|
@@ -6937,15 +7821,16 @@ export class TelegramNotificationDaemon {
|
|
|
6937
7821
|
session?: SessionSocket,
|
|
6938
7822
|
creationLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
6939
7823
|
): Promise<string | undefined> {
|
|
6940
|
-
if (!(await this.
|
|
7824
|
+
if (!(await this.pairedChatAllowsTopics())) return undefined;
|
|
6941
7825
|
if (session && sessionId === session.sessionId && this.#logicalSessionId(session) !== sessionId) return undefined;
|
|
6942
7826
|
const capturedCreationLease = creationLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
|
|
6943
7827
|
if (session?.logicalSessionIdTrusted && !capturedCreationLease) return undefined;
|
|
6944
7828
|
const creationEndpointKey = session?.endpointDigest ?? session?.endpointKey ?? "unbound";
|
|
6945
|
-
|
|
7829
|
+
const malformedCreateEndpoint = this.#malformedTopicCreateEndpoints.get(sessionId);
|
|
7830
|
+
if (malformedCreateEndpoint === "unbound" || malformedCreateEndpoint === creationEndpointKey)
|
|
6946
7831
|
throw new Error("createForumTopic: invalid message_thread_id");
|
|
6947
7832
|
const existing = this.topics.get(sessionId);
|
|
6948
|
-
if (existing
|
|
7833
|
+
if (existing && (existing.authorityState !== "active" || existing.bindingMalformed)) return undefined;
|
|
6949
7834
|
if (existing) return existing.topicId;
|
|
6950
7835
|
if (
|
|
6951
7836
|
session &&
|
|
@@ -6958,9 +7843,7 @@ export class TelegramNotificationDaemon {
|
|
|
6958
7843
|
const creationLeaseEpoch = this.topics.authorityEpoch(sessionId);
|
|
6959
7844
|
let acceptedTopicId: string | undefined;
|
|
6960
7845
|
let acceptedTopicCompensated = false;
|
|
6961
|
-
let
|
|
6962
|
-
let creationSuppressed = false;
|
|
6963
|
-
/** User-created topicId adopted in this callback, if any (for sidecar reconciliation). */
|
|
7846
|
+
let acceptedTopicArchiveAttempted = false;
|
|
6964
7847
|
let adoptedTopicId: number | undefined;
|
|
6965
7848
|
const adoptionIntentCandidate = this.#adoptionIntents.bySession(sessionId);
|
|
6966
7849
|
try {
|
|
@@ -6999,10 +7882,10 @@ export class TelegramNotificationDaemon {
|
|
|
6999
7882
|
const res = (await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name })) as
|
|
7000
7883
|
| { result?: { message_thread_id?: unknown } }
|
|
7001
7884
|
| undefined;
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7885
|
+
// Classification must travel with the rejection, not through
|
|
7886
|
+
// closure flags: `getOrCreateTopic` shares one in-flight create
|
|
7887
|
+
// promise, so every other awaiter observes only the error.
|
|
7888
|
+
if (res === undefined) throw new TopicCreationSuppressed();
|
|
7006
7889
|
if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
|
|
7007
7890
|
const response = res as {
|
|
7008
7891
|
ok?: unknown;
|
|
@@ -7010,57 +7893,52 @@ export class TelegramNotificationDaemon {
|
|
|
7010
7893
|
};
|
|
7011
7894
|
const tid = response.result?.message_thread_id;
|
|
7012
7895
|
if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0) {
|
|
7013
|
-
if (response.ok ===
|
|
7896
|
+
if (response.ok === false) throw new TopicCreationRejected();
|
|
7897
|
+
this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey);
|
|
7014
7898
|
throw new Error("createForumTopic: invalid message_thread_id");
|
|
7015
7899
|
}
|
|
7016
7900
|
acceptedTopicId = String(tid);
|
|
7017
7901
|
this.#malformedTopicCreateEndpoints.delete(sessionId);
|
|
7018
7902
|
if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
|
|
7019
|
-
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7903
|
+
if (
|
|
7904
|
+
!this.topics.fenceAcceptedCreateForLease(
|
|
7905
|
+
sessionId,
|
|
7906
|
+
acceptedTopicId,
|
|
7907
|
+
creationLeaseEpoch,
|
|
7908
|
+
this.installationHostId,
|
|
7909
|
+
this.opts.now,
|
|
7910
|
+
name,
|
|
7911
|
+
creationBinding,
|
|
7912
|
+
undefined,
|
|
7913
|
+
String(this.opts.chatId),
|
|
7914
|
+
)
|
|
7915
|
+
)
|
|
7916
|
+
throw new Error("topic authority was revoked during creation");
|
|
7031
7917
|
try {
|
|
7032
7918
|
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);
|
|
7919
|
+
} catch (error) {
|
|
7920
|
+
this.topics.scheduleArchiveRetry(
|
|
7921
|
+
sessionId,
|
|
7922
|
+
this.runtime.now(),
|
|
7923
|
+
"archive fence persistence failed",
|
|
7924
|
+
);
|
|
7925
|
+
try {
|
|
7926
|
+
await this.#persistTopicsWithRetry();
|
|
7927
|
+
} catch {
|
|
7928
|
+
await this.#superviseCompensationFence(sessionId);
|
|
7063
7929
|
}
|
|
7930
|
+
throw error;
|
|
7931
|
+
}
|
|
7932
|
+
if (
|
|
7933
|
+
this.#acceptedCreateArchiveFenceAllows(
|
|
7934
|
+
sessionId,
|
|
7935
|
+
acceptedTopicId,
|
|
7936
|
+
creationLeaseEpoch,
|
|
7937
|
+
creationBinding,
|
|
7938
|
+
)
|
|
7939
|
+
) {
|
|
7940
|
+
acceptedTopicArchiveAttempted = true;
|
|
7941
|
+
acceptedTopicCompensated = (await this.archiveTopic(sessionId, undefined, true)) === "settled";
|
|
7064
7942
|
}
|
|
7065
7943
|
throw new Error("topic authority was revoked during creation");
|
|
7066
7944
|
}
|
|
@@ -7073,9 +7951,8 @@ export class TelegramNotificationDaemon {
|
|
|
7073
7951
|
session,
|
|
7074
7952
|
adoptionIntentCandidate ? "user_created" : undefined,
|
|
7075
7953
|
);
|
|
7076
|
-
// Adoption commit success
|
|
7077
|
-
//
|
|
7078
|
-
// stale sidecar is left for startup reconciliation to remove safely.
|
|
7954
|
+
// Adoption commit success removes only the sidecar. The committed
|
|
7955
|
+
// user-created topic remains retained even when sidecar cleanup fails.
|
|
7079
7956
|
if (adoptedTopicId !== undefined) {
|
|
7080
7957
|
if (adoptionIntentCandidate)
|
|
7081
7958
|
logger.info(
|
|
@@ -7090,6 +7967,10 @@ export class TelegramNotificationDaemon {
|
|
|
7090
7967
|
);
|
|
7091
7968
|
}
|
|
7092
7969
|
}
|
|
7970
|
+
// Publish the durable host lease before rechecking the captured socket
|
|
7971
|
+
// lease. The recheck consults the registry once a record exists, so the
|
|
7972
|
+
// newly-created record must already authorize this host.
|
|
7973
|
+
if (!(await this.#renewTopicLease(sessionId))) return undefined;
|
|
7093
7974
|
// getOrCreateTopic deduplicates callers, so an accepted create can be
|
|
7094
7975
|
// observed by a successor after its initiating socket was revoked. Check
|
|
7095
7976
|
// the immutable lease again before exposing that record to frame delivery.
|
|
@@ -7111,78 +7992,105 @@ export class TelegramNotificationDaemon {
|
|
|
7111
7992
|
sessionId,
|
|
7112
7993
|
rec.topicId,
|
|
7113
7994
|
creationLeaseEpoch,
|
|
7995
|
+
this.installationHostId,
|
|
7114
7996
|
this.opts.now,
|
|
7115
7997
|
name,
|
|
7116
7998
|
creationBinding,
|
|
7999
|
+
undefined,
|
|
8000
|
+
String(this.opts.chatId),
|
|
7117
8001
|
)
|
|
7118
|
-
)
|
|
7119
|
-
|
|
8002
|
+
) {
|
|
8003
|
+
try {
|
|
8004
|
+
await this.persistTopics();
|
|
8005
|
+
} catch {
|
|
8006
|
+
this.#superviseCompensationFence(sessionId);
|
|
8007
|
+
return undefined;
|
|
8008
|
+
}
|
|
8009
|
+
if (this.#acceptedCreateArchiveFenceAllows(sessionId, rec.topicId, creationLeaseEpoch, creationBinding))
|
|
8010
|
+
await this.archiveTopic(sessionId, undefined, true);
|
|
8011
|
+
}
|
|
7120
8012
|
return undefined;
|
|
7121
8013
|
}
|
|
7122
8014
|
return rec.topicId;
|
|
7123
8015
|
} catch (err) {
|
|
7124
|
-
if (
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
|
|
8016
|
+
if (adoptedTopicId !== undefined) this.#adoptionIntents.releaseClaim(adoptedTopicId, sessionId);
|
|
8017
|
+
if (adoptionIntentCandidate) this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch);
|
|
8018
|
+
if (
|
|
8019
|
+
err instanceof TopicCreationSuppressed ||
|
|
8020
|
+
err instanceof TopicCreationRejected ||
|
|
8021
|
+
err instanceof ThreadedModeCapabilityRefusal
|
|
8022
|
+
) {
|
|
8023
|
+
if (err instanceof ThreadedModeCapabilityRefusal) this.topicCapabilityRefused = true;
|
|
8024
|
+
if (this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch)) await this.persistTopics();
|
|
8025
|
+
return undefined;
|
|
8026
|
+
}
|
|
8027
|
+
const revokedAcceptedRecord = acceptedTopicId ? this.topics.get(sessionId) : undefined;
|
|
8028
|
+
if (
|
|
8029
|
+
revokedAcceptedRecord !== undefined &&
|
|
8030
|
+
revokedAcceptedRecord.topicId === acceptedTopicId &&
|
|
8031
|
+
revokedAcceptedRecord.authorityState === "archive_pending" &&
|
|
8032
|
+
!this.topics.archiveAuthorityAllows(
|
|
8033
|
+
sessionId,
|
|
8034
|
+
this.installationHostId,
|
|
8035
|
+
this.opts.chatId,
|
|
8036
|
+
this.runtime.now(),
|
|
8037
|
+
)
|
|
8038
|
+
) {
|
|
8039
|
+
if (
|
|
8040
|
+
revokedAcceptedRecord.chatId === undefined &&
|
|
8041
|
+
revokedAcceptedRecord.endpointKey === undefined &&
|
|
8042
|
+
revokedAcceptedRecord.endpointDigest === undefined
|
|
8043
|
+
)
|
|
8044
|
+
revokedAcceptedRecord.chatId = String(this.opts.chatId);
|
|
8045
|
+
this.topics.beginArchive(sessionId, this.installationHostId, this.runtime.now());
|
|
8046
|
+
await this.persistTopics();
|
|
7129
8047
|
}
|
|
7130
8048
|
if (
|
|
7131
8049
|
acceptedTopicId &&
|
|
7132
8050
|
!acceptedTopicCompensated &&
|
|
7133
|
-
!
|
|
7134
|
-
this.topics.get(sessionId)?.authorityState !== "
|
|
8051
|
+
!acceptedTopicArchiveAttempted &&
|
|
8052
|
+
this.topics.get(sessionId)?.authorityState !== "archive_pending"
|
|
7135
8053
|
) {
|
|
7136
8054
|
// A failed initial commit must never make compensation conditional on
|
|
7137
8055
|
// successfully publishing its fence.
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
8056
|
+
if (
|
|
8057
|
+
this.topics.fenceAcceptedCreateForLease(
|
|
8058
|
+
sessionId,
|
|
8059
|
+
acceptedTopicId,
|
|
8060
|
+
creationLeaseEpoch,
|
|
8061
|
+
this.installationHostId,
|
|
8062
|
+
this.opts.now,
|
|
8063
|
+
name,
|
|
8064
|
+
creationBinding,
|
|
8065
|
+
undefined,
|
|
8066
|
+
String(this.opts.chatId),
|
|
8067
|
+
)
|
|
8068
|
+
) {
|
|
7150
8069
|
try {
|
|
7151
8070
|
await this.#persistTopicsWithRetry();
|
|
7152
8071
|
} catch {
|
|
7153
|
-
this.#superviseCompensationFence(sessionId);
|
|
8072
|
+
await this.#superviseCompensationFence(sessionId);
|
|
7154
8073
|
}
|
|
7155
8074
|
|
|
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 {
|
|
8075
|
+
if (
|
|
8076
|
+
this.#acceptedCreateArchiveFenceAllows(
|
|
8077
|
+
sessionId,
|
|
8078
|
+
acceptedTopicId,
|
|
8079
|
+
creationLeaseEpoch,
|
|
8080
|
+
creationBinding,
|
|
8081
|
+
)
|
|
8082
|
+
) {
|
|
8083
|
+
try {
|
|
8084
|
+
acceptedTopicArchiveAttempted = true;
|
|
8085
|
+
acceptedTopicCompensated = (await this.archiveTopic(sessionId, undefined, true)) === "settled";
|
|
8086
|
+
} catch {
|
|
7176
8087
|
this.#superviseCompensationFence(sessionId);
|
|
7177
8088
|
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7178
8089
|
}
|
|
7179
|
-
} catch {
|
|
7180
|
-
this.#superviseCompensationFence(sessionId);
|
|
7181
|
-
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7182
8090
|
}
|
|
7183
8091
|
}
|
|
7184
8092
|
}
|
|
7185
|
-
if (acceptedTopicId && !acceptedTopicCompensated &&
|
|
8093
|
+
if (acceptedTopicId && !acceptedTopicCompensated && acceptedTopicArchiveAttempted) {
|
|
7186
8094
|
this.#superviseCompensationFence(sessionId);
|
|
7187
8095
|
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7188
8096
|
}
|
|
@@ -7211,24 +8119,75 @@ export class TelegramNotificationDaemon {
|
|
|
7211
8119
|
if (!this.topicPastOrphanGrace(sessionId)) return;
|
|
7212
8120
|
const currentOwner = this.logicalSessionOwners.get(sessionId);
|
|
7213
8121
|
if (currentOwner && this.#leaseAllows(currentOwner, sessionId)) return;
|
|
7214
|
-
await this.
|
|
8122
|
+
await this.archiveTopic(sessionId);
|
|
7215
8123
|
});
|
|
7216
8124
|
}
|
|
7217
8125
|
|
|
7218
8126
|
/** Best-effort delete of a session topic once its local notification endpoint shuts down. */
|
|
7219
|
-
|
|
8127
|
+
/** Join all close, compensation, orphan, and restart callers for one session. */
|
|
8128
|
+
private archiveTopic(
|
|
8129
|
+
sessionId: string,
|
|
8130
|
+
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
8131
|
+
archiveFenceAlreadyPublished = false,
|
|
8132
|
+
): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> {
|
|
8133
|
+
const active = this.archiveFlights.get(sessionId);
|
|
8134
|
+
if (active) return active;
|
|
8135
|
+
const flight = this.#archiveTopicOnce(sessionId, socketLease, archiveFenceAlreadyPublished);
|
|
8136
|
+
this.archiveFlights.set(sessionId, flight);
|
|
8137
|
+
void flight.then(
|
|
8138
|
+
() => {
|
|
8139
|
+
if (this.archiveFlights.get(sessionId) === flight) this.archiveFlights.delete(sessionId);
|
|
8140
|
+
},
|
|
8141
|
+
() => {
|
|
8142
|
+
if (this.archiveFlights.get(sessionId) === flight) this.archiveFlights.delete(sessionId);
|
|
8143
|
+
},
|
|
8144
|
+
);
|
|
8145
|
+
return flight;
|
|
8146
|
+
}
|
|
8147
|
+
|
|
8148
|
+
/** One durable archive fence and at most one remote close dispatch per flight. */
|
|
8149
|
+
async #archiveTopicOnce(
|
|
7220
8150
|
sessionId: string,
|
|
7221
8151
|
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
7222
|
-
|
|
8152
|
+
archiveFenceAlreadyPublished = false,
|
|
7223
8153
|
): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> {
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
//
|
|
7227
|
-
//
|
|
7228
|
-
|
|
8154
|
+
if (!(await this.pairedChatAllowsTopics())) return "pre_dispatch_cancelled";
|
|
8155
|
+
const existing = this.topics.get(sessionId);
|
|
8156
|
+
// A completed archive retains an inactive record as historical evidence.
|
|
8157
|
+
// Re-archiving it would duplicate the irreversible remote close request.
|
|
8158
|
+
if (existing?.authorityState === "inactive") return "settled";
|
|
8159
|
+
if (existing?.topicOrigin === "user_created") {
|
|
8160
|
+
if (existing.authorityState === "archive_pending") {
|
|
8161
|
+
const archiveSnapshot = this.topics.captureArchiveAuthority(sessionId);
|
|
8162
|
+
const authorityEpoch = existing.authorityEpoch ?? Number.MAX_SAFE_INTEGER;
|
|
8163
|
+
if (!this.topics.settleArchive(sessionId, existing.topicId, authorityEpoch)) return "post_dispatch_pending";
|
|
8164
|
+
try {
|
|
8165
|
+
await this.persistTopics();
|
|
8166
|
+
} catch {
|
|
8167
|
+
this.topics.restoreArchiveFence(archiveSnapshot);
|
|
8168
|
+
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
8169
|
+
return "post_dispatch_pending";
|
|
8170
|
+
}
|
|
8171
|
+
}
|
|
8172
|
+
return "settled";
|
|
8173
|
+
}
|
|
8174
|
+
const archiveSnapshot = this.topics.captureArchiveAuthority(sessionId);
|
|
8175
|
+
let record = archiveFenceAlreadyPublished
|
|
8176
|
+
? existing
|
|
8177
|
+
: this.topics.beginArchive(sessionId, this.installationHostId, this.runtime.now());
|
|
7229
8178
|
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
7230
|
-
|
|
8179
|
+
if (
|
|
8180
|
+
record &&
|
|
8181
|
+
!this.topics.archiveAuthorityAllows(sessionId, this.installationHostId, this.opts.chatId, this.runtime.now())
|
|
8182
|
+
)
|
|
8183
|
+
return "pre_dispatch_cancelled";
|
|
8184
|
+
if (!archiveFenceAlreadyPublished) await this.persistTopics();
|
|
7231
8185
|
if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
|
|
8186
|
+
if (
|
|
8187
|
+
record &&
|
|
8188
|
+
!this.topics.archiveAuthorityAllows(sessionId, this.installationHostId, this.opts.chatId, this.runtime.now())
|
|
8189
|
+
)
|
|
8190
|
+
return "pre_dispatch_cancelled";
|
|
7232
8191
|
await this.#revokeAskAuthority(sessionId);
|
|
7233
8192
|
this.deleteMessageRoutes(sessionId);
|
|
7234
8193
|
this.#clearModelChoiceAliases(sessionId);
|
|
@@ -7237,7 +8196,17 @@ export class TelegramNotificationDaemon {
|
|
|
7237
8196
|
record = this.topics.get(sessionId);
|
|
7238
8197
|
await this.persistTopics();
|
|
7239
8198
|
if (!record) return "settled";
|
|
8199
|
+
if (
|
|
8200
|
+
!this.topics.archiveAuthorityAllows(
|
|
8201
|
+
sessionId,
|
|
8202
|
+
this.installationHostId,
|
|
8203
|
+
this.opts.chatId,
|
|
8204
|
+
this.runtime.now(),
|
|
8205
|
+
)
|
|
8206
|
+
)
|
|
8207
|
+
return "pre_dispatch_cancelled";
|
|
7240
8208
|
}
|
|
8209
|
+
const dispatchedAuthorityEpoch = record.authorityEpoch ?? Number.MAX_SAFE_INTEGER;
|
|
7241
8210
|
const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
|
|
7242
8211
|
for (const item of removed) {
|
|
7243
8212
|
if (item.payload.selectedAck)
|
|
@@ -7247,43 +8216,26 @@ export class TelegramNotificationDaemon {
|
|
|
7247
8216
|
try {
|
|
7248
8217
|
await this.flushPool();
|
|
7249
8218
|
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", {
|
|
8219
|
+
if (
|
|
8220
|
+
!this.topics.archiveAuthorityAllows(
|
|
8221
|
+
sessionId,
|
|
8222
|
+
this.installationHostId,
|
|
8223
|
+
this.opts.chatId,
|
|
8224
|
+
this.runtime.now(),
|
|
8225
|
+
)
|
|
8226
|
+
)
|
|
8227
|
+
return "pre_dispatch_cancelled";
|
|
8228
|
+
const res = (await this.botApi.call("closeForumTopic", {
|
|
7278
8229
|
chat_id: this.opts.chatId,
|
|
7279
8230
|
message_thread_id: Number(record.topicId),
|
|
7280
8231
|
})) as { ok?: boolean };
|
|
7281
|
-
if (!
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
8232
|
+
if (!topicArchiveSettled(res)) {
|
|
8233
|
+
this.topics.scheduleArchiveRetry(sessionId, this.runtime.now(), "archive result was not definitive");
|
|
8234
|
+
await this.persistTopics();
|
|
7285
8235
|
return "post_dispatch_pending";
|
|
7286
8236
|
}
|
|
8237
|
+
if (!this.topics.settleArchive(sessionId, record.topicId, dispatchedAuthorityEpoch))
|
|
8238
|
+
return "post_dispatch_pending";
|
|
7287
8239
|
for (const k of [...this.liveMessages.keys()])
|
|
7288
8240
|
if (k.startsWith(`${sessionId}:`)) {
|
|
7289
8241
|
this.liveMessages.delete(k);
|
|
@@ -7295,32 +8247,89 @@ export class TelegramNotificationDaemon {
|
|
|
7295
8247
|
this.pendingThreadedFrames.delete(sessionId);
|
|
7296
8248
|
try {
|
|
7297
8249
|
await this.persistTopics();
|
|
7298
|
-
this.topics.commitSettledDelete(settled);
|
|
7299
8250
|
return "settled";
|
|
7300
8251
|
} catch {
|
|
7301
|
-
this.topics.
|
|
8252
|
+
this.topics.restoreArchiveFence(archiveSnapshot);
|
|
7302
8253
|
await this.#persistTopicsWithRetry().catch(() => undefined);
|
|
7303
8254
|
return "post_dispatch_pending";
|
|
7304
8255
|
}
|
|
7305
8256
|
} catch {
|
|
7306
|
-
// Once Telegram dispatch starts, retain the persisted
|
|
8257
|
+
// Once Telegram dispatch starts, retain the persisted archive fence: the
|
|
7307
8258
|
// remote result is ambiguous and may not restore stale routing authority.
|
|
8259
|
+
this.topics.scheduleArchiveRetry(sessionId, this.runtime.now(), "archive transport failed");
|
|
8260
|
+
await this.persistTopics().catch(() => undefined);
|
|
7308
8261
|
return "post_dispatch_pending";
|
|
7309
8262
|
}
|
|
7310
8263
|
}
|
|
7311
8264
|
|
|
7312
8265
|
/** Serialize a mutation and its durable snapshot so rollback precedes later writers. */
|
|
7313
8266
|
#persistTopicMutation<T>(mutation: () => T, rollback: () => void): Promise<T> {
|
|
8267
|
+
if (this.validationMode()) return Promise.resolve().then(mutation);
|
|
7314
8268
|
const pending = this.topicsPersistQueue.then(async () => {
|
|
7315
|
-
|
|
8269
|
+
let result = mutation();
|
|
8270
|
+
const authority = this.opts.topicRegistryAuthority;
|
|
8271
|
+
let sharedCommitted = false;
|
|
8272
|
+
let latestWinner: TopicRegistryState | undefined;
|
|
7316
8273
|
try {
|
|
7317
8274
|
const snapshot = this.#topicStateForPersistence();
|
|
7318
|
-
|
|
7319
|
-
|
|
7320
|
-
|
|
8275
|
+
if (authority) {
|
|
8276
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
8277
|
+
const expectedGeneration = this.topics.registryVersion();
|
|
8278
|
+
const snapshot = this.#topicStateForPersistence();
|
|
8279
|
+
const nextGeneration = expectedGeneration + 1;
|
|
8280
|
+
snapshot.registryGeneration = nextGeneration;
|
|
8281
|
+
let accepted = false;
|
|
8282
|
+
try {
|
|
8283
|
+
accepted = await authority.compareAndSet(expectedGeneration, snapshot);
|
|
8284
|
+
} catch {
|
|
8285
|
+
throw new Error("shared topic authority unavailable");
|
|
8286
|
+
}
|
|
8287
|
+
if (accepted) {
|
|
8288
|
+
this.topics.markRegistryPublished(nextGeneration);
|
|
8289
|
+
sharedCommitted = true;
|
|
8290
|
+
break;
|
|
8291
|
+
}
|
|
8292
|
+
const winner = parseTopicRegistryState(await authority.read().catch(() => undefined));
|
|
8293
|
+
if (!winner) throw new Error("shared topic authority conflict");
|
|
8294
|
+
latestWinner = winner;
|
|
8295
|
+
this.#replaceTopicAuthority(winner);
|
|
8296
|
+
if (attempt === 2) throw new Error("shared topic authority conflict");
|
|
8297
|
+
result = mutation();
|
|
8298
|
+
}
|
|
8299
|
+
}
|
|
8300
|
+
if (!authority) {
|
|
8301
|
+
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
8302
|
+
await ensureDir(this.fsImpl, paths.dir);
|
|
8303
|
+
const expectedDestination = await captureTopicRegistryDestination(
|
|
8304
|
+
this.fsImpl,
|
|
8305
|
+
path.join(paths.dir, "telegram-topics.json"),
|
|
8306
|
+
process.platform,
|
|
8307
|
+
);
|
|
8308
|
+
await writeTopicRegistryAtomic(
|
|
8309
|
+
this.fsImpl,
|
|
8310
|
+
path.join(paths.dir, "telegram-topics.json"),
|
|
8311
|
+
snapshot,
|
|
8312
|
+
process.platform,
|
|
8313
|
+
exactReplacePath,
|
|
8314
|
+
expectedDestination,
|
|
8315
|
+
);
|
|
8316
|
+
}
|
|
7321
8317
|
return result;
|
|
7322
8318
|
} catch (error) {
|
|
7323
|
-
|
|
8319
|
+
if (sharedCommitted && authority) {
|
|
8320
|
+
const committed = await authority.read().catch(() => undefined);
|
|
8321
|
+
const state = parseTopicRegistryState(committed);
|
|
8322
|
+
if (state) {
|
|
8323
|
+
this.topics.replace(state);
|
|
8324
|
+
this.closedEndpointKeys.clear();
|
|
8325
|
+
for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {}))
|
|
8326
|
+
if (binding) this.closedEndpointKeys.set(sessionId, binding);
|
|
8327
|
+
}
|
|
8328
|
+
} else if (latestWinner) {
|
|
8329
|
+
this.#replaceTopicAuthority(latestWinner);
|
|
8330
|
+
} else {
|
|
8331
|
+
rollback();
|
|
8332
|
+
}
|
|
7324
8333
|
throw error;
|
|
7325
8334
|
}
|
|
7326
8335
|
});
|
|
@@ -7339,8 +8348,9 @@ export class TelegramNotificationDaemon {
|
|
|
7339
8348
|
}
|
|
7340
8349
|
}
|
|
7341
8350
|
|
|
7342
|
-
#superviseCompensationFence(sessionId: string): void {
|
|
7343
|
-
|
|
8351
|
+
async #superviseCompensationFence(sessionId: string): Promise<void> {
|
|
8352
|
+
const existing = this.compensationFenceRetries.get(sessionId);
|
|
8353
|
+
if (existing) return await existing;
|
|
7344
8354
|
const retry = this.effects.track(
|
|
7345
8355
|
(async () => {
|
|
7346
8356
|
for (;;) {
|
|
@@ -7354,35 +8364,143 @@ export class TelegramNotificationDaemon {
|
|
|
7354
8364
|
})(),
|
|
7355
8365
|
);
|
|
7356
8366
|
this.compensationFenceRetries.set(sessionId, retry);
|
|
7357
|
-
|
|
8367
|
+
try {
|
|
8368
|
+
await retry;
|
|
8369
|
+
} finally {
|
|
8370
|
+
if (this.compensationFenceRetries.get(sessionId) === retry) this.compensationFenceRetries.delete(sessionId);
|
|
8371
|
+
}
|
|
7358
8372
|
}
|
|
7359
8373
|
|
|
7360
8374
|
private persistTopics(): Promise<void> {
|
|
8375
|
+
if (this.validationMode()) return Promise.resolve();
|
|
7361
8376
|
const pending = this.topicsPersistQueue.then(async () => {
|
|
7362
8377
|
// Resolve implicit snapshots inside the serialization queue. Callers that
|
|
7363
8378
|
// mutate the registry before waiting cannot overwrite a newer authority
|
|
7364
8379
|
// binding with an invocation-time snapshot.
|
|
7365
8380
|
const snapshot = this.#topicStateForPersistence();
|
|
7366
|
-
const
|
|
7367
|
-
|
|
7368
|
-
|
|
8381
|
+
const authority = this.opts.topicRegistryAuthority;
|
|
8382
|
+
if (authority) {
|
|
8383
|
+
const expectedGeneration = this.topics.registryVersion();
|
|
8384
|
+
const nextGeneration = expectedGeneration + 1;
|
|
8385
|
+
snapshot.registryGeneration = nextGeneration;
|
|
8386
|
+
let accepted = false;
|
|
8387
|
+
try {
|
|
8388
|
+
accepted = await authority.compareAndSet(expectedGeneration, snapshot);
|
|
8389
|
+
} catch {
|
|
8390
|
+
throw new Error("shared topic authority unavailable");
|
|
8391
|
+
}
|
|
8392
|
+
if (!accepted) {
|
|
8393
|
+
let winner: TopicRegistryState | undefined;
|
|
8394
|
+
try {
|
|
8395
|
+
winner = parseTopicRegistryState(await authority.read());
|
|
8396
|
+
} catch {
|
|
8397
|
+
throw new Error("shared topic authority unavailable");
|
|
8398
|
+
}
|
|
8399
|
+
if (!winner) throw new Error("shared topic authority conflict");
|
|
8400
|
+
this.#replaceTopicAuthority(winner);
|
|
8401
|
+
throw new Error("shared topic authority conflict");
|
|
8402
|
+
}
|
|
8403
|
+
this.topics.markRegistryPublished(nextGeneration);
|
|
8404
|
+
}
|
|
8405
|
+
if (!authority) {
|
|
8406
|
+
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
8407
|
+
await ensureDir(this.fsImpl, paths.dir);
|
|
8408
|
+
const topicPath = path.join(paths.dir, "telegram-topics.json");
|
|
8409
|
+
const expectedDestination = await captureTopicRegistryDestination(this.fsImpl, topicPath, process.platform);
|
|
8410
|
+
await writeTopicRegistryAtomic(
|
|
8411
|
+
this.fsImpl,
|
|
8412
|
+
topicPath,
|
|
8413
|
+
snapshot,
|
|
8414
|
+
process.platform,
|
|
8415
|
+
exactReplacePath,
|
|
8416
|
+
expectedDestination,
|
|
8417
|
+
);
|
|
8418
|
+
}
|
|
7369
8419
|
});
|
|
7370
8420
|
this.topicsPersistQueue = pending.catch(() => undefined);
|
|
7371
8421
|
return pending;
|
|
7372
8422
|
}
|
|
7373
8423
|
|
|
8424
|
+
#acceptedCreateArchiveFenceAllows(
|
|
8425
|
+
sessionId: string,
|
|
8426
|
+
topicId: string,
|
|
8427
|
+
creationLeaseEpoch: number,
|
|
8428
|
+
binding: TopicEndpointBinding | undefined,
|
|
8429
|
+
): boolean {
|
|
8430
|
+
const record = this.topics.get(sessionId);
|
|
8431
|
+
const bindingMatches = binding
|
|
8432
|
+
? record?.chatId === binding.chatId &&
|
|
8433
|
+
record.endpointKey === binding.endpointKey &&
|
|
8434
|
+
record.endpointDigest === binding.endpointDigest &&
|
|
8435
|
+
record.endpointGeneration === binding.endpointGeneration
|
|
8436
|
+
: record?.chatId === String(this.opts.chatId) &&
|
|
8437
|
+
record.endpointKey === undefined &&
|
|
8438
|
+
record.endpointDigest === undefined;
|
|
8439
|
+
return (
|
|
8440
|
+
record?.topicId === topicId &&
|
|
8441
|
+
record.creationLeaseEpoch === creationLeaseEpoch &&
|
|
8442
|
+
bindingMatches &&
|
|
8443
|
+
this.topics.archiveAuthorityAllows(sessionId, this.installationHostId, this.opts.chatId, this.runtime.now())
|
|
8444
|
+
);
|
|
8445
|
+
}
|
|
8446
|
+
|
|
8447
|
+
#replaceTopicAuthority(state: TopicRegistryState): void {
|
|
8448
|
+
this.topics.replace(state);
|
|
8449
|
+
this.closedEndpointKeys.clear();
|
|
8450
|
+
for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {}))
|
|
8451
|
+
if (binding) this.closedEndpointKeys.set(sessionId, binding);
|
|
8452
|
+
}
|
|
8453
|
+
|
|
7374
8454
|
#topicStateForPersistence(): TopicRegistryState {
|
|
7375
|
-
return {
|
|
8455
|
+
return {
|
|
8456
|
+
...this.topics.serialize(),
|
|
8457
|
+
closedEndpoints: Object.fromEntries(this.closedEndpointKeys),
|
|
8458
|
+
...(this.opts.topicRegistryAuthority ? {} : { installationHostId: this.installationHostId }),
|
|
8459
|
+
};
|
|
7376
8460
|
}
|
|
7377
8461
|
|
|
7378
8462
|
async loadTopics(): Promise<void> {
|
|
8463
|
+
if (this.validationMode()) return;
|
|
7379
8464
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
7380
|
-
const
|
|
8465
|
+
const topicPath = path.join(paths.dir, "telegram-topics.json");
|
|
8466
|
+
let raw = await readJson<unknown>(this.fsImpl, topicPath);
|
|
8467
|
+
if (this.opts.topicRegistryAuthority) {
|
|
8468
|
+
try {
|
|
8469
|
+
raw = await this.opts.topicRegistryAuthority.read();
|
|
8470
|
+
} catch {
|
|
8471
|
+
throw new Error("shared topic authority unavailable");
|
|
8472
|
+
}
|
|
8473
|
+
if (!raw) throw new Error("shared topic authority unavailable");
|
|
8474
|
+
}
|
|
8475
|
+
const legacySnapshot =
|
|
8476
|
+
!!raw && typeof raw === "object" && !Array.isArray(raw) && !Object.hasOwn(raw as object, "version");
|
|
8477
|
+
if (legacySnapshot && raw !== undefined) {
|
|
8478
|
+
const legacyDigest = crypto.createHash("sha256").update(JSON.stringify(raw)).digest("hex");
|
|
8479
|
+
const quarantinePath = path.join(paths.dir, `telegram-topics.legacy-quarantine.${legacyDigest}.json`);
|
|
8480
|
+
if ((await readJson<unknown>(this.fsImpl, quarantinePath)) === undefined) {
|
|
8481
|
+
await ensureDir(this.fsImpl, paths.dir);
|
|
8482
|
+
await writeTopicRegistryAtomic(this.fsImpl, quarantinePath, raw);
|
|
8483
|
+
}
|
|
8484
|
+
}
|
|
8485
|
+
const state = parseTopicRegistryState(raw);
|
|
7381
8486
|
// Restore the full serialized registry (topicId + identitySent + name) so a
|
|
7382
8487
|
// fresh daemon after reload does not resend identity headers or lose renames.
|
|
7383
|
-
if (
|
|
7384
|
-
|
|
7385
|
-
|
|
8488
|
+
if (state) {
|
|
8489
|
+
const missingHostId =
|
|
8490
|
+
!this.opts.topicRegistryAuthority &&
|
|
8491
|
+
(typeof state.installationHostId !== "string" || state.installationHostId.length === 0);
|
|
8492
|
+
const missingSessionUuid = Object.values(state.topics).some(
|
|
8493
|
+
record => !!record && typeof record === "object" && typeof record.sessionUuid !== "string",
|
|
8494
|
+
);
|
|
8495
|
+
this.topics.load(state);
|
|
8496
|
+
if (!this.opts.topicRegistryAuthority && typeof state.installationHostId === "string")
|
|
8497
|
+
this.installationHostId = state.installationHostId;
|
|
8498
|
+
let reconciledCreateClaim = false;
|
|
8499
|
+
for (const claim of this.topics.pendingCreateClaims()) {
|
|
8500
|
+
const topic = this.topics.get(claim.sessionId);
|
|
8501
|
+
reconciledCreateClaim = this.topics.reconcileCreateClaim(claim.sessionId, topic) || reconciledCreateClaim;
|
|
8502
|
+
}
|
|
8503
|
+
for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {})) {
|
|
7386
8504
|
if (
|
|
7387
8505
|
binding &&
|
|
7388
8506
|
typeof binding.chatId === "string" &&
|
|
@@ -7393,14 +8511,14 @@ export class TelegramNotificationDaemon {
|
|
|
7393
8511
|
)
|
|
7394
8512
|
this.closedEndpointKeys.set(sessionId, binding);
|
|
7395
8513
|
}
|
|
8514
|
+
if (legacySnapshot || missingHostId || missingSessionUuid || reconciledCreateClaim) await this.persistTopics();
|
|
7396
8515
|
}
|
|
7397
8516
|
}
|
|
7398
8517
|
|
|
7399
8518
|
/**
|
|
7400
|
-
* Rehydrate durable adoption intents after restart
|
|
7401
|
-
*
|
|
7402
|
-
*
|
|
7403
|
-
* and is removed safely without touching the user topic.
|
|
8519
|
+
* Rehydrate durable adoption intents after restart. A sidecar whose topic
|
|
8520
|
+
* already committed is stale evidence and can be removed without touching
|
|
8521
|
+
* the retained user-created topic.
|
|
7404
8522
|
*/
|
|
7405
8523
|
async loadAdoptionIntents(): Promise<void> {
|
|
7406
8524
|
await this.#adoptionIntents.rehydrate();
|
|
@@ -7423,7 +8541,6 @@ export class TelegramNotificationDaemon {
|
|
|
7423
8541
|
}
|
|
7424
8542
|
}
|
|
7425
8543
|
|
|
7426
|
-
/** Periodic sweep of expired adoption-intent sidecars (files only; no Telegram API). */
|
|
7427
8544
|
private startAdoptionSweepTimer(): void {
|
|
7428
8545
|
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
7429
8546
|
this.#adoptionSweepTimer = setIntervalImpl(() => {
|
|
@@ -7446,9 +8563,10 @@ export class TelegramNotificationDaemon {
|
|
|
7446
8563
|
}
|
|
7447
8564
|
}
|
|
7448
8565
|
|
|
7449
|
-
/** Retry crash-interrupted topic
|
|
8566
|
+
/** Retry crash-interrupted topic archives only when the durable backoff permits it. */
|
|
7450
8567
|
private async reconcilePendingTopicDeletes(): Promise<void> {
|
|
7451
|
-
for (const sessionId of this.topics.
|
|
8568
|
+
for (const sessionId of this.topics.archivePendingSessionIds(this.runtime.now()))
|
|
8569
|
+
await this.archiveTopic(sessionId);
|
|
7452
8570
|
}
|
|
7453
8571
|
|
|
7454
8572
|
/** Download one Telegram file with the Bot API's 20 MiB ceiling and one end-to-end deadline. */
|
|
@@ -7892,7 +9010,7 @@ export class TelegramNotificationDaemon {
|
|
|
7892
9010
|
continue;
|
|
7893
9011
|
}
|
|
7894
9012
|
const topicId = topicLease?.topicId;
|
|
7895
|
-
if (topicId && !(await this.
|
|
9013
|
+
if (topicId && !(await this.pairedChatAllowsTopics())) {
|
|
7896
9014
|
this.pool.settle(item.itemId!, "rejected");
|
|
7897
9015
|
this.failLegacyToolStart(toolActivity);
|
|
7898
9016
|
continue;
|
|
@@ -8055,7 +9173,7 @@ export class TelegramNotificationDaemon {
|
|
|
8055
9173
|
);
|
|
8056
9174
|
// Index the sent rich message so an inbound reply to it can restore
|
|
8057
9175
|
// the original markdown as context (Telegram does not echo it back).
|
|
8058
|
-
if (richMessageId !== undefined) {
|
|
9176
|
+
if (richMessageId !== undefined && !this.validationMode()) {
|
|
8059
9177
|
await this.replyStore.record({
|
|
8060
9178
|
chatId: this.opts.chatId,
|
|
8061
9179
|
messageId: richMessageId,
|
|
@@ -8362,29 +9480,41 @@ export class TelegramNotificationDaemon {
|
|
|
8362
9480
|
}
|
|
8363
9481
|
|
|
8364
9482
|
/**
|
|
8365
|
-
* Resolve
|
|
8366
|
-
*
|
|
8367
|
-
* indeterminate `getChat` result fails closed for this attempt and is retried
|
|
8368
|
-
* later.
|
|
9483
|
+
* Resolve and cache the outbound delivery boundary. The validation forum is
|
|
9484
|
+
* explicit and topic-only; all inbound and flat paths remain private-only.
|
|
8369
9485
|
*/
|
|
9486
|
+
private validationMode(): boolean {
|
|
9487
|
+
return this.opts.validationTestSupergroupChatId !== undefined;
|
|
9488
|
+
}
|
|
9489
|
+
|
|
8370
9490
|
private async resolvePairedChatPrivacy(): Promise<PairedChatPrivacy> {
|
|
8371
|
-
if (this.
|
|
9491
|
+
if (this.pairedChatPrivacy !== undefined) return this.pairedChatPrivacy;
|
|
9492
|
+
const chatId = this.opts.validationTestSupergroupChatId ?? this.opts.chatId;
|
|
8372
9493
|
try {
|
|
8373
|
-
const res = (await this.botApi.call("getChat", { chat_id:
|
|
9494
|
+
const res = (await this.botApi.call("getChat", { chat_id: chatId })) as {
|
|
8374
9495
|
ok?: unknown;
|
|
8375
|
-
result?: { type?: unknown };
|
|
9496
|
+
result?: { id?: unknown; type?: unknown; is_forum?: unknown };
|
|
8376
9497
|
};
|
|
8377
9498
|
if (res === undefined) return "indeterminate";
|
|
8378
9499
|
if (res?.ok !== true) {
|
|
8379
9500
|
logger.warn("notifications: getChat privacy check indeterminate (non-success response)");
|
|
8380
9501
|
return "indeterminate";
|
|
8381
9502
|
}
|
|
8382
|
-
if (
|
|
8383
|
-
this.
|
|
9503
|
+
if (
|
|
9504
|
+
this.validationMode() &&
|
|
9505
|
+
String(res.result?.id) === this.opts.validationTestSupergroupChatId &&
|
|
9506
|
+
res.result?.type === "supergroup" &&
|
|
9507
|
+
res.result.is_forum === true
|
|
9508
|
+
) {
|
|
9509
|
+
this.pairedChatPrivacy = "validation-forum";
|
|
9510
|
+
return "validation-forum";
|
|
9511
|
+
}
|
|
9512
|
+
if (!this.validationMode() && res.result?.type === "private") {
|
|
9513
|
+
this.pairedChatPrivacy = "private";
|
|
8384
9514
|
return "private";
|
|
8385
9515
|
}
|
|
8386
9516
|
if (res.result?.type === "group" || res.result?.type === "supergroup" || res.result?.type === "channel") {
|
|
8387
|
-
this.
|
|
9517
|
+
this.pairedChatPrivacy = "non-private";
|
|
8388
9518
|
return "non-private";
|
|
8389
9519
|
}
|
|
8390
9520
|
logger.warn("notifications: getChat privacy check indeterminate (missing or invalid chat type)");
|
|
@@ -8395,7 +9525,13 @@ export class TelegramNotificationDaemon {
|
|
|
8395
9525
|
}
|
|
8396
9526
|
}
|
|
8397
9527
|
|
|
8398
|
-
|
|
9528
|
+
private async pairedChatAllowsTopics(): Promise<boolean> {
|
|
9529
|
+
if (this.topicCapabilityRefused) return false;
|
|
9530
|
+
const privacy = await this.resolvePairedChatPrivacy();
|
|
9531
|
+
return privacy === "private" || privacy === "validation-forum";
|
|
9532
|
+
}
|
|
9533
|
+
|
|
9534
|
+
/** Keep all flat delivery and inbound control paths private-only. */
|
|
8399
9535
|
private async pairedChatIsPrivate(): Promise<boolean> {
|
|
8400
9536
|
return (await this.resolvePairedChatPrivacy()) === "private";
|
|
8401
9537
|
}
|
|
@@ -8432,7 +9568,11 @@ export class TelegramNotificationDaemon {
|
|
|
8432
9568
|
private startFlushTimer(): void {
|
|
8433
9569
|
this.runtime.startInterval("telegram-flush", RATE_LIMIT_FLUSH_INTERVAL_MS, () => {
|
|
8434
9570
|
if (!this.running || this.pool.pending === 0) return;
|
|
8435
|
-
|
|
9571
|
+
// `flushPool` only swallows failures for the shared chain; the promise it
|
|
9572
|
+
// returns still rejects, and an unhandled rejection here exits the daemon.
|
|
9573
|
+
void this.flushPool().catch(error => {
|
|
9574
|
+
logger.warn(`notifications: queue flush failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`);
|
|
9575
|
+
});
|
|
8436
9576
|
});
|
|
8437
9577
|
}
|
|
8438
9578
|
|
|
@@ -8472,11 +9612,27 @@ export class TelegramNotificationDaemon {
|
|
|
8472
9612
|
this.runtime.stopInterval("telegram-owner-heartbeat");
|
|
8473
9613
|
}
|
|
8474
9614
|
|
|
8475
|
-
/**
|
|
9615
|
+
/**
|
|
9616
|
+
* Run a root scan, guarding against overlapping scans from the timer + loop.
|
|
9617
|
+
*
|
|
9618
|
+
* A reconciliation pass is retried every scan interval, so a failed one must
|
|
9619
|
+
* never tear the owner down. Both callers are fatal boundaries: the timer
|
|
9620
|
+
* fires and forgets, and the run loop awaits without a handler. A rejection
|
|
9621
|
+
* escaping either one (a shared topic authority whose lock or compare-and-set
|
|
9622
|
+
* is momentarily unavailable is the observed case) reaches the process-level
|
|
9623
|
+
* handler, which exits the daemon: every session topic is then left as an
|
|
9624
|
+
* unarchived shell that answers nothing, and the sessions that were still
|
|
9625
|
+
* live lose notifications too. Report the failure and let the next scan
|
|
9626
|
+
* retry instead.
|
|
9627
|
+
*/
|
|
8476
9628
|
private async runScan(): Promise<void> {
|
|
8477
|
-
|
|
8478
|
-
await this.
|
|
8479
|
-
|
|
9629
|
+
try {
|
|
9630
|
+
await this.runtime.runExclusive("telegram-scan", async () => {
|
|
9631
|
+
await this.scanRoots();
|
|
9632
|
+
});
|
|
9633
|
+
} catch (error) {
|
|
9634
|
+
logger.warn(`notifications: session scan failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`);
|
|
9635
|
+
}
|
|
8480
9636
|
}
|
|
8481
9637
|
|
|
8482
9638
|
private startScanTimer(): void {
|
|
@@ -8594,14 +9750,18 @@ export class TelegramNotificationDaemon {
|
|
|
8594
9750
|
);
|
|
8595
9751
|
if (!this.#leaseTokenAllows(socketLease) || !this.topicLeaseIsCurrent(topicLease)) return false;
|
|
8596
9752
|
try {
|
|
8597
|
-
const response = await this.botApi.call(
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
9753
|
+
const response = await this.botApi.call(
|
|
9754
|
+
"sendMessage",
|
|
9755
|
+
{
|
|
9756
|
+
chat_id: this.opts.chatId,
|
|
9757
|
+
message_thread_id: Number(topicId),
|
|
9758
|
+
text: rendered.text,
|
|
9759
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
9760
|
+
reply_markup: { inline_keyboard },
|
|
9761
|
+
...(telegramDisableNotification(this.opts.sound, "ask") === true ? { disable_notification: true } : {}),
|
|
9762
|
+
},
|
|
9763
|
+
{ noRetry: true },
|
|
9764
|
+
);
|
|
8605
9765
|
if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
|
|
8606
9766
|
for (const alias of aliases) this.#modelChoiceAliases.delete(alias);
|
|
8607
9767
|
logger.warn("notifications: failed to send model selection keyboard");
|
|
@@ -8689,10 +9849,13 @@ export class TelegramNotificationDaemon {
|
|
|
8689
9849
|
endpointAuthority.sessionId === session.sessionId &&
|
|
8690
9850
|
ownsLiveOpenEndpoint &&
|
|
8691
9851
|
this.topics.matchesEndpoint(session.sessionId, endpointBinding);
|
|
9852
|
+
const inactiveSuccessor =
|
|
9853
|
+
this.topics.get(session.sessionId)?.authorityState === "inactive" &&
|
|
9854
|
+
this.topics.get(session.sessionId)?.endpointDigest !== endpointBinding.endpointDigest;
|
|
8692
9855
|
const canBootstrapTransport =
|
|
8693
|
-
endpointAuthority.state === "none" &&
|
|
9856
|
+
(endpointAuthority.state === "none" || inactiveSuccessor) &&
|
|
8694
9857
|
ownsLiveOpenEndpoint &&
|
|
8695
|
-
!this.topics.get(session.sessionId) &&
|
|
9858
|
+
(!this.topics.get(session.sessionId) || inactiveSuccessor) &&
|
|
8696
9859
|
!this.preservedInitiatorTopics.has(session.sessionId);
|
|
8697
9860
|
const replayCandidateSessionId =
|
|
8698
9861
|
replayIdentitySessionId ?? (canResumeTransport || canBootstrapTransport ? session.sessionId : undefined);
|
|
@@ -8704,6 +9867,7 @@ export class TelegramNotificationDaemon {
|
|
|
8704
9867
|
true,
|
|
8705
9868
|
replayIdentitySessionId ? undefined : canBootstrapTransport ? "bootstrap" : "resume",
|
|
8706
9869
|
);
|
|
9870
|
+
if (this.sessions.get(session.sessionId) !== session) return;
|
|
8707
9871
|
if (!recovered) {
|
|
8708
9872
|
if (session.hostGeneration === msg.generation && session.recoveryLease?.state !== "pending")
|
|
8709
9873
|
this.dropSession(session, "recovery_rejected");
|
|
@@ -9116,10 +10280,8 @@ export class TelegramNotificationDaemon {
|
|
|
9116
10280
|
abandonStaleToolStart();
|
|
9117
10281
|
return;
|
|
9118
10282
|
}
|
|
9119
|
-
|
|
9120
|
-
|
|
9121
|
-
this.topics.get(logicalSessionId)?.bindingMalformed
|
|
9122
|
-
) {
|
|
10283
|
+
const topicRecord = this.topics.get(logicalSessionId);
|
|
10284
|
+
if (topicRecord && (topicRecord.authorityState !== "active" || topicRecord.bindingMalformed)) {
|
|
9123
10285
|
this.failLegacyToolStart(toolActivity);
|
|
9124
10286
|
return;
|
|
9125
10287
|
}
|
|
@@ -9132,10 +10294,8 @@ export class TelegramNotificationDaemon {
|
|
|
9132
10294
|
(await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session));
|
|
9133
10295
|
const topicLease = await this.topicAuthorityLease(logicalSessionId);
|
|
9134
10296
|
if (!topicId || !topicLease || topicLease.topicId !== topicId) {
|
|
9135
|
-
|
|
9136
|
-
|
|
9137
|
-
this.topics.get(logicalSessionId)?.bindingMalformed
|
|
9138
|
-
) {
|
|
10297
|
+
const topicRecord = this.topics.get(logicalSessionId);
|
|
10298
|
+
if (topicRecord && (topicRecord.authorityState !== "active" || topicRecord.bindingMalformed)) {
|
|
9139
10299
|
this.failLegacyToolStart(toolActivity);
|
|
9140
10300
|
return;
|
|
9141
10301
|
}
|
|
@@ -9195,12 +10355,13 @@ export class TelegramNotificationDaemon {
|
|
|
9195
10355
|
const logicalSessionId = this.#logicalSessionId(session);
|
|
9196
10356
|
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
9197
10357
|
if (!socketLease) return;
|
|
9198
|
-
|
|
9199
|
-
if (
|
|
9200
|
-
|
|
9201
|
-
this.
|
|
9202
|
-
|
|
9203
|
-
|
|
10358
|
+
const pendingAction = msg.kind === "ask" ? { sessionId: logicalSessionId, actionId: msg.id } : undefined;
|
|
10359
|
+
if (pendingAction) {
|
|
10360
|
+
session.pending.set(msg.id, pendingAction);
|
|
10361
|
+
await this.reissuePendingAction(logicalSessionId, msg.id);
|
|
10362
|
+
}
|
|
10363
|
+
const topicRecord = this.topics.get(logicalSessionId);
|
|
10364
|
+
if (topicRecord && (topicRecord.authorityState !== "active" || topicRecord.bindingMalformed)) return;
|
|
9204
10365
|
const topicId = await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session);
|
|
9205
10366
|
const topicLease = topicId ? this.topicAuthorityLeaseFromRegistry(logicalSessionId) : undefined;
|
|
9206
10367
|
if (topicId && (!topicLease || topicLease.topicId !== topicId)) return;
|
|
@@ -9247,7 +10408,9 @@ export class TelegramNotificationDaemon {
|
|
|
9247
10408
|
: [],
|
|
9248
10409
|
);
|
|
9249
10410
|
const displayOptions = options.map((option, index) =>
|
|
9250
|
-
multiSelect
|
|
10411
|
+
multiSelect
|
|
10412
|
+
? `${selectedOptionIndices.has(index) ? SELECTION_MARK_CHECKED : SELECTION_MARK_UNCHECKED} ${option}`
|
|
10413
|
+
: option,
|
|
9251
10414
|
);
|
|
9252
10415
|
const rendered = buildActionMessage({
|
|
9253
10416
|
kind: msg.kind ?? "ask",
|
|
@@ -9258,81 +10421,245 @@ export class TelegramNotificationDaemon {
|
|
|
9258
10421
|
controls,
|
|
9259
10422
|
summary: msg.summary,
|
|
9260
10423
|
});
|
|
10424
|
+
const kind = msg.kind === "idle" ? "idle" : "ask";
|
|
10425
|
+
const callbackDispatchAuthorityIsCurrent = (): boolean =>
|
|
10426
|
+
this.#leaseTokenAllows(socketLease) &&
|
|
10427
|
+
(!topicLease || this.topicLeaseIsCurrent(topicLease)) &&
|
|
10428
|
+
(kind !== "ask" || (pendingAction !== undefined && session.pending.get(msg.id) === pendingAction));
|
|
10429
|
+
const createdAliases: Array<[string, CallbackRoute]> = [];
|
|
10430
|
+
const createAlias = (answer: CallbackRoute["answer"]): string => {
|
|
10431
|
+
const alias = this.aliasTable.allocate(candidate => this.reissueBacklog.has(candidate));
|
|
10432
|
+
createdAliases.push([
|
|
10433
|
+
alias,
|
|
10434
|
+
{
|
|
10435
|
+
sessionId: logicalSessionId,
|
|
10436
|
+
actionId: msg.id,
|
|
10437
|
+
answer,
|
|
10438
|
+
},
|
|
10439
|
+
]);
|
|
10440
|
+
return alias;
|
|
10441
|
+
};
|
|
9261
10442
|
const inline_keyboard = [
|
|
9262
|
-
...buildCompactChoiceGrid(displayOptions, (i: number) =>
|
|
9263
|
-
this.aliasTable.put({ sessionId: logicalSessionId, actionId: msg.id, answer: i }),
|
|
9264
|
-
),
|
|
10443
|
+
...buildCompactChoiceGrid(displayOptions, (i: number) => createAlias(i)),
|
|
9265
10444
|
...controls.map(control => [
|
|
9266
10445
|
{
|
|
9267
10446
|
text: control.label,
|
|
9268
|
-
callback_data:
|
|
9269
|
-
sessionId: logicalSessionId,
|
|
9270
|
-
actionId: msg.id,
|
|
9271
|
-
answer: { controlId: control.id },
|
|
9272
|
-
}),
|
|
10447
|
+
callback_data: createAlias({ controlId: control.id }),
|
|
9273
10448
|
},
|
|
9274
10449
|
]),
|
|
9275
10450
|
];
|
|
9276
|
-
|
|
10451
|
+
if (createdAliases.length > 0) {
|
|
10452
|
+
for (const [alias, route] of createdAliases) {
|
|
10453
|
+
if (!this.aliasTable.activate(alias, route)) throw new Error("callback alias reservation conflicted");
|
|
10454
|
+
}
|
|
10455
|
+
// Reserve every exposed callback token durably before Telegram can
|
|
10456
|
+
// accept a keyboard. A restart loads these non-receipt routes only
|
|
10457
|
+
// into the non-routable reissue backlog.
|
|
10458
|
+
await this.persistAliases();
|
|
10459
|
+
}
|
|
9277
10460
|
// HTML delivery: one sendMessage per chunk, keyboard on the last chunk;
|
|
9278
|
-
// returns the last chunk's message_id (the reply-routable message).
|
|
10461
|
+
// returns the last chunk's authoritative message_id (the reply-routable message).
|
|
10462
|
+
let callbackDelivery: "accepted" | "rejected" | "ambiguous" = "rejected";
|
|
9279
10463
|
const sendHtmlChunks = async (): Promise<number | undefined> => {
|
|
9280
10464
|
const chunks = splitTelegramHtml(rendered.text);
|
|
9281
|
-
let
|
|
10465
|
+
let acceptedMessageId: number | undefined;
|
|
9282
10466
|
for (let i = 0; i < chunks.length; i++) {
|
|
9283
|
-
|
|
10467
|
+
const exposesCallbacks = i === chunks.length - 1 && inline_keyboard.length > 0;
|
|
10468
|
+
if (!callbackDispatchAuthorityIsCurrent()) return undefined;
|
|
10469
|
+
let result: { ok?: unknown; result?: { message_id?: unknown } } | undefined;
|
|
10470
|
+
try {
|
|
10471
|
+
result = (await this.botApi.call(
|
|
10472
|
+
"sendMessage",
|
|
10473
|
+
{
|
|
10474
|
+
chat_id: this.opts.chatId,
|
|
10475
|
+
...threadField,
|
|
10476
|
+
text: chunks[i]!,
|
|
10477
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
10478
|
+
...(telegramDisableNotification(this.opts.sound, kind, i === chunks.length - 1) === true
|
|
10479
|
+
? { disable_notification: true }
|
|
10480
|
+
: {}),
|
|
10481
|
+
...(exposesCallbacks ? { reply_markup: { inline_keyboard } } : {}),
|
|
10482
|
+
},
|
|
10483
|
+
{ noRetry: true },
|
|
10484
|
+
)) as { ok?: unknown; result?: { message_id?: unknown } } | undefined;
|
|
10485
|
+
} catch {
|
|
10486
|
+
if (exposesCallbacks) callbackDelivery = "ambiguous";
|
|
9284
10487
|
return undefined;
|
|
9285
|
-
|
|
9286
|
-
|
|
9287
|
-
|
|
9288
|
-
|
|
9289
|
-
|
|
9290
|
-
|
|
9291
|
-
|
|
9292
|
-
|
|
9293
|
-
|
|
9294
|
-
}
|
|
9295
|
-
if (
|
|
10488
|
+
}
|
|
10489
|
+
if (result?.ok !== true) {
|
|
10490
|
+
if (exposesCallbacks && result?.ok !== false) callbackDelivery = "ambiguous";
|
|
10491
|
+
return undefined;
|
|
10492
|
+
}
|
|
10493
|
+
const candidate = result.result?.message_id;
|
|
10494
|
+
if (typeof candidate !== "number" || !Number.isSafeInteger(candidate) || candidate <= 0) {
|
|
10495
|
+
if (exposesCallbacks) callbackDelivery = "ambiguous";
|
|
10496
|
+
return undefined;
|
|
10497
|
+
}
|
|
10498
|
+
if (exposesCallbacks) callbackDelivery = "accepted";
|
|
10499
|
+
acceptedMessageId = candidate;
|
|
9296
10500
|
}
|
|
9297
|
-
return
|
|
10501
|
+
return acceptedMessageId;
|
|
9298
10502
|
};
|
|
10503
|
+
let messageId: number | undefined;
|
|
9299
10504
|
if (this.opts.rich?.enabled !== false) {
|
|
9300
10505
|
// Rich (default on): promote to sendRichMessage with a top-level
|
|
9301
10506
|
// 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
|
-
|
|
10507
|
+
if (callbackDispatchAuthorityIsCurrent()) {
|
|
10508
|
+
const outcome = await deliverRichActionWithFallback(
|
|
10509
|
+
this.botApi,
|
|
10510
|
+
{
|
|
10511
|
+
chat_id: this.opts.chatId,
|
|
10512
|
+
...threadField,
|
|
10513
|
+
...(telegramDisableNotification(this.opts.sound, kind) === true
|
|
10514
|
+
? { disable_notification: true }
|
|
10515
|
+
: {}),
|
|
10516
|
+
},
|
|
10517
|
+
{
|
|
10518
|
+
markdown: buildActionMarkdown({
|
|
10519
|
+
kind,
|
|
10520
|
+
question: msg.question,
|
|
10521
|
+
options: displayOptions,
|
|
10522
|
+
recommendedIndex: msg.recommendedIndex,
|
|
10523
|
+
summary: msg.summary,
|
|
10524
|
+
}),
|
|
10525
|
+
replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
|
|
10526
|
+
},
|
|
10527
|
+
AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
|
|
10528
|
+
sendHtmlChunks,
|
|
10529
|
+
logger,
|
|
10530
|
+
);
|
|
10531
|
+
messageId = outcome.messageId;
|
|
10532
|
+
if (createdAliases.length > 0 && messageId !== undefined) callbackDelivery = "accepted";
|
|
10533
|
+
else if (createdAliases.length > 0 && !outcome.usedFallback) callbackDelivery = "ambiguous";
|
|
10534
|
+
}
|
|
9330
10535
|
} else {
|
|
9331
10536
|
// Rich disabled: deliver through the HTML chunk path.
|
|
9332
|
-
|
|
9333
|
-
|
|
9334
|
-
|
|
9335
|
-
|
|
10537
|
+
messageId = await sendHtmlChunks();
|
|
10538
|
+
}
|
|
10539
|
+
const pendingActionIsCurrent = pendingAction !== undefined && session.pending.get(msg.id) === pendingAction;
|
|
10540
|
+
const mayActivate =
|
|
10541
|
+
kind === "ask" &&
|
|
10542
|
+
messageId !== undefined &&
|
|
10543
|
+
pendingActionIsCurrent &&
|
|
10544
|
+
this.#leaseTokenAllows(socketLease) &&
|
|
10545
|
+
(!topicLease || this.topicLeaseIsCurrent(topicLease));
|
|
10546
|
+
if (mayActivate) {
|
|
10547
|
+
for (const [alias, route] of createdAliases) {
|
|
10548
|
+
if (
|
|
10549
|
+
!this.aliasTable.update(alias, {
|
|
10550
|
+
...route,
|
|
10551
|
+
chatId: String(this.opts.chatId),
|
|
10552
|
+
messageId,
|
|
10553
|
+
ownerId: this.opts.ownerId,
|
|
10554
|
+
generation: DAEMON_GENERATION,
|
|
10555
|
+
})
|
|
10556
|
+
)
|
|
10557
|
+
throw new Error("callback alias receipt update conflicted");
|
|
10558
|
+
}
|
|
10559
|
+
this.messageRoutes.set(String(messageId), { sessionId: logicalSessionId, actionId: msg.id });
|
|
10560
|
+
const activation = this.#stageCallbackActivation(
|
|
10561
|
+
createdAliases.map(([alias]) => alias),
|
|
10562
|
+
pendingAction,
|
|
10563
|
+
socketLease,
|
|
10564
|
+
topicLease,
|
|
10565
|
+
);
|
|
10566
|
+
try {
|
|
10567
|
+
// A receipt is durable before an alias gets a routable socket lease.
|
|
10568
|
+
await this.persistAliases();
|
|
10569
|
+
} catch {
|
|
10570
|
+
for (const [alias] of createdAliases) {
|
|
10571
|
+
this.#stagedCallbackActivations.delete(alias);
|
|
10572
|
+
this.#revokeCallbackAlias(alias);
|
|
10573
|
+
}
|
|
10574
|
+
this.deleteMessageRoutes(logicalSessionId, msg.id);
|
|
10575
|
+
try {
|
|
10576
|
+
await this.persistAliases();
|
|
10577
|
+
} catch {
|
|
10578
|
+
// The in-memory backlog retains the accepted receipt for shutdown terminalization.
|
|
10579
|
+
}
|
|
10580
|
+
activation.resolve(false);
|
|
10581
|
+
throw new Error("callback alias receipt persistence failed");
|
|
10582
|
+
}
|
|
10583
|
+
if (
|
|
10584
|
+
session.pending.get(msg.id) !== pendingAction ||
|
|
10585
|
+
!this.#leaseTokenAllows(socketLease) ||
|
|
10586
|
+
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
10587
|
+
) {
|
|
10588
|
+
for (const [alias] of createdAliases) {
|
|
10589
|
+
this.#revokeCallbackAlias(alias);
|
|
10590
|
+
this.#stagedCallbackActivations.delete(alias);
|
|
10591
|
+
}
|
|
10592
|
+
this.deleteMessageRoutes(logicalSessionId, msg.id);
|
|
10593
|
+
let revocationPersistenceError: unknown;
|
|
10594
|
+
try {
|
|
10595
|
+
await this.persistAliases();
|
|
10596
|
+
} catch (error) {
|
|
10597
|
+
revocationPersistenceError = error;
|
|
10598
|
+
} finally {
|
|
10599
|
+
activation.resolve(false);
|
|
10600
|
+
}
|
|
10601
|
+
try {
|
|
10602
|
+
await this.botApi.call("editMessageReplyMarkup", {
|
|
10603
|
+
chat_id: this.opts.chatId,
|
|
10604
|
+
message_id: messageId,
|
|
10605
|
+
reply_markup: { inline_keyboard: [] },
|
|
10606
|
+
});
|
|
10607
|
+
} catch {
|
|
10608
|
+
// The accepted receipt remains in memory for shutdown terminalization if persistence failed.
|
|
10609
|
+
}
|
|
10610
|
+
if (revocationPersistenceError !== undefined)
|
|
10611
|
+
throw new Error("callback alias revocation persistence failed", {
|
|
10612
|
+
cause: revocationPersistenceError,
|
|
10613
|
+
});
|
|
10614
|
+
return;
|
|
10615
|
+
}
|
|
10616
|
+
for (const [alias] of createdAliases) {
|
|
10617
|
+
this.#callbackAliasPending.set(alias, pendingAction);
|
|
10618
|
+
this.#callbackAliasLeases.set(alias, socketLease);
|
|
10619
|
+
if (topicLease) this.#callbackAliasTopics.set(alias, topicLease);
|
|
10620
|
+
this.#stagedCallbackActivations.delete(alias);
|
|
10621
|
+
}
|
|
10622
|
+
activation.resolve(true);
|
|
10623
|
+
} else if (kind === "ask" && messageId !== undefined) {
|
|
10624
|
+
// The send may have succeeded after authority ended. Persist every
|
|
10625
|
+
// receipt as revoked before best-effort remote terminalization.
|
|
10626
|
+
for (const [alias, route] of createdAliases) {
|
|
10627
|
+
const receipt = {
|
|
10628
|
+
...route,
|
|
10629
|
+
chatId: String(this.opts.chatId),
|
|
10630
|
+
messageId,
|
|
10631
|
+
ownerId: this.opts.ownerId,
|
|
10632
|
+
generation: DAEMON_GENERATION,
|
|
10633
|
+
};
|
|
10634
|
+
if (this.aliasTable.update(alias, receipt)) {
|
|
10635
|
+
this.#revokeCallbackAlias(alias);
|
|
10636
|
+
continue;
|
|
10637
|
+
}
|
|
10638
|
+
const revoked = this.reissueBacklog.get(alias);
|
|
10639
|
+
if (
|
|
10640
|
+
revoked &&
|
|
10641
|
+
(revoked.sessionId !== route.sessionId ||
|
|
10642
|
+
revoked.actionId !== route.actionId ||
|
|
10643
|
+
revoked.answer !== route.answer)
|
|
10644
|
+
)
|
|
10645
|
+
throw new Error("callback alias revocation update conflicted");
|
|
10646
|
+
this.reissueBacklog.set(alias, receipt);
|
|
10647
|
+
}
|
|
10648
|
+
await this.persistAliases();
|
|
10649
|
+
try {
|
|
10650
|
+
const terminalized = (await this.botApi.call("editMessageReplyMarkup", {
|
|
10651
|
+
chat_id: this.opts.chatId,
|
|
10652
|
+
message_id: messageId,
|
|
10653
|
+
reply_markup: { inline_keyboard: [] },
|
|
10654
|
+
})) as { ok?: unknown } | undefined;
|
|
10655
|
+
if (terminalized?.ok === true) for (const [alias] of createdAliases) this.reissueBacklog.delete(alias);
|
|
10656
|
+
} catch {
|
|
10657
|
+
// The accepted receipt remains durably terminalizable even if its keyboard cannot be removed.
|
|
10658
|
+
}
|
|
10659
|
+
} else if (kind === "ask" && callbackDelivery === "ambiguous") {
|
|
10660
|
+
for (const [alias] of createdAliases) this.#revokeCallbackAlias(alias);
|
|
10661
|
+
} else if (kind === "ask") {
|
|
10662
|
+
for (const [alias] of createdAliases) this.aliasTable.delete(alias);
|
|
9336
10663
|
}
|
|
9337
10664
|
await this.persistAliases();
|
|
9338
10665
|
} else if (msg.type === "action_resolved" && msg.id) {
|
|
@@ -9340,8 +10667,9 @@ export class TelegramNotificationDaemon {
|
|
|
9340
10667
|
this.deleteMessageRoutes(this.#logicalSessionId(session), msg.id);
|
|
9341
10668
|
for (const [alias, route] of this.aliasTable.entries()) {
|
|
9342
10669
|
if (route.sessionId === this.#logicalSessionId(session) && route.actionId === msg.id)
|
|
9343
|
-
this
|
|
10670
|
+
this.#revokeCallbackAlias(alias);
|
|
9344
10671
|
}
|
|
10672
|
+
await this.reissuePendingAction(this.#logicalSessionId(session), msg.id);
|
|
9345
10673
|
await this.persistAliases();
|
|
9346
10674
|
}
|
|
9347
10675
|
}
|
|
@@ -9556,14 +10884,18 @@ export class TelegramNotificationDaemon {
|
|
|
9556
10884
|
}),
|
|
9557
10885
|
}));
|
|
9558
10886
|
try {
|
|
9559
|
-
const response = await this.botApi.call(
|
|
9560
|
-
|
|
9561
|
-
|
|
9562
|
-
|
|
9563
|
-
|
|
9564
|
-
|
|
10887
|
+
const response = await this.botApi.call(
|
|
10888
|
+
"sendMessage",
|
|
10889
|
+
{
|
|
10890
|
+
chat_id: this.opts.chatId,
|
|
10891
|
+
message_thread_id: threadId,
|
|
10892
|
+
text: "Choose where to start the GJC session:",
|
|
10893
|
+
reply_markup: {
|
|
10894
|
+
inline_keyboard: aliases.map(choice => [{ text: choice.label, callback_data: choice.alias }]),
|
|
10895
|
+
},
|
|
9565
10896
|
},
|
|
9566
|
-
|
|
10897
|
+
{ noRetry: true },
|
|
10898
|
+
);
|
|
9567
10899
|
if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
|
|
9568
10900
|
for (const choice of aliases) this.#adoptionPickerAliases.delete(choice.alias);
|
|
9569
10901
|
return false;
|
|
@@ -9626,12 +10958,16 @@ export class TelegramNotificationDaemon {
|
|
|
9626
10958
|
index => aliases[index]!.alias,
|
|
9627
10959
|
);
|
|
9628
10960
|
try {
|
|
9629
|
-
const response = await this.botApi.call(
|
|
9630
|
-
|
|
9631
|
-
|
|
9632
|
-
|
|
9633
|
-
|
|
9634
|
-
|
|
10961
|
+
const response = await this.botApi.call(
|
|
10962
|
+
"sendMessage",
|
|
10963
|
+
{
|
|
10964
|
+
chat_id: this.opts.chatId,
|
|
10965
|
+
message_thread_id: threadId,
|
|
10966
|
+
text: "Pick a recent work folder to start a session here:",
|
|
10967
|
+
reply_markup: { inline_keyboard },
|
|
10968
|
+
},
|
|
10969
|
+
{ noRetry: true },
|
|
10970
|
+
);
|
|
9635
10971
|
if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
|
|
9636
10972
|
for (const a of aliases) this.#adoptionPickerAliases.delete(a.alias);
|
|
9637
10973
|
return false;
|
|
@@ -10080,6 +11416,7 @@ export class TelegramNotificationDaemon {
|
|
|
10080
11416
|
}
|
|
10081
11417
|
|
|
10082
11418
|
private async processTelegramUpdate(update: unknown): Promise<TelegramUpdateOutcome> {
|
|
11419
|
+
if (this.validationMode()) return "consumed";
|
|
10083
11420
|
const createdOutcome = await this.handleForumTopicCreatedUpdate(update);
|
|
10084
11421
|
if (createdOutcome !== "not-topic") return createdOutcome;
|
|
10085
11422
|
const topicOutcome = await this.handleForumTopicEdited(update);
|
|
@@ -10088,16 +11425,13 @@ export class TelegramNotificationDaemon {
|
|
|
10088
11425
|
await this.handleTelegramUpdate(update);
|
|
10089
11426
|
} catch (err) {
|
|
10090
11427
|
logger.error("notifications daemon: handleTelegramUpdate failed", { error: String(err) });
|
|
11428
|
+
return "retry";
|
|
10091
11429
|
}
|
|
10092
11430
|
return "consumed";
|
|
10093
11431
|
}
|
|
10094
11432
|
|
|
10095
11433
|
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.
|
|
11434
|
+
if (this.validationMode()) return;
|
|
10101
11435
|
if ((await this.handleForumTopicCreatedUpdate(update)) !== "not-topic") return;
|
|
10102
11436
|
if ((await this.handleForumTopicEdited(update)) !== "not-topic") return;
|
|
10103
11437
|
// A raw path is accepted only after the explicit direct-entry choice. The exact
|
|
@@ -10259,7 +11593,17 @@ export class TelegramNotificationDaemon {
|
|
|
10259
11593
|
// update_id dedupe are all enforced by decideThreadedInbound.
|
|
10260
11594
|
const raw = update as {
|
|
10261
11595
|
callback_query?: unknown;
|
|
10262
|
-
message?: {
|
|
11596
|
+
message?: {
|
|
11597
|
+
text?: unknown;
|
|
11598
|
+
reply_to_message?: { message_id?: unknown };
|
|
11599
|
+
photo?: unknown;
|
|
11600
|
+
document?: unknown;
|
|
11601
|
+
video?: unknown;
|
|
11602
|
+
audio?: unknown;
|
|
11603
|
+
chat?: { id?: unknown };
|
|
11604
|
+
message_thread_id?: unknown;
|
|
11605
|
+
message_id?: unknown;
|
|
11606
|
+
};
|
|
10263
11607
|
};
|
|
10264
11608
|
// A reply to a known ask message routes to that ask (below). Any OTHER
|
|
10265
11609
|
// message in a topic (plain text, or a reply to a non-ask message) is a
|
|
@@ -10269,6 +11613,23 @@ export class TelegramNotificationDaemon {
|
|
|
10269
11613
|
typeof raw.message?.text === "string" ? parseBtwCommand(raw.message.text, this.botUsername) : undefined;
|
|
10270
11614
|
const isAskReply =
|
|
10271
11615
|
replyTo !== undefined && (this.messageRoutes.has(String(replyTo)) || this.messageRoutes.has(Number(replyTo)));
|
|
11616
|
+
if (
|
|
11617
|
+
reservedBtw?.kind === "question" &&
|
|
11618
|
+
(raw.message?.photo || raw.message?.document || raw.message?.video || raw.message?.audio) &&
|
|
11619
|
+
typeof raw.message?.message_thread_id === "number" &&
|
|
11620
|
+
typeof raw.message.message_id === "number" &&
|
|
11621
|
+
String(raw.message.chat?.id) === this.opts.chatId &&
|
|
11622
|
+
(await this.pairedChatIsPrivate())
|
|
11623
|
+
) {
|
|
11624
|
+
if (!(await this.reserveSeenUpdateId((update as { update_id?: number }).update_id!))) return;
|
|
11625
|
+
await this.#sendBtwMessage({
|
|
11626
|
+
threadId: String(raw.message.message_thread_id),
|
|
11627
|
+
messageId: raw.message.message_id,
|
|
11628
|
+
text: BTW_USAGE_TEXT,
|
|
11629
|
+
isAuthoritative: () => true,
|
|
11630
|
+
});
|
|
11631
|
+
return;
|
|
11632
|
+
}
|
|
10272
11633
|
const directControl =
|
|
10273
11634
|
typeof raw.message?.text === "string"
|
|
10274
11635
|
? parseTelegramControlCommand(raw.message.text, this.botUsername)
|
|
@@ -10302,11 +11663,17 @@ export class TelegramNotificationDaemon {
|
|
|
10302
11663
|
return undefined;
|
|
10303
11664
|
return transportOwner.logicalSessionId;
|
|
10304
11665
|
}
|
|
11666
|
+
if (
|
|
11667
|
+
reservedBtw?.kind === "question" &&
|
|
11668
|
+
(raw.message?.photo || raw.message?.document || raw.message?.video || raw.message?.audio)
|
|
11669
|
+
)
|
|
11670
|
+
return topicSessionId;
|
|
10305
11671
|
return [...this.sessions.values()].some(
|
|
10306
11672
|
session =>
|
|
10307
|
-
session.sessionId === topicSessionId ||
|
|
10308
|
-
|
|
10309
|
-
|
|
11673
|
+
(session.sessionId === topicSessionId ||
|
|
11674
|
+
this.#logicalSessionId(session) === topicSessionId ||
|
|
11675
|
+
session.recoveryLease?.logicalSessionId === topicSessionId) &&
|
|
11676
|
+
session.ws.readyState !== WebSocket.CLOSED,
|
|
10310
11677
|
)
|
|
10311
11678
|
? undefined
|
|
10312
11679
|
: topicSessionId;
|
|
@@ -10319,6 +11686,7 @@ export class TelegramNotificationDaemon {
|
|
|
10319
11686
|
const preliminaryControl = inbound.attachment
|
|
10320
11687
|
? { kind: "none" as const }
|
|
10321
11688
|
: parseTelegramControlCommand(inbound.text, this.botUsername);
|
|
11689
|
+
const reservedBtw = parseBtwCommand(inbound.text, this.botUsername);
|
|
10322
11690
|
if (preliminaryControl.kind === "ignored") return;
|
|
10323
11691
|
const session =
|
|
10324
11692
|
this.logicalSessionOwners.get(inbound.sessionId) ??
|
|
@@ -10348,6 +11716,20 @@ export class TelegramNotificationDaemon {
|
|
|
10348
11716
|
topicLeaseAllows();
|
|
10349
11717
|
const routeLeaseAllows = (): boolean =>
|
|
10350
11718
|
topicLeaseAllows() && (!session || (!!routeLease && this.#leaseTokenAllows(routeLease)));
|
|
11719
|
+
if (
|
|
11720
|
+
/^\/btw(?:\s|$)/i.test(inbound.text) &&
|
|
11721
|
+
inbound.attachment &&
|
|
11722
|
+
(!session || session.ws.readyState !== WebSocket.OPEN || !routeLease)
|
|
11723
|
+
) {
|
|
11724
|
+
if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
|
|
11725
|
+
await this.#sendBtwMessage({
|
|
11726
|
+
threadId: inbound.threadId,
|
|
11727
|
+
messageId: inbound.messageId,
|
|
11728
|
+
text: BTW_USAGE_TEXT,
|
|
11729
|
+
isAuthoritative: () => true,
|
|
11730
|
+
});
|
|
11731
|
+
return;
|
|
11732
|
+
}
|
|
10351
11733
|
const reserveRouteUpdate = async (): Promise<boolean> => {
|
|
10352
11734
|
if (!(await this.reserveSeenUpdateId(inbound.updateId))) return false;
|
|
10353
11735
|
if (routeLeaseAllows()) return true;
|
|
@@ -10355,9 +11737,18 @@ export class TelegramNotificationDaemon {
|
|
|
10355
11737
|
return false;
|
|
10356
11738
|
};
|
|
10357
11739
|
|
|
10358
|
-
if (session && !routeLease) return;
|
|
10359
|
-
if (
|
|
10360
|
-
|
|
11740
|
+
if (session && !routeLease && !(reservedBtw?.kind === "question" && inbound.attachment)) return;
|
|
11741
|
+
if (
|
|
11742
|
+
preliminaryControl.kind === "invalid" &&
|
|
11743
|
+
session?.ws.readyState !== WebSocket.OPEN &&
|
|
11744
|
+
!(reservedBtw?.kind === "question" && inbound.attachment)
|
|
11745
|
+
)
|
|
11746
|
+
return;
|
|
11747
|
+
if (
|
|
11748
|
+
preliminaryControl.kind === "command" &&
|
|
11749
|
+
session?.ws.readyState !== WebSocket.OPEN &&
|
|
11750
|
+
!(reservedBtw?.kind === "question" && inbound.attachment)
|
|
11751
|
+
) {
|
|
10361
11752
|
if (await reserveRouteUpdate()) {
|
|
10362
11753
|
try {
|
|
10363
11754
|
await this.botApi.call("sendMessage", {
|
|
@@ -10374,18 +11765,22 @@ export class TelegramNotificationDaemon {
|
|
|
10374
11765
|
}
|
|
10375
11766
|
return;
|
|
10376
11767
|
}
|
|
10377
|
-
const reservedBtw = parseBtwCommand(inbound.text, this.botUsername);
|
|
10378
11768
|
if (reservedBtw?.kind === "ignored") {
|
|
10379
11769
|
await this.rememberSeenUpdateId(inbound.updateId);
|
|
10380
11770
|
return;
|
|
10381
11771
|
}
|
|
10382
11772
|
if (reservedBtw?.kind === "question" && (!reservedBtw.question || inbound.attachment)) {
|
|
10383
|
-
|
|
11773
|
+
const reserved =
|
|
11774
|
+
session?.ws.readyState === WebSocket.OPEN
|
|
11775
|
+
? await reserveRouteUpdate()
|
|
11776
|
+
: await this.reserveSeenUpdateId(inbound.updateId);
|
|
11777
|
+
if (!reserved) return;
|
|
11778
|
+
if (session?.ws.readyState !== WebSocket.OPEN) await this.flushPool();
|
|
10384
11779
|
await this.#sendBtwMessage({
|
|
10385
11780
|
threadId: inbound.threadId,
|
|
10386
11781
|
messageId: inbound.messageId,
|
|
10387
11782
|
text: BTW_USAGE_TEXT,
|
|
10388
|
-
isAuthoritative: routeLeaseAllows,
|
|
11783
|
+
isAuthoritative: session?.ws.readyState === WebSocket.OPEN ? routeLeaseAllows : () => true,
|
|
10389
11784
|
});
|
|
10390
11785
|
return;
|
|
10391
11786
|
}
|
|
@@ -10627,19 +12022,102 @@ export class TelegramNotificationDaemon {
|
|
|
10627
12022
|
const callbackId = (update as { callback_query?: { id?: unknown } }).callback_query?.id;
|
|
10628
12023
|
if (await this.#handleAdoptionPickerCallback(update, callbackId)) return;
|
|
10629
12024
|
if (await this.#handleModelChoiceCallback(update, callbackId)) return;
|
|
12025
|
+
const callbackAlias = (update as { callback_query?: { data?: unknown } }).callback_query?.data;
|
|
12026
|
+
const stagedActivation =
|
|
12027
|
+
typeof callbackAlias === "string" ? this.#stagedCallbackActivations.get(callbackAlias) : undefined;
|
|
12028
|
+
if (stagedActivation && !(await stagedActivation.settled)) {
|
|
12029
|
+
await this.sendStaleGuidance(callbackId);
|
|
12030
|
+
return;
|
|
12031
|
+
}
|
|
10630
12032
|
const decision = routeInboundUpdate(update, {
|
|
10631
12033
|
aliasTable: this.aliasTable,
|
|
10632
12034
|
messageRoutes: this.messageRoutes,
|
|
10633
12035
|
pairedChatId: this.opts.chatId,
|
|
10634
12036
|
});
|
|
10635
12037
|
if (decision.kind === "reply") {
|
|
12038
|
+
const callbackLease =
|
|
12039
|
+
typeof callbackAlias === "string" ? this.#callbackAliasLeases.get(callbackAlias) : undefined;
|
|
12040
|
+
const callbackPending =
|
|
12041
|
+
typeof callbackAlias === "string" ? this.#callbackAliasPending.get(callbackAlias) : undefined;
|
|
12042
|
+
const callbackTopic =
|
|
12043
|
+
typeof callbackAlias === "string" ? this.#callbackAliasTopics.get(callbackAlias) : undefined;
|
|
10636
12044
|
const session = this.logicalSessionOwners.get(decision.sessionId) ?? this.sessions.get(decision.sessionId);
|
|
12045
|
+
const pendingAction = session?.pending.get(decision.actionId);
|
|
10637
12046
|
if (
|
|
12047
|
+
(typeof callbackAlias === "string" &&
|
|
12048
|
+
(!callbackLease ||
|
|
12049
|
+
!callbackPending ||
|
|
12050
|
+
callbackPending !== pendingAction ||
|
|
12051
|
+
!this.#leaseTokenAllows(callbackLease) ||
|
|
12052
|
+
(callbackTopic
|
|
12053
|
+
? !this.topicLeaseIsCurrent(callbackTopic)
|
|
12054
|
+
: this.topics.get(decision.sessionId) !== undefined))) ||
|
|
10638
12055
|
session?.ws.readyState !== WebSocket.OPEN ||
|
|
10639
|
-
!
|
|
10640
|
-
!this.#leaseAllows(session, decision.sessionId)
|
|
10641
|
-
|
|
10642
|
-
|
|
12056
|
+
!pendingAction ||
|
|
12057
|
+
!this.#leaseAllows(session, decision.sessionId)
|
|
12058
|
+
) {
|
|
12059
|
+
if (
|
|
12060
|
+
typeof callbackAlias === "string" &&
|
|
12061
|
+
callbackPending !== undefined &&
|
|
12062
|
+
callbackPending !== pendingAction &&
|
|
12063
|
+
this.#revokeCallbackAlias(callbackAlias)
|
|
12064
|
+
)
|
|
12065
|
+
await this.persistAliases();
|
|
12066
|
+
await this.sendStaleGuidance(callbackId);
|
|
12067
|
+
return;
|
|
12068
|
+
}
|
|
12069
|
+
const consumedAliases: Array<
|
|
12070
|
+
[
|
|
12071
|
+
string,
|
|
12072
|
+
CallbackRoute,
|
|
12073
|
+
{ session: SessionSocket; token: number; logicalSessionId: string } | undefined,
|
|
12074
|
+
{ sessionId: string; actionId: string } | undefined,
|
|
12075
|
+
TopicAuthorityLease | undefined,
|
|
12076
|
+
]
|
|
12077
|
+
> = [];
|
|
12078
|
+
// Consume only controls from this exact rendered ask. Predecessor receipts
|
|
12079
|
+
// remain in the terminalization backlog until their authoritative outcome.
|
|
12080
|
+
for (const [alias, route] of this.aliasTable.entries()) {
|
|
12081
|
+
if (
|
|
12082
|
+
route.sessionId === decision.sessionId &&
|
|
12083
|
+
route.actionId === decision.actionId &&
|
|
12084
|
+
this.#callbackAliasPending.get(alias) === pendingAction
|
|
12085
|
+
) {
|
|
12086
|
+
consumedAliases.push([
|
|
12087
|
+
alias,
|
|
12088
|
+
route,
|
|
12089
|
+
this.#callbackAliasLeases.get(alias),
|
|
12090
|
+
this.#callbackAliasPending.get(alias),
|
|
12091
|
+
this.#callbackAliasTopics.get(alias),
|
|
12092
|
+
]);
|
|
12093
|
+
this.aliasTable.delete(alias);
|
|
12094
|
+
this.#callbackAliasLeases.delete(alias);
|
|
12095
|
+
this.#callbackAliasPending.delete(alias);
|
|
12096
|
+
this.#callbackAliasTopics.delete(alias);
|
|
12097
|
+
}
|
|
12098
|
+
}
|
|
12099
|
+
try {
|
|
12100
|
+
await this.persistAliases();
|
|
12101
|
+
} catch {
|
|
12102
|
+
for (const [alias, route, lease, pending, topic] of consumedAliases) {
|
|
12103
|
+
this.aliasTable.activate(alias, route);
|
|
12104
|
+
if (lease) this.#callbackAliasLeases.set(alias, lease);
|
|
12105
|
+
if (pending) this.#callbackAliasPending.set(alias, pending);
|
|
12106
|
+
if (topic) this.#callbackAliasTopics.set(alias, topic);
|
|
12107
|
+
}
|
|
12108
|
+
throw new Error("callback receipt consumption was not durably persisted");
|
|
12109
|
+
}
|
|
12110
|
+
if (
|
|
12111
|
+
session.ws.readyState !== WebSocket.OPEN ||
|
|
12112
|
+
session.pending.get(decision.actionId) !== pendingAction ||
|
|
12113
|
+
(typeof callbackAlias === "string" &&
|
|
12114
|
+
(!callbackPending ||
|
|
12115
|
+
callbackPending !== pendingAction ||
|
|
12116
|
+
!callbackLease ||
|
|
12117
|
+
!this.#leaseTokenAllows(callbackLease) ||
|
|
12118
|
+
(callbackTopic
|
|
12119
|
+
? !this.topicLeaseIsCurrent(callbackTopic)
|
|
12120
|
+
: this.topics.get(decision.sessionId) !== undefined)))
|
|
10643
12121
|
) {
|
|
10644
12122
|
await this.sendStaleGuidance(callbackId);
|
|
10645
12123
|
return;
|
|
@@ -10694,6 +12172,12 @@ export class TelegramNotificationDaemon {
|
|
|
10694
12172
|
// Runtime callers can bypass TypeScript's option type. Without a valid bot
|
|
10695
12173
|
// token, there is no authenticated daemon identity or lifecycle authority.
|
|
10696
12174
|
if (!validBotToken(this.opts.botToken)) return;
|
|
12175
|
+
if (this.validationMode()) {
|
|
12176
|
+
if (!/^-100\d+$/.test(this.opts.validationTestSupergroupChatId!))
|
|
12177
|
+
throw new Error("validation forum destination must be a negative -100... chat ID");
|
|
12178
|
+
if ((await this.resolvePairedChatPrivacy()) !== "validation-forum")
|
|
12179
|
+
throw new Error("validation forum destination must be the exact supergroup forum returned by getChat");
|
|
12180
|
+
}
|
|
10697
12181
|
let ownershipProved = false;
|
|
10698
12182
|
try {
|
|
10699
12183
|
const renewed = await renewDaemonHeartbeat({
|
|
@@ -10713,19 +12197,22 @@ export class TelegramNotificationDaemon {
|
|
|
10713
12197
|
if (!this.running) return;
|
|
10714
12198
|
// Self-heal durable notification state before any scan/poll work so
|
|
10715
12199
|
// `daemon reload` recovers dead roots + leak artifacts (#2956).
|
|
10716
|
-
|
|
10717
|
-
|
|
10718
|
-
|
|
10719
|
-
|
|
10720
|
-
|
|
10721
|
-
|
|
10722
|
-
|
|
10723
|
-
|
|
12200
|
+
if (!this.validationMode()) {
|
|
12201
|
+
try {
|
|
12202
|
+
await healTelegramDaemonNotificationState({
|
|
12203
|
+
settings: this.opts.settings,
|
|
12204
|
+
fs: this.fsImpl,
|
|
12205
|
+
now: this.opts.now,
|
|
12206
|
+
});
|
|
12207
|
+
} catch (error) {
|
|
12208
|
+
logger.warn(`notifications: startup self-heal failed: ${sanitizeDiagnostic(String(error))}`);
|
|
12209
|
+
}
|
|
10724
12210
|
}
|
|
12211
|
+
await this.loadAliases();
|
|
10725
12212
|
// Owner-only: start lifecycle control immediately after ownership proof,
|
|
10726
12213
|
// before timers or pre-poll startup work can invalidate this run.
|
|
10727
12214
|
// Best-effort; notification delivery remains available on failure.
|
|
10728
|
-
await this.startLifecycleControl();
|
|
12215
|
+
if (!this.validationMode()) await this.startLifecycleControl();
|
|
10729
12216
|
// A stop may arrive while lifecycle startup awaits its control endpoint.
|
|
10730
12217
|
// Do not re-enable runtime work after that stop; close the partial server.
|
|
10731
12218
|
if (!this.running) return;
|
|
@@ -10734,14 +12221,19 @@ export class TelegramNotificationDaemon {
|
|
|
10734
12221
|
this.startFlushTimer();
|
|
10735
12222
|
this.startScanTimer();
|
|
10736
12223
|
this.startTypingTimer();
|
|
10737
|
-
|
|
10738
|
-
|
|
10739
|
-
|
|
12224
|
+
if (!this.validationMode()) {
|
|
12225
|
+
await this.refreshBotIdentity();
|
|
12226
|
+
await this.registerBotCommands();
|
|
12227
|
+
}
|
|
10740
12228
|
await this.loadTopics();
|
|
10741
|
-
|
|
10742
|
-
|
|
10743
|
-
|
|
10744
|
-
|
|
12229
|
+
if (!this.validationMode()) {
|
|
12230
|
+
await this.loadAdoptionIntents();
|
|
12231
|
+
this.startAdoptionSweepTimer();
|
|
12232
|
+
}
|
|
12233
|
+
if (!this.validationMode()) {
|
|
12234
|
+
await this.loadSeenUpdateIds();
|
|
12235
|
+
await this.replyStore.load();
|
|
12236
|
+
}
|
|
10745
12237
|
await this.runScan();
|
|
10746
12238
|
let idleSince = this.runtime.now();
|
|
10747
12239
|
while (this.running) {
|
|
@@ -10807,37 +12299,43 @@ export class TelegramNotificationDaemon {
|
|
|
10807
12299
|
}
|
|
10808
12300
|
this.effects.beginShutdown();
|
|
10809
12301
|
this.#deliveryAbort.abort();
|
|
12302
|
+
this.stopFlushTimer();
|
|
12303
|
+
this.stopScanTimer();
|
|
12304
|
+
this.stopTypingTimer();
|
|
12305
|
+
this.stopAdoptionSweepTimer();
|
|
12306
|
+
this.stopLifecycleControl();
|
|
10810
12307
|
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
|
-
|
|
12308
|
+
const sessionEffectsQuiesced = await this.effects.join(BTW_SHUTDOWN_JOIN_MS);
|
|
12309
|
+
let completed = false;
|
|
12310
|
+
if (sessionEffectsQuiesced) {
|
|
12311
|
+
const shutdown = this.effects.allowTerminal(async () => {
|
|
12312
|
+
if (toolShutdownError) throw toolShutdownError;
|
|
12313
|
+
await this.#drainBtwTurns();
|
|
12314
|
+
await this.toolTerminalizationChain;
|
|
12315
|
+
await this.cleanupAllAttachmentDirs();
|
|
12316
|
+
if (!this.validationMode()) {
|
|
12317
|
+
await this.persistAliases();
|
|
12318
|
+
await this.persistSeenUpdateIds();
|
|
12319
|
+
await this.opts.control?.clear?.(this.opts.ownerId);
|
|
12320
|
+
}
|
|
12321
|
+
await this.persistTopics();
|
|
12322
|
+
persisted = true;
|
|
12323
|
+
});
|
|
12324
|
+
const deadline = Promise.withResolvers<boolean>();
|
|
12325
|
+
const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
|
|
12326
|
+
completed = await Promise.race([
|
|
12327
|
+
shutdown.then(
|
|
12328
|
+
() => true,
|
|
12329
|
+
error => {
|
|
12330
|
+
logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
|
|
12331
|
+
return false;
|
|
12332
|
+
},
|
|
12333
|
+
),
|
|
12334
|
+
deadline.promise,
|
|
12335
|
+
]);
|
|
12336
|
+
clearTimeout(deadlineTimer);
|
|
12337
|
+
}
|
|
12338
|
+
const quiesced = completed && heartbeatJoined && sessionEffectsQuiesced;
|
|
10841
12339
|
if (!quiesced || !persisted) {
|
|
10842
12340
|
logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
|
|
10843
12341
|
} else {
|