@gajae-code/coding-agent 0.11.3 → 0.11.5
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 +47 -0
- package/dist/types/cli/notify-cli.d.ts +5 -1
- package/dist/types/cli/worktree-cli.d.ts +8 -25
- package/dist/types/commands/gc.d.ts +4 -0
- package/dist/types/commands/worktree.d.ts +7 -32
- package/dist/types/config/settings-schema.d.ts +20 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
- package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
- package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
- package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
- package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/owner.d.ts +3 -1
- package/dist/types/internal-urls/local-protocol.d.ts +18 -0
- package/dist/types/main.d.ts +2 -2
- package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +8 -3
- package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-transcript-format.d.ts +77 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
- package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
- package/dist/types/modes/controllers/input-controller.d.ts +8 -2
- package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
- package/dist/types/plan-mode/approved-plan.d.ts +1 -0
- package/dist/types/runtime-mcp/manager.d.ts +2 -0
- package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
- package/dist/types/sdk/broker/broker.d.ts +1 -0
- package/dist/types/sdk/broker/discovery.d.ts +10 -0
- package/dist/types/sdk/broker/ensure.d.ts +24 -1
- package/dist/types/sdk/broker/internal.d.ts +3 -0
- package/dist/types/sdk/broker/session-index.d.ts +12 -0
- package/dist/types/sdk/broker/state-version.d.ts +2 -1
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +39 -4
- package/dist/types/sdk/bus/config-commands.d.ts +8 -0
- package/dist/types/sdk/bus/config.d.ts +22 -0
- package/dist/types/sdk/bus/index.d.ts +162 -2
- package/dist/types/sdk/bus/notification-service.d.ts +71 -8
- package/dist/types/sdk/bus/session-control.d.ts +11 -8
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
- package/dist/types/sdk/session.d.ts +16 -1
- package/dist/types/secrets/index.d.ts +4 -2
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +31 -2
- package/dist/types/session/artifacts.d.ts +30 -8
- package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
- package/dist/types/session/session-manager.d.ts +88 -18
- package/dist/types/session/session-storage.d.ts +27 -0
- package/dist/types/session/streaming-output.d.ts +4 -0
- package/dist/types/task/executor.d.ts +9 -0
- package/dist/types/tool-discovery/tool-index.d.ts +2 -0
- package/dist/types/tools/ask.d.ts +87 -12
- package/dist/types/tools/bash-skill-urls.d.ts +2 -2
- package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
- package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
- package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/path-utils.d.ts +9 -14
- package/dist/types/tools/render-utils.d.ts +1 -8
- package/dist/types/tools/resource-gc.d.ts +14 -0
- package/dist/types/utils/image-loading.d.ts +23 -0
- package/dist/types/utils/image-resize.d.ts +2 -0
- package/dist/types/utils/pasted-image-loading.d.ts +39 -0
- package/dist/types/utils/pasted-image-path.d.ts +21 -19
- package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
- package/dist/types/web/scrapers/types.d.ts +1 -5
- package/package.json +25 -9
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/scripts/run-threat-model-check.ts +60 -0
- package/src/cli/notify-cli.ts +40 -18
- package/src/cli/worktree-cli.ts +9 -287
- package/src/cli.ts +10 -9
- package/src/commands/gc.ts +11 -1
- package/src/commands/sdk.ts +10 -4
- package/src/commands/worktree.ts +10 -53
- package/src/config/model-profiles.ts +15 -15
- package/src/config/settings-schema.ts +17 -0
- package/src/daemon/runtime.ts +2 -2
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
- package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
- package/src/defaults/gjc/skills/team/SKILL.md +7 -9
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
- package/src/eval/js/shared/runtime.ts +1 -1
- package/src/eval/py/index.ts +2 -2
- package/src/export/html/template.generated.ts +1 -1
- package/src/extensibility/plugins/marketplace/manager.ts +94 -10
- package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
- package/src/gjc-runtime/deep-interview-state.ts +127 -3
- package/src/gjc-runtime/gc-render.ts +19 -1
- package/src/gjc-runtime/gc-runtime.ts +68 -3
- package/src/gjc-runtime/launch-tmux.ts +61 -6
- package/src/gjc-runtime/managed-owner-admission.ts +352 -0
- package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
- package/src/gjc-runtime/state-argv.ts +292 -0
- package/src/gjc-runtime/state-runtime.ts +80 -222
- package/src/gjc-runtime/team-runtime.ts +1 -1
- package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
- package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
- package/src/harness-control-plane/owner.ts +59 -3
- package/src/hindsight/client.ts +65 -2
- package/src/internal-urls/agent-protocol.ts +76 -5
- package/src/internal-urls/docs-index.generated.ts +7 -6
- package/src/internal-urls/local-protocol.ts +416 -18
- package/src/lsp/index.ts +4 -88
- package/src/main.ts +37 -13
- package/src/modes/acp/acp-agent.ts +8 -0
- package/src/modes/components/ansi-display-validator.ts +54 -0
- package/src/modes/components/custom-editor.ts +103 -32
- package/src/modes/components/custom-provider-wizard.ts +26 -1
- package/src/modes/components/notifications-settings-editor.ts +26 -2
- package/src/modes/components/session-observer-overlay.ts +40 -37
- package/src/modes/components/settings-selector.ts +3 -1
- package/src/modes/components/tool-transcript-format.ts +393 -0
- package/src/modes/components/transcript-viewer-overlay.ts +63 -1
- package/src/modes/controllers/goal-mode-controller.ts +20 -1
- package/src/modes/controllers/input-controller.ts +98 -37
- package/src/modes/controllers/plan-mode-controller.ts +2 -0
- package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +71 -9
- package/src/modes/interactive-mode.ts +14 -2
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
- package/src/plan-mode/approved-plan.ts +3 -1
- package/src/prompts/agents/architect.md +1 -0
- package/src/prompts/agents/critic.md +1 -0
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/system-prompt.md +2 -1
- package/src/prompts/tools/lsp.md +3 -3
- package/src/runtime-mcp/manager.ts +16 -1
- package/src/runtime-mcp/oauth-discovery.ts +116 -26
- package/src/sdk/broker/broker.ts +139 -32
- package/src/sdk/broker/discovery.ts +125 -0
- package/src/sdk/broker/ensure.ts +103 -19
- package/src/sdk/broker/internal.ts +10 -0
- package/src/sdk/broker/session-index.ts +309 -38
- package/src/sdk/broker/state-version.ts +5 -2
- package/src/sdk/bus/chat-daemon-cli.ts +72 -10
- package/src/sdk/bus/chat-daemon-control.ts +526 -130
- package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
- package/src/sdk/bus/config-commands.ts +22 -0
- package/src/sdk/bus/config.ts +37 -0
- package/src/sdk/bus/index.ts +384 -92
- package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
- package/src/sdk/bus/notification-service.ts +284 -62
- package/src/sdk/bus/recent-activity.ts +14 -5
- package/src/sdk/bus/session-control.ts +150 -44
- package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
- package/src/sdk/bus/telegram-daemon-contract.ts +6 -3
- package/src/sdk/bus/telegram-daemon-control.ts +294 -76
- package/src/sdk/bus/telegram-daemon.ts +1842 -281
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/session.ts +155 -27
- package/src/secrets/index.ts +53 -10
- package/src/secrets/obfuscator.ts +21 -18
- package/src/secrets/regex.ts +2 -0
- package/src/session/agent-session.ts +495 -253
- package/src/session/artifacts.ts +188 -23
- package/src/session/context-estimation.ts +7 -2
- package/src/session/internal/managed-session-scope.ts +215 -39
- package/src/session/internal/managed-session-storage.ts +1152 -46
- package/src/session/session-manager.ts +1055 -148
- package/src/session/session-storage.ts +109 -11
- package/src/session/streaming-output.ts +6 -0
- package/src/task/executor.ts +71 -26
- package/src/task/index.ts +63 -20
- package/src/tool-discovery/tool-index.ts +18 -0
- package/src/tools/ask.ts +329 -57
- package/src/tools/bash-allowed-prefixes.ts +79 -29
- package/src/tools/bash-skill-urls.ts +14 -3
- package/src/tools/bash.ts +2 -2
- package/src/tools/browser/launch.ts +3 -1
- package/src/tools/browser/readable.ts +1 -1
- package/src/tools/browser/screenshot-format.ts +34 -0
- package/src/tools/browser/tab-protocol.ts +3 -0
- package/src/tools/browser/tab-supervisor.ts +125 -122
- package/src/tools/browser/tab-worker-entry.ts +33 -14
- package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
- package/src/tools/browser/tab-worker-smoke.ts +87 -0
- package/src/tools/browser/tab-worker.ts +7 -5
- package/src/tools/index.ts +4 -0
- package/src/tools/path-utils.ts +42 -35
- package/src/tools/plan-mode-guard.ts +1 -0
- package/src/tools/read.ts +7 -1
- package/src/tools/render-utils.ts +1 -27
- package/src/tools/resource-gc.ts +123 -34
- package/src/tools/sqlite-reader.ts +3 -2
- package/src/tools/subagent.ts +2 -0
- package/src/utils/image-loading.ts +85 -34
- package/src/utils/image-resize.ts +16 -9
- package/src/utils/pasted-image-loading.ts +514 -0
- package/src/utils/pasted-image-path.ts +223 -98
- package/src/web/scrapers/html-to-markdown.ts +68 -0
- package/src/web/scrapers/readthedocs.ts +1 -1
- package/src/web/scrapers/types.ts +1 -68
- package/src/web/search/providers/openai-compatible.ts +76 -23
|
@@ -9,11 +9,33 @@ import { withFileLock } from "../../config/file-lock";
|
|
|
9
9
|
import type { Settings } from "../../config/settings";
|
|
10
10
|
import type { DaemonRuntimeInfo } from "../../daemon/control-types";
|
|
11
11
|
import { resolveGjcRuntimeSpawnInfo } from "../../daemon/runtime";
|
|
12
|
+
import { isProcessIncarnation, processIncarnation } from "../broker/process-incarnation";
|
|
12
13
|
import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
parseInThreadConfigCommand,
|
|
16
|
+
parseRichToggleCommand,
|
|
17
|
+
parseTelegramControlCommand,
|
|
18
|
+
parseToolActivityToggleCommand,
|
|
19
|
+
} from "./config-commands";
|
|
14
20
|
import { daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
|
|
15
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
acquireDaemonTransitionLock,
|
|
23
|
+
type DaemonTransitionLock,
|
|
24
|
+
daemonTransitionLockIsHeld,
|
|
25
|
+
exactUnlinkNotificationFile,
|
|
26
|
+
type NotificationEndpointFile,
|
|
27
|
+
type NotificationEndpointFileIdentity,
|
|
28
|
+
type NotificationExactUnlinkResult,
|
|
29
|
+
readNotificationEndpointFile,
|
|
30
|
+
releaseDaemonTransitionLock,
|
|
31
|
+
sanitizeDiagnostic,
|
|
32
|
+
} from "./notification-service";
|
|
16
33
|
import { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
|
|
34
|
+
import {
|
|
35
|
+
type DaemonProcessReference,
|
|
36
|
+
type TelegramDaemonControlDeps,
|
|
37
|
+
TelegramDaemonController,
|
|
38
|
+
} from "./telegram-daemon-control";
|
|
17
39
|
import { withTelegramSetupLease } from "./telegram-setup";
|
|
18
40
|
|
|
19
41
|
export { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
|
|
@@ -79,9 +101,18 @@ export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "bl
|
|
|
79
101
|
/** Detailed result for orchestration that must distinguish a #2028 handoff from a fresh spawn. */
|
|
80
102
|
export type EnsureTelegramDaemonDetailedResult = "spawned" | "reloaded" | "attached" | "disabled" | "blocked_identity";
|
|
81
103
|
|
|
104
|
+
export type TelegramDaemonOwnershipPhase = "provisional" | "ready" | "retired";
|
|
105
|
+
|
|
82
106
|
export interface DaemonState {
|
|
83
107
|
pid: number;
|
|
108
|
+
/** OS process-start provenance; mandatory for PID-authorized ownership actions. */
|
|
109
|
+
incarnation: string;
|
|
84
110
|
ownerId: string;
|
|
111
|
+
/** Unique, durable identity for one ownership acquisition. */
|
|
112
|
+
acquisitionId?: string;
|
|
113
|
+
/** A provisional owner is physical-live but MUST NOT be attached as ready. */
|
|
114
|
+
ownershipPhase?: TelegramDaemonOwnershipPhase;
|
|
115
|
+
|
|
85
116
|
tokenFingerprint: string;
|
|
86
117
|
chatId: string;
|
|
87
118
|
startedAt: number;
|
|
@@ -95,10 +126,8 @@ export interface DaemonState {
|
|
|
95
126
|
version: 1;
|
|
96
127
|
/**
|
|
97
128
|
* Operational daemon generation of the process that owns the lock, distinct
|
|
98
|
-
* from the persisted state-schema {@link DaemonState.version}
|
|
99
|
-
* wire
|
|
100
|
-
* freshly-upgraded host can detect — and reload — a still-live pre-upgrade
|
|
101
|
-
* daemon whose schema version is unchanged. Absent on pre-generation state.
|
|
129
|
+
* from both the persisted state-schema {@link DaemonState.version} and the
|
|
130
|
+
* notification wire protocol version. Absent on pre-generation state.
|
|
102
131
|
*/
|
|
103
132
|
generation?: number;
|
|
104
133
|
stoppedAt?: number;
|
|
@@ -112,9 +141,13 @@ export interface TelegramDaemonFs {
|
|
|
112
141
|
open(path: string, flags: string, mode?: number): Promise<{ close(): Promise<void> }>;
|
|
113
142
|
readdir(path: string): Promise<string[]>;
|
|
114
143
|
chmod(path: string, mode: number): Promise<void>;
|
|
144
|
+
stat?(path: string): Promise<{ mtimeMs: number }>;
|
|
145
|
+
readEndpointFile?(path: string): Promise<NotificationEndpointFile>;
|
|
146
|
+
exactUnlink?(path: string, identity: NotificationEndpointFileIdentity): Promise<NotificationExactUnlinkResult>;
|
|
115
147
|
}
|
|
116
148
|
|
|
117
149
|
export interface SpawnResult {
|
|
150
|
+
pid?: number;
|
|
118
151
|
unref?: () => void;
|
|
119
152
|
}
|
|
120
153
|
|
|
@@ -123,6 +156,10 @@ export interface TelegramDaemonDeps {
|
|
|
123
156
|
now?: () => number;
|
|
124
157
|
pid?: number;
|
|
125
158
|
pidAlive?: (pid: number) => boolean;
|
|
159
|
+
/** Opens an identity-stable process authority for destructive lifecycle operations. */
|
|
160
|
+
processReference?: (pid: number) => DaemonProcessReference | undefined;
|
|
161
|
+
/** Returns immutable process-start provenance, or undefined when unsupported. */
|
|
162
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
126
163
|
spawn?: (
|
|
127
164
|
command: string,
|
|
128
165
|
args: string[],
|
|
@@ -140,6 +177,8 @@ export interface TelegramDaemonDeps {
|
|
|
140
177
|
sendSignal?: (pid: number, signal: NodeJS.Signals) => void;
|
|
141
178
|
sleep?: (ms: number) => Promise<void>;
|
|
142
179
|
waitStepMs?: number;
|
|
180
|
+
/** Bounded startup-readiness timeout; injectable for deterministic handoff tests. */
|
|
181
|
+
readinessTimeoutMs?: number;
|
|
143
182
|
}
|
|
144
183
|
|
|
145
184
|
export const HEARTBEAT_INTERVAL_MS = 5_000;
|
|
@@ -153,16 +192,22 @@ export const ASK_CONTROLS_CAPABILITY = "ask_controls_v1";
|
|
|
153
192
|
/** Capability required for tool lifecycle and reasoning-summary frames. */
|
|
154
193
|
export const TOOL_ACTIVITY_CAPABILITY = "tool_activity_v1";
|
|
155
194
|
|
|
156
|
-
const nodeFs: TelegramDaemonFs =
|
|
195
|
+
const nodeFs: TelegramDaemonFs = {
|
|
196
|
+
...(fs.promises as unknown as TelegramDaemonFs),
|
|
197
|
+
readEndpointFile: readNotificationEndpointFile,
|
|
198
|
+
exactUnlink: async (file, identity) =>
|
|
199
|
+
exactUnlinkNotificationFile(file, identity, `.gjc-delete-daemon-transition-${crypto.randomUUID()}.json`),
|
|
200
|
+
};
|
|
157
201
|
|
|
158
202
|
/**
|
|
159
|
-
* Durably persist a
|
|
160
|
-
* `flushOrThrow()`, which rejects on a failed config.yml
|
|
161
|
-
* fire-and-forget whose background save swallows errors).
|
|
162
|
-
* settings has no `flushOrThrow` — its `set()` already
|
|
163
|
-
* on failure — so its plain `flush()` no-op drain is
|
|
203
|
+
* Durably persist a daemon-local Telegram delivery toggle. A real
|
|
204
|
+
* {@link Settings} exposes `flushOrThrow()`, which rejects on a failed config.yml
|
|
205
|
+
* write (its `set()` is a fire-and-forget whose background save swallows errors).
|
|
206
|
+
* The lightweight daemon settings has no `flushOrThrow` — its `set()` already
|
|
207
|
+
* wrote durably and throws on failure — so its plain `flush()` no-op drain is
|
|
208
|
+
* sufficient.
|
|
164
209
|
*/
|
|
165
|
-
async function
|
|
210
|
+
async function flushTelegramToggleSettings(settings: Settings): Promise<void> {
|
|
166
211
|
if (typeof settings.flushOrThrow === "function") {
|
|
167
212
|
await settings.flushOrThrow();
|
|
168
213
|
return;
|
|
@@ -421,24 +466,14 @@ async function writeJsonAtomic(fsImpl: TelegramDaemonFs, file: string, data: unk
|
|
|
421
466
|
await fsImpl.rename(tmp, file);
|
|
422
467
|
}
|
|
423
468
|
|
|
424
|
-
async function tryOpenWx(fsImpl: TelegramDaemonFs, file: string): Promise<boolean> {
|
|
425
|
-
try {
|
|
426
|
-
const handle = await fsImpl.open(file, "wx", 0o600);
|
|
427
|
-
await handle.close();
|
|
428
|
-
return true;
|
|
429
|
-
} catch (error) {
|
|
430
|
-
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
431
|
-
throw error;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
469
|
function validDaemonPid(pid: unknown): pid is number {
|
|
435
470
|
return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0;
|
|
436
471
|
}
|
|
437
472
|
|
|
438
|
-
async function tryCreateOwnershipLock(
|
|
473
|
+
export async function tryCreateOwnershipLock(
|
|
439
474
|
fsImpl: TelegramDaemonFs,
|
|
440
475
|
file: string,
|
|
441
|
-
initialization:
|
|
476
|
+
initialization: OwnershipLockMetadata,
|
|
442
477
|
): Promise<boolean> {
|
|
443
478
|
try {
|
|
444
479
|
await fsImpl.writeFile(file, `${JSON.stringify(initialization)}\n`, { mode: 0o600, flag: "wx" });
|
|
@@ -450,6 +485,270 @@ async function tryCreateOwnershipLock(
|
|
|
450
485
|
}
|
|
451
486
|
}
|
|
452
487
|
|
|
488
|
+
type OwnershipLockMetadata = {
|
|
489
|
+
pid: number;
|
|
490
|
+
incarnation: string;
|
|
491
|
+
ownerId?: string;
|
|
492
|
+
acquisitionId?: string;
|
|
493
|
+
startedAt: number;
|
|
494
|
+
};
|
|
495
|
+
type LegacyOwnershipLockMetadata = {
|
|
496
|
+
pid: number;
|
|
497
|
+
incarnation?: string;
|
|
498
|
+
startedAt: number;
|
|
499
|
+
};
|
|
500
|
+
type OwnershipLockRead =
|
|
501
|
+
| { kind: "missing" }
|
|
502
|
+
| { kind: "malformed"; raw: string; mtimeMs?: number }
|
|
503
|
+
| { kind: "legacy"; metadata: LegacyOwnershipLockMetadata }
|
|
504
|
+
| { kind: "valid"; metadata: OwnershipLockMetadata };
|
|
505
|
+
|
|
506
|
+
/** Read lock provenance without treating a corrupt legacy artifact as a filesystem failure. */
|
|
507
|
+
export async function readOwnershipLock(fsImpl: TelegramDaemonFs, file: string): Promise<OwnershipLockRead> {
|
|
508
|
+
let raw: string;
|
|
509
|
+
try {
|
|
510
|
+
raw = await fsImpl.readFile(file, "utf8");
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" };
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
const value = JSON.parse(raw) as Partial<OwnershipLockMetadata>;
|
|
517
|
+
const pid = value.pid;
|
|
518
|
+
const startedAt = value.startedAt;
|
|
519
|
+
if (validDaemonPid(pid) && typeof startedAt === "number" && Number.isSafeInteger(startedAt)) {
|
|
520
|
+
if (isProcessIncarnation(value.incarnation))
|
|
521
|
+
return {
|
|
522
|
+
kind: "valid",
|
|
523
|
+
metadata: {
|
|
524
|
+
pid,
|
|
525
|
+
incarnation: value.incarnation,
|
|
526
|
+
...(typeof value.ownerId === "string" && value.ownerId.length > 0 ? { ownerId: value.ownerId } : {}),
|
|
527
|
+
...(typeof value.acquisitionId === "string" && value.acquisitionId.length > 0
|
|
528
|
+
? { acquisitionId: value.acquisitionId }
|
|
529
|
+
: {}),
|
|
530
|
+
startedAt,
|
|
531
|
+
},
|
|
532
|
+
};
|
|
533
|
+
return { kind: "legacy", metadata: { pid, startedAt } };
|
|
534
|
+
}
|
|
535
|
+
} catch {}
|
|
536
|
+
const mtimeMs = await fsImpl
|
|
537
|
+
.stat?.(file)
|
|
538
|
+
.then(stat => stat.mtimeMs)
|
|
539
|
+
.catch(() => undefined);
|
|
540
|
+
return { kind: "malformed", raw, ...(mtimeMs === undefined ? {} : { mtimeMs }) };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* A live initializer lock only proves that a concurrent publisher is active.
|
|
545
|
+
* It never proves a ready daemon: legacy or unavailable provenance remains
|
|
546
|
+
* blocked, while a canonical mismatch proves PID reuse and can be reclaimed
|
|
547
|
+
* under the transition lock.
|
|
548
|
+
*/
|
|
549
|
+
export function liveOwnershipLockDecision(input: {
|
|
550
|
+
lock: OwnershipLockRead;
|
|
551
|
+
pidAlive: (pid: number) => boolean;
|
|
552
|
+
pidIncarnation: (pid: number) => string | undefined;
|
|
553
|
+
}):
|
|
554
|
+
| { acquired: false; attached: false; blocked: true }
|
|
555
|
+
| { acquired: false; attached: false; provisional: true }
|
|
556
|
+
| undefined {
|
|
557
|
+
if (input.lock.kind !== "valid" && input.lock.kind !== "legacy") return undefined;
|
|
558
|
+
if (!input.pidAlive(input.lock.metadata.pid)) return undefined;
|
|
559
|
+
if (input.lock.kind === "legacy") {
|
|
560
|
+
if (!isProcessIncarnation(input.lock.metadata.incarnation))
|
|
561
|
+
return { acquired: false, attached: false, blocked: true };
|
|
562
|
+
const current = input.pidIncarnation(input.lock.metadata.pid);
|
|
563
|
+
if (!isProcessIncarnation(current)) return { acquired: false, attached: false, blocked: true };
|
|
564
|
+
if (current !== input.lock.metadata.incarnation) return undefined;
|
|
565
|
+
return { acquired: false, attached: false, blocked: true };
|
|
566
|
+
}
|
|
567
|
+
const current = input.pidIncarnation(input.lock.metadata.pid);
|
|
568
|
+
if (!isProcessIncarnation(current)) return { acquired: false, attached: false, blocked: true };
|
|
569
|
+
if (current !== input.lock.metadata.incarnation) return undefined;
|
|
570
|
+
return { acquired: false, attached: false, provisional: true };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function ownershipLockMatches(left: OwnershipLockRead, right: OwnershipLockRead): boolean {
|
|
574
|
+
if (left.kind !== right.kind) return false;
|
|
575
|
+
if (left.kind === "missing") return true;
|
|
576
|
+
if (left.kind === "malformed" && right.kind === "malformed")
|
|
577
|
+
return left.raw === right.raw && left.mtimeMs !== undefined && left.mtimeMs === right.mtimeMs;
|
|
578
|
+
if ((left.kind !== "valid" && left.kind !== "legacy") || (right.kind !== "valid" && right.kind !== "legacy"))
|
|
579
|
+
return false;
|
|
580
|
+
return JSON.stringify(left.metadata) === JSON.stringify(right.metadata);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function ownershipLockMatchesState(lock: OwnershipLockRead, state: DaemonState | undefined): boolean {
|
|
584
|
+
return Boolean(
|
|
585
|
+
lock.kind === "valid" &&
|
|
586
|
+
state &&
|
|
587
|
+
lock.metadata.ownerId === state.ownerId &&
|
|
588
|
+
lock.metadata.acquisitionId === state.acquisitionId &&
|
|
589
|
+
lock.metadata.pid === state.pid &&
|
|
590
|
+
lock.metadata.incarnation === state.incarnation,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function ownershipLockMatchesStoppedState(lock: OwnershipLockRead, state: DaemonState | undefined): boolean {
|
|
595
|
+
return Boolean(state && isExplicitlyStoppedDaemonState(state) && ownershipLockMatchesState(lock, state));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function transitionLockIsHeldByCaller(input: {
|
|
599
|
+
fs: TelegramDaemonFs;
|
|
600
|
+
path: string;
|
|
601
|
+
lock: DaemonTransitionLock;
|
|
602
|
+
}): Promise<boolean> {
|
|
603
|
+
return await daemonTransitionLockIsHeld(input);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function ownershipLockMatchesMetadata(lock: OwnershipLockRead, metadata: OwnershipLockMetadata): boolean {
|
|
607
|
+
return lock.kind === "valid" && JSON.stringify(lock.metadata) === JSON.stringify(metadata);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Replace an acquisition's lease only after proving the old lease is unchanged.
|
|
612
|
+
* The transition fence serializes lifecycle writers; the exact read-back prevents
|
|
613
|
+
* a stale binder from overwriting a newer owner if that fence is lost.
|
|
614
|
+
*/
|
|
615
|
+
async function rebindOwnershipLock(input: {
|
|
616
|
+
fs: TelegramDaemonFs;
|
|
617
|
+
path: string;
|
|
618
|
+
transitionPath: string;
|
|
619
|
+
transition: DaemonTransitionLock;
|
|
620
|
+
expected: OwnershipLockMetadata;
|
|
621
|
+
rebound: OwnershipLockMetadata;
|
|
622
|
+
}): Promise<boolean> {
|
|
623
|
+
if (!(await transitionLockIsHeldByCaller({ fs: input.fs, path: input.transitionPath, lock: input.transition })))
|
|
624
|
+
return false;
|
|
625
|
+
if (!ownershipLockMatchesMetadata(await readOwnershipLock(input.fs, input.path), input.expected)) return false;
|
|
626
|
+
if (!(await transitionLockIsHeldByCaller({ fs: input.fs, path: input.transitionPath, lock: input.transition })))
|
|
627
|
+
return false;
|
|
628
|
+
try {
|
|
629
|
+
await writeJsonAtomic(input.fs, input.path, input.rebound);
|
|
630
|
+
} catch {
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
return (
|
|
634
|
+
(await transitionLockIsHeldByCaller({ fs: input.fs, path: input.transitionPath, lock: input.transition })) &&
|
|
635
|
+
ownershipLockMatchesMetadata(await readOwnershipLock(input.fs, input.path), input.rebound)
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Restore the launcher lease only when the failed binder still owns the child lease. */
|
|
640
|
+
async function rollbackOwnershipLockRebind(input: {
|
|
641
|
+
fs: TelegramDaemonFs;
|
|
642
|
+
path: string;
|
|
643
|
+
transitionPath: string;
|
|
644
|
+
transition: DaemonTransitionLock;
|
|
645
|
+
previous: OwnershipLockMetadata;
|
|
646
|
+
rebound: OwnershipLockMetadata;
|
|
647
|
+
}): Promise<boolean> {
|
|
648
|
+
if (!(await transitionLockIsHeldByCaller({ fs: input.fs, path: input.transitionPath, lock: input.transition })))
|
|
649
|
+
return false;
|
|
650
|
+
if (!ownershipLockMatchesMetadata(await readOwnershipLock(input.fs, input.path), input.rebound)) return false;
|
|
651
|
+
if (!(await transitionLockIsHeldByCaller({ fs: input.fs, path: input.transitionPath, lock: input.transition })))
|
|
652
|
+
return false;
|
|
653
|
+
try {
|
|
654
|
+
await writeJsonAtomic(input.fs, input.path, input.previous);
|
|
655
|
+
} catch {
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
return (
|
|
659
|
+
(await transitionLockIsHeldByCaller({ fs: input.fs, path: input.transitionPath, lock: input.transition })) &&
|
|
660
|
+
ownershipLockMatchesMetadata(await readOwnershipLock(input.fs, input.path), input.previous)
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function ownershipLockIsReclaimable(input: {
|
|
665
|
+
fs: TelegramDaemonFs;
|
|
666
|
+
path: string;
|
|
667
|
+
lock: OwnershipLockRead;
|
|
668
|
+
now: number;
|
|
669
|
+
pidAlive: (pid: number) => boolean;
|
|
670
|
+
pidIncarnation: (pid: number) => string | undefined;
|
|
671
|
+
}): Promise<boolean> {
|
|
672
|
+
if (input.lock.kind === "missing") return true;
|
|
673
|
+
if (input.lock.kind === "malformed") {
|
|
674
|
+
if (!input.fs.stat) return false;
|
|
675
|
+
const stat = await input.fs.stat(input.path).catch(() => undefined);
|
|
676
|
+
return stat !== undefined && input.now - stat.mtimeMs > HEARTBEAT_TTL_MS;
|
|
677
|
+
}
|
|
678
|
+
if (!input.pidAlive(input.lock.metadata.pid)) return true;
|
|
679
|
+
const current = input.pidIncarnation(input.lock.metadata.pid);
|
|
680
|
+
return (
|
|
681
|
+
isProcessIncarnation(input.lock.metadata.incarnation) &&
|
|
682
|
+
isProcessIncarnation(current) &&
|
|
683
|
+
current !== input.lock.metadata.incarnation
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
interface NotificationRootRegistration {
|
|
688
|
+
root?: string;
|
|
689
|
+
managed?: boolean;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function readNotificationRootRegistration(input: {
|
|
693
|
+
settings: Settings;
|
|
694
|
+
sessionId: string;
|
|
695
|
+
fs?: TelegramDaemonFs;
|
|
696
|
+
}): Promise<NotificationRootRegistration> {
|
|
697
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
698
|
+
const current = await readJson<{ sessions?: Record<string, string>; managedRoots?: string[] }>(
|
|
699
|
+
fsImpl,
|
|
700
|
+
daemonPaths(input.settings.getAgentDir()).roots,
|
|
701
|
+
);
|
|
702
|
+
const root = current?.sessions?.[input.sessionId];
|
|
703
|
+
return { root, managed: root !== undefined && current?.managedRoots?.includes(root) === true };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Restore a session root only if this ensure operation still owns its registration. */
|
|
707
|
+
async function restoreNotificationRootRegistration(input: {
|
|
708
|
+
settings: Settings;
|
|
709
|
+
sessionId: string;
|
|
710
|
+
registeredRoot: string;
|
|
711
|
+
previous: NotificationRootRegistration;
|
|
712
|
+
fs?: TelegramDaemonFs;
|
|
713
|
+
}): Promise<void> {
|
|
714
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
715
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
716
|
+
await ensureDir(fsImpl, paths.dir);
|
|
717
|
+
await withFileLock(
|
|
718
|
+
paths.roots,
|
|
719
|
+
async () => {
|
|
720
|
+
const current =
|
|
721
|
+
(await readJson<{ roots?: string[]; managedRoots?: string[]; sessions?: Record<string, string> }>(
|
|
722
|
+
fsImpl,
|
|
723
|
+
paths.roots,
|
|
724
|
+
)) ?? {};
|
|
725
|
+
const sessions = { ...(current.sessions ?? {}) };
|
|
726
|
+
if (sessions[input.sessionId] !== input.registeredRoot) return;
|
|
727
|
+
if (input.previous.root) sessions[input.sessionId] = input.previous.root;
|
|
728
|
+
else delete sessions[input.sessionId];
|
|
729
|
+
const referencedRoots = new Set(Object.values(sessions));
|
|
730
|
+
const roots = new Set(current.roots ?? []);
|
|
731
|
+
const managedRoots = new Set(current.managedRoots ?? []);
|
|
732
|
+
if (input.previous.root) {
|
|
733
|
+
roots.add(input.previous.root);
|
|
734
|
+
if (input.previous.managed) managedRoots.add(input.previous.root);
|
|
735
|
+
else managedRoots.delete(input.previous.root);
|
|
736
|
+
}
|
|
737
|
+
if (managedRoots.has(input.registeredRoot) && !referencedRoots.has(input.registeredRoot)) {
|
|
738
|
+
roots.delete(input.registeredRoot);
|
|
739
|
+
managedRoots.delete(input.registeredRoot);
|
|
740
|
+
}
|
|
741
|
+
await writeJsonAtomic(fsImpl, paths.roots, {
|
|
742
|
+
version: 1,
|
|
743
|
+
roots: Array.from(roots).sort(),
|
|
744
|
+
managedRoots: Array.from(managedRoots).sort(),
|
|
745
|
+
sessions,
|
|
746
|
+
});
|
|
747
|
+
},
|
|
748
|
+
{ staleMs: 10_000 },
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
453
752
|
export async function registerNotificationRoot(input: {
|
|
454
753
|
settings: Settings;
|
|
455
754
|
cwd: string;
|
|
@@ -464,13 +763,32 @@ export async function registerNotificationRoot(input: {
|
|
|
464
763
|
paths.roots,
|
|
465
764
|
async () => {
|
|
466
765
|
const current =
|
|
467
|
-
(await readJson<{ roots?: string[]; sessions?: Record<string, string> }>(
|
|
766
|
+
(await readJson<{ roots?: string[]; managedRoots?: string[]; sessions?: Record<string, string> }>(
|
|
767
|
+
fsImpl,
|
|
768
|
+
paths.roots,
|
|
769
|
+
)) ?? {};
|
|
468
770
|
const roots = new Set(current.roots ?? []);
|
|
771
|
+
const managedRoots = new Set(current.managedRoots ?? []);
|
|
772
|
+
const sessions = { ...(current.sessions ?? {}) };
|
|
773
|
+
const previousRoot = sessions[input.sessionId];
|
|
774
|
+
const rootAlreadyPresent = roots.has(root);
|
|
469
775
|
roots.add(root);
|
|
776
|
+
// Roots present before session registration are legacy/unmanaged and must
|
|
777
|
+
// survive a later session rollback or unregister.
|
|
778
|
+
if (!rootAlreadyPresent) managedRoots.add(root);
|
|
779
|
+
sessions[input.sessionId] = root;
|
|
780
|
+
if (previousRoot && previousRoot !== root && managedRoots.has(previousRoot)) {
|
|
781
|
+
const previousStillReferenced = Object.values(sessions).includes(previousRoot);
|
|
782
|
+
if (!previousStillReferenced) {
|
|
783
|
+
roots.delete(previousRoot);
|
|
784
|
+
managedRoots.delete(previousRoot);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
470
787
|
await writeJsonAtomic(fsImpl, paths.roots, {
|
|
471
788
|
version: 1,
|
|
472
789
|
roots: Array.from(roots).sort(),
|
|
473
|
-
|
|
790
|
+
managedRoots: Array.from(managedRoots).sort(),
|
|
791
|
+
sessions,
|
|
474
792
|
});
|
|
475
793
|
},
|
|
476
794
|
{ staleMs: 10_000 },
|
|
@@ -498,16 +816,31 @@ export async function unregisterNotificationRoot(input: {
|
|
|
498
816
|
await withFileLock(
|
|
499
817
|
paths.roots,
|
|
500
818
|
async () => {
|
|
501
|
-
const current = await readJson<{
|
|
819
|
+
const current = await readJson<{
|
|
820
|
+
roots?: string[];
|
|
821
|
+
managedRoots?: string[];
|
|
822
|
+
sessions?: Record<string, string>;
|
|
823
|
+
}>(fsImpl, paths.roots);
|
|
502
824
|
if (!current) return;
|
|
503
825
|
const sessions = { ...(current.sessions ?? {}) };
|
|
826
|
+
// A stale cleanup from a previous registration must not remove a newer
|
|
827
|
+
// registration for the same session under a different root.
|
|
828
|
+
if (sessions[input.sessionId] !== root) {
|
|
829
|
+
remainingRoots = (current.roots ?? []).length;
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
504
832
|
delete sessions[input.sessionId];
|
|
505
833
|
const rootStillReferenced = Object.values(sessions).includes(root);
|
|
506
|
-
const
|
|
834
|
+
const managedRoots = new Set(current.managedRoots ?? []);
|
|
835
|
+
const roots = (current.roots ?? []).filter(
|
|
836
|
+
candidate => candidate !== root || rootStillReferenced || !managedRoots.has(root),
|
|
837
|
+
);
|
|
838
|
+
if (!rootStillReferenced) managedRoots.delete(root);
|
|
507
839
|
remainingRoots = roots.length;
|
|
508
840
|
await writeJsonAtomic(fsImpl, paths.roots, {
|
|
509
841
|
version: 1,
|
|
510
842
|
roots: Array.from(new Set(roots)).sort(),
|
|
843
|
+
managedRoots: Array.from(managedRoots).sort(),
|
|
511
844
|
sessions,
|
|
512
845
|
});
|
|
513
846
|
},
|
|
@@ -524,24 +857,212 @@ function validBotToken(token: unknown): token is string {
|
|
|
524
857
|
return typeof token === "string" && token.length > 0;
|
|
525
858
|
}
|
|
526
859
|
|
|
527
|
-
function
|
|
860
|
+
function isExplicitlyStoppedDaemonState(state: unknown): state is DaemonState {
|
|
861
|
+
return Boolean(
|
|
862
|
+
hasSafeDaemonStateShape(state) &&
|
|
863
|
+
state.stoppedAt !== undefined &&
|
|
864
|
+
typeof state.acquisitionId === "string" &&
|
|
865
|
+
state.acquisitionId.length > 0,
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function ownerIdentityMatches(
|
|
870
|
+
state: Pick<DaemonState, "tokenFingerprint" | "chatId">,
|
|
871
|
+
tokenFingerprint: string,
|
|
872
|
+
chatId: string,
|
|
873
|
+
): boolean {
|
|
528
874
|
return state.tokenFingerprint === tokenFingerprint && state.chatId === chatId;
|
|
529
875
|
}
|
|
530
876
|
|
|
531
|
-
function
|
|
877
|
+
function ownerProvenanceMatches(state: DaemonState, pidIncarnation?: (pid: number) => string | undefined): boolean {
|
|
878
|
+
const current = (pidIncarnation ?? defaultPidIncarnation)(state.pid);
|
|
879
|
+
return isProcessIncarnation(state.incarnation) && isProcessIncarnation(current) && current === state.incarnation;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
export function hasSafeDaemonStateShape(state: unknown): state is DaemonState {
|
|
883
|
+
if (!state || typeof state !== "object" || Array.isArray(state)) return false;
|
|
884
|
+
const candidate = state as Partial<DaemonState>;
|
|
885
|
+
return Boolean(
|
|
886
|
+
Number.isSafeInteger(candidate.pid) &&
|
|
887
|
+
(candidate.pid as number) > 0 &&
|
|
888
|
+
typeof candidate.ownerId === "string" &&
|
|
889
|
+
candidate.ownerId.length > 0 &&
|
|
890
|
+
(candidate.acquisitionId === undefined ||
|
|
891
|
+
(typeof candidate.acquisitionId === "string" && candidate.acquisitionId.length > 0)) &&
|
|
892
|
+
(candidate.ownershipPhase === undefined ||
|
|
893
|
+
candidate.ownershipPhase === "provisional" ||
|
|
894
|
+
candidate.ownershipPhase === "ready" ||
|
|
895
|
+
candidate.ownershipPhase === "retired") &&
|
|
896
|
+
typeof candidate.tokenFingerprint === "string" &&
|
|
897
|
+
typeof candidate.chatId === "string" &&
|
|
898
|
+
Number.isSafeInteger(candidate.startedAt) &&
|
|
899
|
+
Number.isSafeInteger(candidate.heartbeatAt) &&
|
|
900
|
+
isProcessIncarnation(candidate.incarnation) &&
|
|
901
|
+
(candidate.launcherPid === undefined ||
|
|
902
|
+
(Number.isSafeInteger(candidate.launcherPid) && (candidate.launcherPid as number) > 0)) &&
|
|
903
|
+
Array.isArray(candidate.roots) &&
|
|
904
|
+
candidate.roots.every(root => typeof root === "string") &&
|
|
905
|
+
candidate.version === DAEMON_VERSION &&
|
|
906
|
+
(candidate.generation === undefined ||
|
|
907
|
+
(Number.isSafeInteger(candidate.generation) && (candidate.generation as number) > 0)) &&
|
|
908
|
+
(candidate.stoppedAt === undefined || Number.isSafeInteger(candidate.stoppedAt)),
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
type LegacyParentDaemonState = Omit<DaemonState, "incarnation" | "acquisitionId" | "ownershipPhase" | "generation"> & {
|
|
913
|
+
incarnation?: undefined;
|
|
914
|
+
acquisitionId?: undefined;
|
|
915
|
+
ownershipPhase?: undefined;
|
|
916
|
+
generation?: undefined;
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
interface LegacyMigrationAttestation {
|
|
920
|
+
pid: number;
|
|
921
|
+
incarnation: string;
|
|
922
|
+
heartbeatAt: number;
|
|
923
|
+
observedAt: number;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function isLegacyParentDaemonState(state: unknown): state is LegacyParentDaemonState {
|
|
927
|
+
const candidate = state as Partial<LegacyParentDaemonState> | undefined;
|
|
928
|
+
return Boolean(
|
|
929
|
+
candidate &&
|
|
930
|
+
Number.isSafeInteger(candidate.pid) &&
|
|
931
|
+
(candidate.pid ?? 0) > 0 &&
|
|
932
|
+
typeof candidate.ownerId === "string" &&
|
|
933
|
+
candidate.ownerId.length > 0 &&
|
|
934
|
+
typeof candidate.tokenFingerprint === "string" &&
|
|
935
|
+
typeof candidate.chatId === "string" &&
|
|
936
|
+
Number.isSafeInteger(candidate.startedAt) &&
|
|
937
|
+
Number.isSafeInteger(candidate.heartbeatAt) &&
|
|
938
|
+
Array.isArray(candidate.roots) &&
|
|
939
|
+
candidate.roots.every(root => typeof root === "string") &&
|
|
940
|
+
candidate.version === DAEMON_VERSION &&
|
|
941
|
+
candidate.incarnation === undefined &&
|
|
942
|
+
candidate.acquisitionId === undefined &&
|
|
943
|
+
candidate.ownershipPhase === undefined &&
|
|
944
|
+
candidate.generation === undefined &&
|
|
945
|
+
candidate.stoppedAt === undefined,
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function legacyMigrationAttestationPath(statePath: string): string {
|
|
950
|
+
return `${statePath}.legacy-migration.json`;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async function legacyParentHandoffDecision(input: {
|
|
954
|
+
fs: TelegramDaemonFs;
|
|
955
|
+
statePath: string;
|
|
956
|
+
state: LegacyParentDaemonState;
|
|
957
|
+
now: number;
|
|
958
|
+
pidAlive: (pid: number) => boolean;
|
|
959
|
+
pidIncarnation: (pid: number) => string | undefined;
|
|
960
|
+
tokenFingerprint: string;
|
|
961
|
+
chatId: string;
|
|
962
|
+
}): Promise<
|
|
963
|
+
{ acquired: false; attached: false; provisional?: boolean; reloadRequired?: boolean; blocked?: boolean } | undefined
|
|
964
|
+
> {
|
|
965
|
+
const { state } = input;
|
|
966
|
+
if (!input.pidAlive(state.pid)) return undefined;
|
|
967
|
+
if (!ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
|
|
968
|
+
return { acquired: false, attached: false, blocked: true };
|
|
969
|
+
const incarnation = input.pidIncarnation(state.pid);
|
|
970
|
+
if (!isProcessIncarnation(incarnation)) return { acquired: false, attached: false, blocked: true };
|
|
971
|
+
const attestationPath = legacyMigrationAttestationPath(input.statePath);
|
|
972
|
+
const previous = await readJson<LegacyMigrationAttestation>(input.fs, attestationPath);
|
|
973
|
+
const attested = Boolean(
|
|
974
|
+
previous &&
|
|
975
|
+
Number.isSafeInteger(previous.pid) &&
|
|
976
|
+
isProcessIncarnation(previous.incarnation) &&
|
|
977
|
+
Number.isSafeInteger(previous.heartbeatAt) &&
|
|
978
|
+
Number.isSafeInteger(previous.observedAt) &&
|
|
979
|
+
previous.pid === state.pid &&
|
|
980
|
+
previous.incarnation === incarnation &&
|
|
981
|
+
state.heartbeatAt > previous.heartbeatAt &&
|
|
982
|
+
input.now - previous.observedAt <= HEARTBEAT_TTL_MS,
|
|
983
|
+
);
|
|
984
|
+
await writeJsonAtomic(input.fs, attestationPath, {
|
|
985
|
+
pid: state.pid,
|
|
986
|
+
incarnation,
|
|
987
|
+
heartbeatAt: state.heartbeatAt,
|
|
988
|
+
observedAt: input.now,
|
|
989
|
+
} satisfies LegacyMigrationAttestation);
|
|
990
|
+
return attested
|
|
991
|
+
? { acquired: false, attached: false, reloadRequired: true }
|
|
992
|
+
: { acquired: false, attached: false, provisional: true };
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function isRecognizedLegacyGeneration(generation: number | undefined): boolean {
|
|
996
|
+
return (
|
|
997
|
+
generation === undefined || (Number.isSafeInteger(generation) && generation > 0 && generation < DAEMON_GENERATION)
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Classifies a physically live daemon record before any replacement action.
|
|
1003
|
+
*
|
|
1004
|
+
* A canonical, differing incarnation is the only authoritative proof that a
|
|
1005
|
+
* live PID is no longer the recorded owner. This applies equally to matching
|
|
1006
|
+
* and foreign Telegram identities: identity never substitutes for provenance.
|
|
1007
|
+
*/
|
|
1008
|
+
function classifyForeignLiveOwner(input: {
|
|
1009
|
+
state: unknown;
|
|
1010
|
+
tokenFingerprint: string;
|
|
1011
|
+
chatId: string;
|
|
1012
|
+
pidAlive: (pid: number) => boolean;
|
|
1013
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1014
|
+
}): "identity_mismatch" | "ambiguous" | undefined {
|
|
1015
|
+
const state = input.state as Partial<DaemonState> | undefined;
|
|
1016
|
+
// Validate before probing so malformed PIDs never reach the liveness source.
|
|
1017
|
+
if (!state || !validDaemonPid(state.pid) || !input.pidAlive(state.pid)) return undefined;
|
|
1018
|
+
// A stopped tombstone with a canonical acquisition is not a live owner.
|
|
1019
|
+
if (isExplicitlyStoppedDaemonState(state) || isLegacyParentDaemonState(state)) return undefined;
|
|
1020
|
+
const currentIncarnation = input.pidIncarnation?.(state.pid) ?? defaultPidIncarnation(state.pid);
|
|
1021
|
+
if (
|
|
1022
|
+
!hasSafeDaemonStateShape(state) ||
|
|
1023
|
+
!isProcessIncarnation(state.incarnation) ||
|
|
1024
|
+
!isProcessIncarnation(currentIncarnation)
|
|
1025
|
+
)
|
|
1026
|
+
return "ambiguous";
|
|
1027
|
+
if (currentIncarnation !== state.incarnation) return undefined;
|
|
1028
|
+
return ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) ? undefined : "identity_mismatch";
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/** True for a physically live owner with this configuration, including legacy generations. */
|
|
1032
|
+
export function isPhysicalMatchingOwner(input: {
|
|
532
1033
|
state: DaemonState | undefined;
|
|
533
1034
|
tokenFingerprint: string;
|
|
534
1035
|
chatId: string;
|
|
535
1036
|
pidAlive: (pid: number) => boolean;
|
|
1037
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
536
1038
|
}): boolean {
|
|
537
1039
|
const { state } = input;
|
|
538
1040
|
return Boolean(
|
|
539
1041
|
state &&
|
|
540
|
-
state
|
|
1042
|
+
hasSafeDaemonStateShape(state) &&
|
|
541
1043
|
state.stoppedAt === undefined &&
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
1044
|
+
ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
|
|
1045
|
+
input.pidAlive(state.pid) &&
|
|
1046
|
+
ownerProvenanceMatches(state, input.pidIncarnation),
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** True only for a signalable legacy owner or this generation's ready bound child. */
|
|
1051
|
+
export function isSignalableMatchingOwner(input: {
|
|
1052
|
+
state: DaemonState | undefined;
|
|
1053
|
+
tokenFingerprint: string;
|
|
1054
|
+
chatId: string;
|
|
1055
|
+
pidAlive: (pid: number) => boolean;
|
|
1056
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1057
|
+
}): boolean {
|
|
1058
|
+
const { state } = input;
|
|
1059
|
+
return Boolean(
|
|
1060
|
+
isPhysicalMatchingOwner(input) &&
|
|
1061
|
+
(isRecognizedLegacyGeneration(state?.generation) ||
|
|
1062
|
+
(state?.generation === DAEMON_GENERATION &&
|
|
1063
|
+
state.ownershipPhase === "ready" &&
|
|
1064
|
+
typeof state.acquisitionId === "string" &&
|
|
1065
|
+
state.acquisitionId.length > 0)),
|
|
545
1066
|
);
|
|
546
1067
|
}
|
|
547
1068
|
|
|
@@ -551,16 +1072,37 @@ export function isFreshLiveOwner(input: {
|
|
|
551
1072
|
tokenFingerprint: string;
|
|
552
1073
|
chatId: string;
|
|
553
1074
|
pidAlive: (pid: number) => boolean;
|
|
1075
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
554
1076
|
}): boolean {
|
|
555
1077
|
const { state } = input;
|
|
556
1078
|
return Boolean(
|
|
557
1079
|
state &&
|
|
558
|
-
state
|
|
1080
|
+
hasSafeDaemonStateShape(state) &&
|
|
559
1081
|
state.stoppedAt === undefined &&
|
|
560
1082
|
ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
|
|
561
1083
|
input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
|
|
562
|
-
|
|
563
|
-
|
|
1084
|
+
input.pidAlive(state.pid) &&
|
|
1085
|
+
ownerProvenanceMatches(state, input.pidIncarnation),
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/** True only when a physically live matching owner can serve this build's daemon lifecycle contract. */
|
|
1090
|
+
export function isCurrentCompatibleOwner(input: {
|
|
1091
|
+
state: DaemonState | undefined;
|
|
1092
|
+
now: number;
|
|
1093
|
+
tokenFingerprint: string;
|
|
1094
|
+
chatId: string;
|
|
1095
|
+
pidAlive: (pid: number) => boolean;
|
|
1096
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1097
|
+
}): boolean {
|
|
1098
|
+
const state = input.state;
|
|
1099
|
+
return Boolean(
|
|
1100
|
+
isFreshLiveOwner(input) &&
|
|
1101
|
+
state?.ownershipPhase === "ready" &&
|
|
1102
|
+
typeof state.acquisitionId === "string" &&
|
|
1103
|
+
state.acquisitionId.length > 0 &&
|
|
1104
|
+
Number.isSafeInteger(state.generation) &&
|
|
1105
|
+
(state.generation as number) >= DAEMON_GENERATION,
|
|
564
1106
|
);
|
|
565
1107
|
}
|
|
566
1108
|
|
|
@@ -573,6 +1115,7 @@ export async function acquireDaemonOwnership(input: {
|
|
|
573
1115
|
now?: () => number;
|
|
574
1116
|
pid?: number;
|
|
575
1117
|
pidAlive?: (pid: number) => boolean;
|
|
1118
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
576
1119
|
randomId?: () => string;
|
|
577
1120
|
/** Permit one Windows source launcher PID to daemon PID handoff. */
|
|
578
1121
|
allowPidRebind?: boolean;
|
|
@@ -581,8 +1124,10 @@ export async function acquireDaemonOwnership(input: {
|
|
|
581
1124
|
}): Promise<{
|
|
582
1125
|
acquired: boolean;
|
|
583
1126
|
ownerId?: string;
|
|
1127
|
+
acquisitionId?: string;
|
|
584
1128
|
attached?: boolean;
|
|
585
1129
|
blocked?: boolean;
|
|
1130
|
+
provisional?: boolean;
|
|
586
1131
|
reason?: "identity_mismatch";
|
|
587
1132
|
reloadRequired?: boolean;
|
|
588
1133
|
}> {
|
|
@@ -591,190 +1136,672 @@ export async function acquireDaemonOwnership(input: {
|
|
|
591
1136
|
const pid = input.pid ?? process.pid;
|
|
592
1137
|
if (!validDaemonPid(pid)) return { acquired: false, attached: true };
|
|
593
1138
|
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
1139
|
+
const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
|
|
1140
|
+
const incarnation = pidIncarnation(pid);
|
|
1141
|
+
if (!isProcessIncarnation(incarnation)) return { acquired: false, attached: false, provisional: true };
|
|
594
1142
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
595
1143
|
await ensureDir(fsImpl, paths.dir);
|
|
596
1144
|
const ownerId =
|
|
597
1145
|
input.ownerId ?? input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
598
1146
|
const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
1147
|
+
|
|
1148
|
+
// A fresh, identity-matching live owner running an OLDER generation than this
|
|
1149
|
+
// build cannot serve our newer wire frames; signal a reload instead of a
|
|
1150
|
+
// silent attach. Newer/equal generations attach as before (no downgrade).
|
|
1151
|
+
const attachDecision = (
|
|
1152
|
+
state: DaemonState | undefined,
|
|
1153
|
+
):
|
|
1154
|
+
| { acquired: false; attached: boolean; blocked?: boolean; provisional?: boolean; reloadRequired?: boolean }
|
|
1155
|
+
| undefined => {
|
|
1156
|
+
if (state && !hasSafeDaemonStateShape(state)) {
|
|
1157
|
+
const malformed = state as Partial<DaemonState>;
|
|
610
1158
|
if (
|
|
611
|
-
!
|
|
612
|
-
|
|
613
|
-
|
|
1159
|
+
!isExplicitlyStoppedDaemonState(malformed) &&
|
|
1160
|
+
validDaemonPid(malformed.pid) &&
|
|
1161
|
+
typeof malformed.incarnation === "string" &&
|
|
1162
|
+
pidAlive(malformed.pid)
|
|
614
1163
|
)
|
|
615
|
-
return true;
|
|
616
|
-
return
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1164
|
+
return { acquired: false, attached: false, blocked: true };
|
|
1165
|
+
return undefined;
|
|
1166
|
+
}
|
|
1167
|
+
if (!state || state.stoppedAt !== undefined || !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
|
|
1168
|
+
return undefined;
|
|
1169
|
+
// Unavailable provenance is ambiguous and must remain fail-closed. A
|
|
1170
|
+
// different authoritative incarnation proves PID reuse, so allow only the
|
|
1171
|
+
// transition-locked path below to reclaim the stale owner artifacts.
|
|
1172
|
+
if (pidAlive(state.pid)) {
|
|
1173
|
+
const currentIncarnation = pidIncarnation(state.pid);
|
|
1174
|
+
if (!isProcessIncarnation(currentIncarnation) || !isProcessIncarnation(state.incarnation))
|
|
1175
|
+
return { acquired: false, attached: false, blocked: true };
|
|
1176
|
+
if (currentIncarnation !== state.incarnation) return undefined;
|
|
1177
|
+
}
|
|
1178
|
+
if (!pidAlive(state.pid)) return undefined;
|
|
1179
|
+
if (
|
|
1180
|
+
isCurrentCompatibleOwner({
|
|
1181
|
+
state,
|
|
1182
|
+
now: now(),
|
|
628
1183
|
tokenFingerprint: input.tokenFingerprint,
|
|
629
1184
|
chatId: input.chatId,
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
state
|
|
640
|
-
|
|
641
|
-
if (
|
|
642
|
-
!isFreshLiveOwner({
|
|
643
|
-
state,
|
|
644
|
-
now: now(),
|
|
645
|
-
tokenFingerprint: input.tokenFingerprint,
|
|
646
|
-
chatId: input.chatId,
|
|
647
|
-
pidAlive,
|
|
648
|
-
})
|
|
649
|
-
) {
|
|
650
|
-
return undefined;
|
|
651
|
-
}
|
|
652
|
-
return (state?.generation ?? 0) < DAEMON_GENERATION
|
|
653
|
-
? { acquired: false, attached: false, reloadRequired: true }
|
|
654
|
-
: { acquired: false, attached: true };
|
|
655
|
-
};
|
|
656
|
-
const rejectLiveDifferentIdentity = (state: DaemonState | undefined) =>
|
|
657
|
-
liveOwnerUsesDifferentIdentity({
|
|
1185
|
+
pidAlive,
|
|
1186
|
+
pidIncarnation,
|
|
1187
|
+
})
|
|
1188
|
+
)
|
|
1189
|
+
return { acquired: false, attached: true };
|
|
1190
|
+
// A physical owner that cannot prove current compatibility is never safe to
|
|
1191
|
+
// attach. Legacy, stale, and malformed generations require a handoff;
|
|
1192
|
+
// otherwise retain the explicit provisional classification.
|
|
1193
|
+
if (
|
|
1194
|
+
state.version !== DAEMON_VERSION ||
|
|
1195
|
+
!isFreshLiveOwner({
|
|
658
1196
|
state,
|
|
1197
|
+
now: now(),
|
|
659
1198
|
tokenFingerprint: input.tokenFingerprint,
|
|
660
1199
|
chatId: input.chatId,
|
|
661
1200
|
pidAlive,
|
|
1201
|
+
pidIncarnation,
|
|
1202
|
+
}) ||
|
|
1203
|
+
isRecognizedLegacyGeneration(state.generation)
|
|
1204
|
+
)
|
|
1205
|
+
return { acquired: false, attached: false, reloadRequired: true };
|
|
1206
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1207
|
+
};
|
|
1208
|
+
const existing = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1209
|
+
const foreignOwner = classifyForeignLiveOwner({
|
|
1210
|
+
state: existing,
|
|
1211
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1212
|
+
chatId: input.chatId,
|
|
1213
|
+
pidAlive,
|
|
1214
|
+
pidIncarnation,
|
|
1215
|
+
});
|
|
1216
|
+
if (foreignOwner) {
|
|
1217
|
+
return foreignOwner === "identity_mismatch"
|
|
1218
|
+
? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
|
|
1219
|
+
: { acquired: false, attached: false, blocked: true };
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const transition = await acquireTransitionLock({ fs: fsImpl, path: paths.steal, pidAlive, pidIncarnation });
|
|
1223
|
+
if (!transition) return { acquired: false, attached: false, provisional: true };
|
|
1224
|
+
try {
|
|
1225
|
+
const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1226
|
+
const recheckedLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
1227
|
+
const recheckedDecision = isLegacyParentDaemonState(rechecked) ? undefined : attachDecision(rechecked);
|
|
1228
|
+
if (
|
|
1229
|
+
recheckedDecision &&
|
|
1230
|
+
(recheckedDecision.attached ||
|
|
1231
|
+
recheckedDecision.blocked ||
|
|
1232
|
+
recheckedLock.kind === "missing" ||
|
|
1233
|
+
ownershipLockMatchesState(recheckedLock, rechecked))
|
|
1234
|
+
)
|
|
1235
|
+
return recheckedDecision;
|
|
1236
|
+
// A stopped tombstone authorizes removal only of its own canonical lock.
|
|
1237
|
+
// A newer initializer may have replaced the pathname while that tombstone
|
|
1238
|
+
// remained, and must receive the same liveness/freshness protection as any
|
|
1239
|
+
// other live reservation.
|
|
1240
|
+
const stoppedLockMatches = ownershipLockMatchesStoppedState(recheckedLock, rechecked);
|
|
1241
|
+
const lockDecision = stoppedLockMatches
|
|
1242
|
+
? undefined
|
|
1243
|
+
: liveOwnershipLockDecision({ lock: recheckedLock, pidAlive, pidIncarnation });
|
|
1244
|
+
if (lockDecision) return lockDecision;
|
|
1245
|
+
if (
|
|
1246
|
+
!stoppedLockMatches &&
|
|
1247
|
+
!(await ownershipLockIsReclaimable({
|
|
1248
|
+
fs: fsImpl,
|
|
1249
|
+
path: paths.lock,
|
|
1250
|
+
lock: recheckedLock,
|
|
1251
|
+
now: now(),
|
|
1252
|
+
pidAlive,
|
|
1253
|
+
pidIncarnation,
|
|
1254
|
+
}))
|
|
1255
|
+
)
|
|
1256
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1257
|
+
if (isLegacyParentDaemonState(rechecked)) {
|
|
1258
|
+
const legacyDecision = await legacyParentHandoffDecision({
|
|
1259
|
+
fs: fsImpl,
|
|
1260
|
+
statePath: paths.state,
|
|
1261
|
+
state: rechecked,
|
|
1262
|
+
now: now(),
|
|
1263
|
+
pidAlive,
|
|
1264
|
+
pidIncarnation,
|
|
1265
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1266
|
+
chatId: input.chatId,
|
|
662
1267
|
});
|
|
663
|
-
|
|
664
|
-
if (
|
|
665
|
-
return
|
|
1268
|
+
if (legacyDecision) return legacyDecision;
|
|
1269
|
+
} else if (recheckedDecision) {
|
|
1270
|
+
return recheckedDecision;
|
|
666
1271
|
}
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
1272
|
+
const recheckedForeignOwner = classifyForeignLiveOwner({
|
|
1273
|
+
state: rechecked,
|
|
1274
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1275
|
+
chatId: input.chatId,
|
|
1276
|
+
pidAlive,
|
|
1277
|
+
pidIncarnation,
|
|
1278
|
+
});
|
|
1279
|
+
if (recheckedForeignOwner) {
|
|
1280
|
+
return recheckedForeignOwner === "identity_mismatch"
|
|
1281
|
+
? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
|
|
1282
|
+
: { acquired: false, attached: false, blocked: true };
|
|
673
1283
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
1284
|
+
if (
|
|
1285
|
+
hasSafeDaemonStateShape(rechecked) &&
|
|
1286
|
+
rechecked.stoppedAt === undefined &&
|
|
1287
|
+
pidAlive(rechecked.pid) &&
|
|
1288
|
+
ownerProvenanceMatches(rechecked, pidIncarnation)
|
|
1289
|
+
)
|
|
1290
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1291
|
+
const currentLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
1292
|
+
if (!ownershipLockMatches(recheckedLock, currentLock))
|
|
1293
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1294
|
+
if (currentLock.kind !== "missing") {
|
|
1295
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
|
|
1296
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1297
|
+
await fsImpl.unlink(paths.lock);
|
|
677
1298
|
}
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
1299
|
+
const ownershipLock: OwnershipLockMetadata = {
|
|
1300
|
+
pid,
|
|
1301
|
+
incarnation,
|
|
1302
|
+
ownerId,
|
|
1303
|
+
acquisitionId: ownerId,
|
|
1304
|
+
startedAt: now(),
|
|
1305
|
+
};
|
|
1306
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
|
|
1307
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1308
|
+
if (!(await tryCreateOwnershipLock(fsImpl, paths.lock, ownershipLock)))
|
|
1309
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1310
|
+
if (
|
|
1311
|
+
!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })) ||
|
|
1312
|
+
!ownershipLockMatchesMetadata(await readOwnershipLock(fsImpl, paths.lock), ownershipLock)
|
|
1313
|
+
)
|
|
1314
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1315
|
+
await writeJsonAtomic(fsImpl, paths.state, {
|
|
1316
|
+
pid,
|
|
1317
|
+
incarnation,
|
|
1318
|
+
ownerId,
|
|
1319
|
+
acquisitionId: ownerId,
|
|
1320
|
+
ownershipPhase: "provisional",
|
|
1321
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1322
|
+
chatId: input.chatId,
|
|
1323
|
+
startedAt: now(),
|
|
1324
|
+
heartbeatAt: now(),
|
|
1325
|
+
roots,
|
|
1326
|
+
version: DAEMON_VERSION,
|
|
1327
|
+
generation: DAEMON_GENERATION,
|
|
1328
|
+
} satisfies DaemonState);
|
|
1329
|
+
if (
|
|
1330
|
+
!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })) ||
|
|
1331
|
+
!ownershipLockMatchesMetadata(await readOwnershipLock(fsImpl, paths.lock), ownershipLock)
|
|
1332
|
+
)
|
|
1333
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1334
|
+
return { acquired: true, ownerId, acquisitionId: ownerId };
|
|
1335
|
+
} finally {
|
|
1336
|
+
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
export async function renewDaemonHeartbeat(input: {
|
|
1341
|
+
settings: Settings;
|
|
1342
|
+
ownerId: string;
|
|
1343
|
+
acquisitionId?: string;
|
|
1344
|
+
tokenFingerprint?: string;
|
|
1345
|
+
chatId?: string;
|
|
1346
|
+
fs?: TelegramDaemonFs;
|
|
1347
|
+
now?: () => number;
|
|
1348
|
+
pid?: number;
|
|
1349
|
+
generation?: number;
|
|
1350
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1351
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1352
|
+
stealRetries?: number;
|
|
1353
|
+
stealRetryDelayMs?: number;
|
|
1354
|
+
}): Promise<boolean> {
|
|
1355
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
1356
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
1357
|
+
const acquisitionId = input.acquisitionId ?? input.ownerId;
|
|
1358
|
+
// The steal lock is held only briefly by concurrent lifecycle operations.
|
|
1359
|
+
// A contended lock never proves readiness: only the holder may validate and
|
|
1360
|
+
// publish the exact ready PID/generation state.
|
|
1361
|
+
const transition = await acquireTransitionLock({
|
|
1362
|
+
fs: fsImpl,
|
|
1363
|
+
path: paths.steal,
|
|
1364
|
+
pidIncarnation: input.pidIncarnation,
|
|
1365
|
+
sleep: input.sleep,
|
|
1366
|
+
retries: input.stealRetries,
|
|
1367
|
+
retryDelayMs: input.stealRetryDelayMs,
|
|
1368
|
+
});
|
|
1369
|
+
if (!transition) return false;
|
|
1370
|
+
try {
|
|
1371
|
+
const state = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1372
|
+
const pid = input.pid ?? state?.pid;
|
|
1373
|
+
const generation = input.generation ?? state?.generation;
|
|
1374
|
+
const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(pid ?? 0);
|
|
1375
|
+
// A daemon child may atomically bind its own PID only while its launcher
|
|
1376
|
+
// reservation is still provisional and carries the same acquisition secret.
|
|
1377
|
+
const canBindProvisionalPid =
|
|
1378
|
+
state?.ownershipPhase === "provisional" &&
|
|
1379
|
+
state.pid !== pid &&
|
|
1380
|
+
state.ownerId === input.ownerId &&
|
|
1381
|
+
state.acquisitionId === acquisitionId;
|
|
1382
|
+
if (
|
|
1383
|
+
!state ||
|
|
1384
|
+
!hasSafeDaemonStateShape(state) ||
|
|
1385
|
+
typeof pid !== "number" ||
|
|
1386
|
+
!Number.isSafeInteger(pid) ||
|
|
1387
|
+
pid <= 0 ||
|
|
1388
|
+
!isProcessIncarnation(incarnation) ||
|
|
1389
|
+
generation !== DAEMON_GENERATION ||
|
|
1390
|
+
state.ownerId !== input.ownerId ||
|
|
1391
|
+
state.acquisitionId !== acquisitionId ||
|
|
1392
|
+
(input.tokenFingerprint !== undefined && state.tokenFingerprint !== input.tokenFingerprint) ||
|
|
1393
|
+
(input.chatId !== undefined && state.chatId !== input.chatId) ||
|
|
1394
|
+
(!canBindProvisionalPid && state.incarnation !== incarnation) ||
|
|
1395
|
+
(!canBindProvisionalPid && state.pid !== pid) ||
|
|
1396
|
+
state.generation !== generation ||
|
|
1397
|
+
state.stoppedAt !== undefined ||
|
|
1398
|
+
state.ownershipPhase === "retired"
|
|
1399
|
+
)
|
|
1400
|
+
return false;
|
|
1401
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
|
|
1402
|
+
const previousLock = canBindProvisionalPid ? await readOwnershipLock(fsImpl, paths.lock) : undefined;
|
|
1403
|
+
const expectedLock =
|
|
1404
|
+
previousLock?.kind === "valid" &&
|
|
1405
|
+
previousLock.metadata.pid === state.pid &&
|
|
1406
|
+
previousLock.metadata.incarnation === state.incarnation &&
|
|
1407
|
+
previousLock.metadata.ownerId === state.ownerId &&
|
|
1408
|
+
previousLock.metadata.acquisitionId === state.acquisitionId
|
|
1409
|
+
? previousLock.metadata
|
|
1410
|
+
: undefined;
|
|
1411
|
+
const reboundLock = expectedLock ? { ...expectedLock, pid, incarnation } : undefined;
|
|
1412
|
+
if (
|
|
1413
|
+
canBindProvisionalPid &&
|
|
1414
|
+
(!expectedLock ||
|
|
1415
|
+
!reboundLock ||
|
|
1416
|
+
!(await rebindOwnershipLock({
|
|
1417
|
+
fs: fsImpl,
|
|
1418
|
+
path: paths.lock,
|
|
1419
|
+
transitionPath: paths.steal,
|
|
1420
|
+
transition,
|
|
1421
|
+
|
|
1422
|
+
expected: expectedLock,
|
|
1423
|
+
rebound: reboundLock,
|
|
1424
|
+
})))
|
|
1425
|
+
)
|
|
1426
|
+
return false;
|
|
682
1427
|
try {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
)
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
return { acquired: false, attached: true };
|
|
706
|
-
}
|
|
707
|
-
await writeState();
|
|
708
|
-
return { acquired: true, ownerId };
|
|
709
|
-
} finally {
|
|
710
|
-
await fsImpl.unlink(paths.steal).catch(() => undefined);
|
|
1428
|
+
await writeJsonAtomic(fsImpl, paths.state, {
|
|
1429
|
+
...state,
|
|
1430
|
+
// Preserve the source launcher PID so concurrent ensures can recognize
|
|
1431
|
+
// this ready PID as its child rather than excluding it as the launcher.
|
|
1432
|
+
launcherPid: state.pid,
|
|
1433
|
+
pid,
|
|
1434
|
+
incarnation,
|
|
1435
|
+
ownershipPhase: "ready",
|
|
1436
|
+
heartbeatAt: (input.now ?? Date.now)(),
|
|
1437
|
+
});
|
|
1438
|
+
} catch {
|
|
1439
|
+
if (expectedLock && reboundLock)
|
|
1440
|
+
await rollbackOwnershipLockRebind({
|
|
1441
|
+
fs: fsImpl,
|
|
1442
|
+
path: paths.lock,
|
|
1443
|
+
transitionPath: paths.steal,
|
|
1444
|
+
transition,
|
|
1445
|
+
|
|
1446
|
+
previous: expectedLock,
|
|
1447
|
+
rebound: reboundLock,
|
|
1448
|
+
});
|
|
1449
|
+
return false;
|
|
711
1450
|
}
|
|
1451
|
+
return true;
|
|
1452
|
+
} finally {
|
|
1453
|
+
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** Acquire the lifecycle transition lock with bounded retry for bind/retire races. */
|
|
1458
|
+
export async function acquireTransitionLock(input: {
|
|
1459
|
+
fs: TelegramDaemonFs;
|
|
1460
|
+
path: string;
|
|
1461
|
+
pidAlive?: (pid: number) => boolean;
|
|
1462
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1463
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1464
|
+
retries?: number;
|
|
1465
|
+
retryDelayMs?: number;
|
|
1466
|
+
}): Promise<DaemonTransitionLock | undefined> {
|
|
1467
|
+
return await acquireDaemonTransitionLock({
|
|
1468
|
+
fs: input.fs,
|
|
1469
|
+
path: input.path,
|
|
1470
|
+
pid: process.pid,
|
|
1471
|
+
pidAlive: input.pidAlive ?? defaultPidAlive,
|
|
1472
|
+
pidIncarnation: input.pidIncarnation ?? defaultPidIncarnation,
|
|
1473
|
+
sleep: input.sleep,
|
|
1474
|
+
retries: input.retries,
|
|
1475
|
+
retryDelayMs: input.retryDelayMs,
|
|
712
1476
|
});
|
|
713
1477
|
}
|
|
714
1478
|
|
|
715
|
-
|
|
1479
|
+
/** Retire only the unchanged provisional acquisition after bounded readiness fails. */
|
|
1480
|
+
export async function retireProvisionalDaemonOwnership(input: {
|
|
1481
|
+
settings: Settings;
|
|
1482
|
+
ownerId: string;
|
|
1483
|
+
acquisitionId?: string;
|
|
1484
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1485
|
+
pidAlive?: (pid: number) => boolean;
|
|
1486
|
+
/** Detached child PID, when the launcher successfully reported it. */
|
|
1487
|
+
pid: number;
|
|
1488
|
+
/** PID which created the provisional reservation before the child was bound. */
|
|
1489
|
+
launcherPid?: number;
|
|
1490
|
+
fs?: TelegramDaemonFs;
|
|
1491
|
+
now?: () => number;
|
|
1492
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1493
|
+
stealRetries?: number;
|
|
1494
|
+
stealRetryDelayMs?: number;
|
|
1495
|
+
/** Only no-child confirmation may retire a ready-like launcher publication. */
|
|
1496
|
+
allowReadyWithoutChildPid?: boolean;
|
|
1497
|
+
}): Promise<boolean> {
|
|
1498
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
1499
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
1500
|
+
const transition = await acquireTransitionLock({
|
|
1501
|
+
fs: fsImpl,
|
|
1502
|
+
path: paths.steal,
|
|
1503
|
+
pidAlive: input.pidAlive,
|
|
1504
|
+
pidIncarnation: input.pidIncarnation,
|
|
1505
|
+
sleep: input.sleep,
|
|
1506
|
+
retries: input.stealRetries,
|
|
1507
|
+
retryDelayMs: input.stealRetryDelayMs,
|
|
1508
|
+
});
|
|
1509
|
+
if (!transition) return false;
|
|
1510
|
+
try {
|
|
1511
|
+
const state = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1512
|
+
const acquisitionId = input.acquisitionId ?? input.ownerId;
|
|
1513
|
+
const expectedPid = state?.pid === input.pid ? input.pid : input.launcherPid;
|
|
1514
|
+
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
1515
|
+
const expectedPidAlive = expectedPid !== undefined && pidAlive(expectedPid);
|
|
1516
|
+
const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(expectedPid ?? 0);
|
|
1517
|
+
if (
|
|
1518
|
+
!state ||
|
|
1519
|
+
state.ownerId !== input.ownerId ||
|
|
1520
|
+
state.acquisitionId !== acquisitionId ||
|
|
1521
|
+
(expectedPidAlive &&
|
|
1522
|
+
(!isProcessIncarnation(incarnation) ||
|
|
1523
|
+
!isProcessIncarnation(state.incarnation) ||
|
|
1524
|
+
state.incarnation !== incarnation)) ||
|
|
1525
|
+
(state.pid !== input.pid && state.pid !== input.launcherPid) ||
|
|
1526
|
+
state.generation !== DAEMON_GENERATION ||
|
|
1527
|
+
!Number.isSafeInteger(state.generation) ||
|
|
1528
|
+
(state.ownershipPhase !== "provisional" &&
|
|
1529
|
+
!(input.allowReadyWithoutChildPid === true && state.ownershipPhase === "ready"))
|
|
1530
|
+
)
|
|
1531
|
+
return false;
|
|
1532
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
|
|
1533
|
+
await writeJsonAtomic(fsImpl, paths.state, {
|
|
1534
|
+
...state,
|
|
1535
|
+
ownershipPhase: "retired",
|
|
1536
|
+
stoppedAt: (input.now ?? Date.now)(),
|
|
1537
|
+
});
|
|
1538
|
+
if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
|
|
1539
|
+
await fsImpl.unlink(paths.lock).catch(() => undefined);
|
|
1540
|
+
return true;
|
|
1541
|
+
} finally {
|
|
1542
|
+
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/** Bind a launcher-reserved provisional acquisition to its actual detached child. */
|
|
1547
|
+
async function bindProvisionalDaemonPid(input: {
|
|
716
1548
|
settings: Settings;
|
|
717
1549
|
ownerId: string;
|
|
718
|
-
|
|
719
|
-
tokenFingerprint: string;
|
|
720
|
-
chatId: string;
|
|
721
|
-
/** Require the current daemon PID after the launcher-to-daemon PID rebind. */
|
|
1550
|
+
acquisitionId: string;
|
|
722
1551
|
pid: number;
|
|
1552
|
+
incarnation: string;
|
|
723
1553
|
fs?: TelegramDaemonFs;
|
|
724
|
-
|
|
1554
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1555
|
+
stealRetries?: number;
|
|
1556
|
+
stealRetryDelayMs?: number;
|
|
725
1557
|
}): Promise<boolean> {
|
|
726
1558
|
const fsImpl = input.fs ?? nodeFs;
|
|
727
1559
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
728
|
-
|
|
1560
|
+
const transition = await acquireTransitionLock({
|
|
1561
|
+
fs: fsImpl,
|
|
1562
|
+
path: paths.steal,
|
|
1563
|
+
sleep: input.sleep,
|
|
1564
|
+
retries: input.stealRetries,
|
|
1565
|
+
retryDelayMs: input.stealRetryDelayMs,
|
|
1566
|
+
});
|
|
1567
|
+
if (!transition) return false;
|
|
1568
|
+
try {
|
|
729
1569
|
const state = await readJson<DaemonState>(fsImpl, paths.state);
|
|
730
1570
|
if (
|
|
731
1571
|
!state ||
|
|
732
|
-
state.stoppedAt !== undefined ||
|
|
733
|
-
typeof input.tokenFingerprint !== "string" ||
|
|
734
|
-
typeof input.chatId !== "string" ||
|
|
735
|
-
!Number.isSafeInteger(input.pid) ||
|
|
736
|
-
input.pid <= 0 ||
|
|
737
1572
|
state.ownerId !== input.ownerId ||
|
|
738
|
-
|
|
1573
|
+
state.acquisitionId !== input.acquisitionId ||
|
|
1574
|
+
state.ownershipPhase !== "provisional" ||
|
|
1575
|
+
state.generation !== DAEMON_GENERATION ||
|
|
1576
|
+
!isProcessIncarnation(state.incarnation) ||
|
|
1577
|
+
!isProcessIncarnation(input.incarnation)
|
|
739
1578
|
)
|
|
740
1579
|
return false;
|
|
741
|
-
if (!
|
|
742
|
-
|
|
1580
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
|
|
1581
|
+
const previousLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
1582
|
+
if (
|
|
1583
|
+
previousLock.kind !== "valid" ||
|
|
1584
|
+
previousLock.metadata.pid !== state.pid ||
|
|
1585
|
+
previousLock.metadata.incarnation !== state.incarnation ||
|
|
1586
|
+
previousLock.metadata.ownerId !== state.ownerId ||
|
|
1587
|
+
previousLock.metadata.acquisitionId !== state.acquisitionId
|
|
1588
|
+
)
|
|
743
1589
|
return false;
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
1590
|
+
const reboundLock = { ...previousLock.metadata, pid: input.pid, incarnation: input.incarnation };
|
|
1591
|
+
if (
|
|
1592
|
+
!(await rebindOwnershipLock({
|
|
1593
|
+
fs: fsImpl,
|
|
1594
|
+
path: paths.lock,
|
|
1595
|
+
transitionPath: paths.steal,
|
|
1596
|
+
transition,
|
|
1597
|
+
|
|
1598
|
+
expected: previousLock.metadata,
|
|
1599
|
+
rebound: reboundLock,
|
|
1600
|
+
}))
|
|
1601
|
+
)
|
|
1602
|
+
return false;
|
|
1603
|
+
try {
|
|
1604
|
+
await writeJsonAtomic(fsImpl, paths.state, {
|
|
1605
|
+
...state,
|
|
1606
|
+
// This durable marker distinguishes a launcher reservation from a PID
|
|
1607
|
+
// the launcher authoritatively rebound to its child.
|
|
1608
|
+
launcherPid: state.launcherPid ?? state.pid,
|
|
1609
|
+
pid: input.pid,
|
|
1610
|
+
incarnation: input.incarnation,
|
|
1611
|
+
});
|
|
1612
|
+
} catch {
|
|
1613
|
+
await rollbackOwnershipLockRebind({
|
|
1614
|
+
fs: fsImpl,
|
|
1615
|
+
path: paths.lock,
|
|
1616
|
+
transitionPath: paths.steal,
|
|
1617
|
+
transition,
|
|
1618
|
+
previous: previousLock.metadata,
|
|
1619
|
+
rebound: reboundLock,
|
|
1620
|
+
});
|
|
1621
|
+
return false;
|
|
1622
|
+
}
|
|
749
1623
|
return true;
|
|
750
|
-
}
|
|
1624
|
+
} finally {
|
|
1625
|
+
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1626
|
+
}
|
|
751
1627
|
}
|
|
752
1628
|
|
|
753
|
-
|
|
1629
|
+
/** Wait for a matching current-generation daemon to publish a ready state. */
|
|
1630
|
+
export async function waitForTelegramDaemonReady(input: {
|
|
754
1631
|
settings: Settings;
|
|
755
|
-
ownerId
|
|
756
|
-
|
|
1632
|
+
ownerId?: string;
|
|
1633
|
+
acquisitionId?: string;
|
|
1634
|
+
pid?: number;
|
|
1635
|
+
excludedPid?: number;
|
|
1636
|
+
tokenFingerprint: string;
|
|
1637
|
+
chatId: string;
|
|
1638
|
+
fs?: TelegramDaemonFs;
|
|
1639
|
+
now?: () => number;
|
|
1640
|
+
pidAlive?: (pid: number) => boolean;
|
|
1641
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1642
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1643
|
+
waitStepMs?: number;
|
|
1644
|
+
timeoutMs?: number;
|
|
1645
|
+
}): Promise<boolean> {
|
|
1646
|
+
const now = input.now ?? Date.now;
|
|
1647
|
+
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
1648
|
+
const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
|
|
1649
|
+
const sleep = input.sleep ?? (async (ms: number) => await Bun.sleep(ms));
|
|
1650
|
+
const timeoutMs = Math.max(input.timeoutMs ?? 8_000, 0);
|
|
1651
|
+
const waitStepMs = Math.max(input.waitStepMs ?? 25, 1);
|
|
1652
|
+
const deadline = now() + timeoutMs;
|
|
1653
|
+
const maxPolls = Math.ceil(timeoutMs / waitStepMs);
|
|
1654
|
+
for (let poll = 0; poll <= maxPolls; poll++) {
|
|
1655
|
+
const state = await readDaemonState(input.settings, input.fs);
|
|
1656
|
+
if (
|
|
1657
|
+
(!input.ownerId || state?.ownerId === input.ownerId) &&
|
|
1658
|
+
(!input.acquisitionId || state?.acquisitionId === input.acquisitionId) &&
|
|
1659
|
+
state?.ownershipPhase === "ready" &&
|
|
1660
|
+
state.generation === DAEMON_GENERATION &&
|
|
1661
|
+
ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
|
|
1662
|
+
ownerProvenanceMatches(state, pidIncarnation) &&
|
|
1663
|
+
pidAlive(state.pid) &&
|
|
1664
|
+
(Number.isSafeInteger(input.pid) && (input.pid as number) > 0
|
|
1665
|
+
? state.pid === input.pid
|
|
1666
|
+
: !Number.isSafeInteger(input.excludedPid) || state.pid !== input.excludedPid) &&
|
|
1667
|
+
isCurrentCompatibleOwner({
|
|
1668
|
+
state,
|
|
1669
|
+
now: now(),
|
|
1670
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1671
|
+
chatId: input.chatId,
|
|
1672
|
+
pidAlive,
|
|
1673
|
+
pidIncarnation,
|
|
1674
|
+
})
|
|
1675
|
+
)
|
|
1676
|
+
return true;
|
|
1677
|
+
if (now() >= deadline || poll === maxPolls) break;
|
|
1678
|
+
await sleep(waitStepMs);
|
|
1679
|
+
}
|
|
1680
|
+
return false;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/** Confirm the provisional owner or retire only its unchanged acquisition. */
|
|
1684
|
+
export async function confirmTelegramDaemonSpawn(input: {
|
|
1685
|
+
settings: Settings;
|
|
1686
|
+
spawned: TelegramSpawnOwnerResult;
|
|
757
1687
|
tokenFingerprint: string;
|
|
758
1688
|
chatId: string;
|
|
759
|
-
/** Require the current daemon PID after the launcher-to-daemon PID rebind. */
|
|
760
1689
|
pid: number;
|
|
761
1690
|
fs?: TelegramDaemonFs;
|
|
762
1691
|
now?: () => number;
|
|
1692
|
+
pidAlive?: (pid: number) => boolean;
|
|
1693
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1694
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1695
|
+
waitStepMs?: number;
|
|
1696
|
+
timeoutMs?: number;
|
|
1697
|
+
}): Promise<boolean> {
|
|
1698
|
+
if (input.spawned.result !== "owner_spawned") return true;
|
|
1699
|
+
const childPid = input.spawned.acquisition.pid;
|
|
1700
|
+
const hasExactChildPid = Number.isSafeInteger(childPid) && (childPid as number) > 0;
|
|
1701
|
+
const ready = await waitForTelegramDaemonReady({
|
|
1702
|
+
settings: input.settings,
|
|
1703
|
+
ownerId: input.spawned.acquisition.ownerId,
|
|
1704
|
+
acquisitionId: input.spawned.acquisition.acquisitionId,
|
|
1705
|
+
pid: hasExactChildPid ? childPid : undefined,
|
|
1706
|
+
excludedPid: hasExactChildPid ? undefined : input.pid,
|
|
1707
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1708
|
+
chatId: input.chatId,
|
|
1709
|
+
fs: input.fs,
|
|
1710
|
+
now: input.now,
|
|
1711
|
+
pidAlive: input.pidAlive,
|
|
1712
|
+
pidIncarnation: input.pidIncarnation,
|
|
1713
|
+
sleep: input.sleep,
|
|
1714
|
+
waitStepMs: input.waitStepMs,
|
|
1715
|
+
timeoutMs: input.timeoutMs,
|
|
1716
|
+
});
|
|
1717
|
+
if (ready) return true;
|
|
1718
|
+
const launcherPid = input.spawned.acquisition.launcherPid ?? input.pid;
|
|
1719
|
+
const retired = await retireProvisionalDaemonOwnership({
|
|
1720
|
+
settings: input.settings,
|
|
1721
|
+
ownerId: input.spawned.acquisition.ownerId,
|
|
1722
|
+
acquisitionId: input.spawned.acquisition.acquisitionId,
|
|
1723
|
+
pid: hasExactChildPid ? (childPid as number) : launcherPid,
|
|
1724
|
+
launcherPid,
|
|
1725
|
+
allowReadyWithoutChildPid: !hasExactChildPid,
|
|
1726
|
+
fs: input.fs,
|
|
1727
|
+
now: input.now,
|
|
1728
|
+
pidAlive: input.pidAlive,
|
|
1729
|
+
pidIncarnation: input.pidIncarnation,
|
|
1730
|
+
sleep: input.sleep,
|
|
1731
|
+
});
|
|
1732
|
+
if (retired) return false;
|
|
1733
|
+
const state = await readDaemonState(input.settings, input.fs);
|
|
1734
|
+
if (
|
|
1735
|
+
hasExactChildPid &&
|
|
1736
|
+
isCurrentCompatibleOwner({
|
|
1737
|
+
state,
|
|
1738
|
+
now: (input.now ?? Date.now)(),
|
|
1739
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1740
|
+
chatId: input.chatId,
|
|
1741
|
+
pidAlive: input.pidAlive ?? defaultPidAlive,
|
|
1742
|
+
pidIncarnation: input.pidIncarnation ?? defaultPidIncarnation,
|
|
1743
|
+
}) &&
|
|
1744
|
+
(state?.ownerId !== input.spawned.acquisition.ownerId ||
|
|
1745
|
+
state.acquisitionId !== input.spawned.acquisition.acquisitionId ||
|
|
1746
|
+
state.pid === childPid)
|
|
1747
|
+
)
|
|
1748
|
+
return true;
|
|
1749
|
+
if (
|
|
1750
|
+
state?.ownerId === input.spawned.acquisition.ownerId &&
|
|
1751
|
+
state.acquisitionId === input.spawned.acquisition.acquisitionId &&
|
|
1752
|
+
(state.pid === childPid || state.pid === launcherPid || state.pid === input.pid) &&
|
|
1753
|
+
state.ownershipPhase === "retired"
|
|
1754
|
+
)
|
|
1755
|
+
return false;
|
|
1756
|
+
throw new Error("Telegram daemon provisional ownership could not be retired safely");
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
export async function releaseDaemonOwnership(input: {
|
|
1760
|
+
settings: Settings;
|
|
1761
|
+
ownerId: string;
|
|
1762
|
+
acquisitionId?: string;
|
|
1763
|
+
tokenFingerprint?: string;
|
|
1764
|
+
chatId?: string;
|
|
1765
|
+
pid?: number;
|
|
1766
|
+
generation?: number;
|
|
1767
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1768
|
+
fs?: TelegramDaemonFs;
|
|
1769
|
+
now?: () => number;
|
|
763
1770
|
}): Promise<void> {
|
|
764
1771
|
const fsImpl = input.fs ?? nodeFs;
|
|
765
1772
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
766
|
-
|
|
1773
|
+
const transition = await acquireTransitionLock({
|
|
1774
|
+
fs: fsImpl,
|
|
1775
|
+
path: paths.steal,
|
|
1776
|
+
pidIncarnation: input.pidIncarnation,
|
|
1777
|
+
});
|
|
1778
|
+
if (!transition) return;
|
|
1779
|
+
try {
|
|
767
1780
|
const state = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1781
|
+
const acquisitionId = input.acquisitionId ?? input.ownerId;
|
|
1782
|
+
const pid = input.pid ?? state?.pid;
|
|
1783
|
+
const generation = input.generation ?? state?.generation;
|
|
1784
|
+
const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(pid ?? 0);
|
|
768
1785
|
if (
|
|
769
|
-
!state ||
|
|
1786
|
+
!hasSafeDaemonStateShape(state) ||
|
|
770
1787
|
state.ownerId !== input.ownerId ||
|
|
771
|
-
|
|
772
|
-
state.
|
|
1788
|
+
state.acquisitionId !== acquisitionId ||
|
|
1789
|
+
(input.tokenFingerprint !== undefined && state.tokenFingerprint !== input.tokenFingerprint) ||
|
|
1790
|
+
(input.chatId !== undefined && state.chatId !== input.chatId) ||
|
|
1791
|
+
state.pid !== pid ||
|
|
1792
|
+
!isProcessIncarnation(incarnation) ||
|
|
1793
|
+
state.incarnation !== incarnation ||
|
|
1794
|
+
state.generation !== generation ||
|
|
1795
|
+
!Number.isSafeInteger(generation)
|
|
773
1796
|
)
|
|
774
1797
|
return;
|
|
1798
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return;
|
|
775
1799
|
await writeJsonAtomic(fsImpl, paths.state, { ...state, stoppedAt: (input.now ?? Date.now)() });
|
|
776
|
-
await fsImpl
|
|
777
|
-
|
|
1800
|
+
if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
|
|
1801
|
+
await fsImpl.unlink(paths.lock).catch(() => undefined);
|
|
1802
|
+
} finally {
|
|
1803
|
+
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1804
|
+
}
|
|
778
1805
|
}
|
|
779
1806
|
|
|
780
1807
|
/** Read the persisted daemon ownership state (or undefined when absent). */
|
|
@@ -803,6 +1830,15 @@ function defaultPidAlive(pid: number): boolean {
|
|
|
803
1830
|
}
|
|
804
1831
|
}
|
|
805
1832
|
|
|
1833
|
+
/**
|
|
1834
|
+
* Use immutable process-start provenance on every supported OS. In particular,
|
|
1835
|
+
* Windows is authorized by the native process API or PowerShell StartTime/FileTime;
|
|
1836
|
+
* absent authority fails closed and never trusts the numeric PID alone.
|
|
1837
|
+
*/
|
|
1838
|
+
function defaultPidIncarnation(pid: number): string | undefined {
|
|
1839
|
+
return processIncarnation(pid);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
806
1842
|
/** True for AbortError-shaped rejections raised when an in-flight fetch is aborted. */
|
|
807
1843
|
function isAbortError(err: unknown): boolean {
|
|
808
1844
|
return err instanceof Error && (err.name === "AbortError" || /\baborted\b/i.test(err.message));
|
|
@@ -828,7 +1864,7 @@ function defaultDaemonSpawn(
|
|
|
828
1864
|
const child = childProcessSpawn(command, args, { detached: opts.detached, stdio });
|
|
829
1865
|
// Best-effort autostart: a spawn failure must never crash the host session.
|
|
830
1866
|
child.on("error", () => undefined);
|
|
831
|
-
return { unref: () => child.unref() };
|
|
1867
|
+
return { pid: child.pid, unref: () => child.unref() };
|
|
832
1868
|
}
|
|
833
1869
|
|
|
834
1870
|
export interface TelegramSpawnOwnerInput {
|
|
@@ -838,19 +1874,20 @@ export interface TelegramSpawnOwnerInput {
|
|
|
838
1874
|
chatId: string;
|
|
839
1875
|
}
|
|
840
1876
|
|
|
841
|
-
export interface
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
/**
|
|
847
|
-
|
|
848
|
-
* older daemon generation. The caller must hand off via a reload rather than
|
|
849
|
-
* attach; see {@link ensureTelegramDaemonRunning}.
|
|
850
|
-
*/
|
|
851
|
-
reloadRequired?: boolean;
|
|
1877
|
+
export interface TelegramSpawnAcquisition {
|
|
1878
|
+
readonly ownerId: string;
|
|
1879
|
+
readonly acquisitionId: string;
|
|
1880
|
+
/** PID of the launcher which reserved provisional ownership before spawn. */
|
|
1881
|
+
readonly launcherPid?: number;
|
|
1882
|
+
/** Actual detached child which must publish the ready owner state. */
|
|
1883
|
+
readonly pid?: number;
|
|
852
1884
|
}
|
|
853
1885
|
|
|
1886
|
+
export type TelegramSpawnOwnerResult =
|
|
1887
|
+
| { result: "owner_spawned"; acquisition: TelegramSpawnAcquisition; runtime: DaemonRuntimeInfo; warnings: string[] }
|
|
1888
|
+
| { result: "attached"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean }
|
|
1889
|
+
| { result: "blocked"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean };
|
|
1890
|
+
|
|
854
1891
|
/**
|
|
855
1892
|
* Build the detached spawn command/args for the daemon-internal entrypoint.
|
|
856
1893
|
* Source mode prepends the entry script so the respawn loads edited source;
|
|
@@ -910,26 +1947,41 @@ export async function spawnTelegramDaemonOwner(
|
|
|
910
1947
|
now: deps.now,
|
|
911
1948
|
pid: deps.pid,
|
|
912
1949
|
pidAlive: deps.pidAlive,
|
|
1950
|
+
pidIncarnation: deps.pidIncarnation,
|
|
913
1951
|
randomId: ownerId ? undefined : deps.randomId,
|
|
914
1952
|
ownerId,
|
|
915
|
-
allowPidRebind: true,
|
|
916
|
-
});
|
|
917
|
-
// Build args from the shared runtime resolver.
|
|
918
|
-
const { command, args, runtime } = buildTelegramDaemonSpawnArgs({
|
|
919
|
-
execPath,
|
|
920
|
-
ownerId: ownership.ownerId ?? "",
|
|
921
|
-
agentDir,
|
|
922
1953
|
});
|
|
923
1954
|
if (!ownership.acquired) {
|
|
924
|
-
if (ownership.blocked) {
|
|
1955
|
+
if (ownership.blocked || ownership.provisional) {
|
|
925
1956
|
return {
|
|
926
1957
|
result: "blocked",
|
|
927
|
-
runtime,
|
|
928
|
-
warnings: [
|
|
1958
|
+
runtime: buildTelegramDaemonSpawnArgs({ execPath, ownerId: "", agentDir }).runtime,
|
|
1959
|
+
warnings: [
|
|
1960
|
+
ownership.provisional
|
|
1961
|
+
? "telegram daemon ownership is provisional; refusing to attach"
|
|
1962
|
+
: "live telegram daemon uses a different bot token or chat; refusing to attach",
|
|
1963
|
+
],
|
|
929
1964
|
};
|
|
930
1965
|
}
|
|
931
|
-
return {
|
|
1966
|
+
return {
|
|
1967
|
+
result: "attached",
|
|
1968
|
+
runtime: buildTelegramDaemonSpawnArgs({ execPath, ownerId: "", agentDir }).runtime,
|
|
1969
|
+
warnings: [],
|
|
1970
|
+
reloadRequired: ownership.reloadRequired,
|
|
1971
|
+
};
|
|
932
1972
|
}
|
|
1973
|
+
const launcherPid = deps.pid ?? process.pid;
|
|
1974
|
+
const provisionalAcquisition: TelegramSpawnAcquisition = Object.freeze({
|
|
1975
|
+
ownerId: ownership.ownerId as string,
|
|
1976
|
+
acquisitionId: ownership.acquisitionId as string,
|
|
1977
|
+
launcherPid,
|
|
1978
|
+
});
|
|
1979
|
+
// One source of truth for runtime detection + spawn args (no duplicate resolve).
|
|
1980
|
+
const { command, args, runtime } = buildTelegramDaemonSpawnArgs({
|
|
1981
|
+
execPath,
|
|
1982
|
+
ownerId: provisionalAcquisition.ownerId,
|
|
1983
|
+
agentDir,
|
|
1984
|
+
});
|
|
933
1985
|
const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
|
|
934
1986
|
const child = spawnImpl(command, args, {
|
|
935
1987
|
detached: true,
|
|
@@ -937,7 +1989,25 @@ export async function spawnTelegramDaemonOwner(
|
|
|
937
1989
|
logPath: path.join(daemonPaths(agentDir).dir, "daemon.log"),
|
|
938
1990
|
});
|
|
939
1991
|
child?.unref?.();
|
|
940
|
-
|
|
1992
|
+
// A launcher can reserve ownership, but only the actual child can become
|
|
1993
|
+
// ready. Missing PID provenance is intentionally non-ready and times out.
|
|
1994
|
+
const childPid = child?.pid;
|
|
1995
|
+
if (!Number.isSafeInteger(childPid) || (childPid as number) <= 0)
|
|
1996
|
+
return { result: "owner_spawned", acquisition: provisionalAcquisition, runtime, warnings: [] };
|
|
1997
|
+
const pid = childPid as number;
|
|
1998
|
+
const acquisition: TelegramSpawnAcquisition = Object.freeze({ ...provisionalAcquisition, pid });
|
|
1999
|
+
const incarnation = (deps.pidIncarnation ?? defaultPidIncarnation)(pid);
|
|
2000
|
+
if (!incarnation) return { result: "owner_spawned", acquisition, runtime, warnings: [] };
|
|
2001
|
+
await bindProvisionalDaemonPid({
|
|
2002
|
+
settings: input.settings,
|
|
2003
|
+
ownerId: acquisition.ownerId,
|
|
2004
|
+
acquisitionId: acquisition.acquisitionId,
|
|
2005
|
+
incarnation,
|
|
2006
|
+
pid,
|
|
2007
|
+
fs: deps.fs,
|
|
2008
|
+
sleep: deps.sleep,
|
|
2009
|
+
});
|
|
2010
|
+
return { result: "owner_spawned", acquisition, runtime, warnings: [] };
|
|
941
2011
|
}
|
|
942
2012
|
|
|
943
2013
|
/**
|
|
@@ -950,25 +2020,94 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
950
2020
|
): Promise<EnsureTelegramDaemonDetailedResult> {
|
|
951
2021
|
const cfg = getNotificationConfig(input.settings);
|
|
952
2022
|
if (!isTelegramConfigured(cfg)) return "disabled";
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
2023
|
+
const root = notificationRootForCwd(input.cwd);
|
|
2024
|
+
const fp = tokenFingerprint(cfg.botToken);
|
|
2025
|
+
let spawned = await withTelegramSetupLease(
|
|
2026
|
+
cfg.botToken,
|
|
2027
|
+
async () =>
|
|
2028
|
+
await spawnTelegramDaemonOwner(
|
|
2029
|
+
{ settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
|
|
2030
|
+
deps,
|
|
2031
|
+
),
|
|
2032
|
+
);
|
|
2033
|
+
if (spawned.result === "blocked" && spawned.warnings[0]?.includes("provisional")) {
|
|
2034
|
+
const provisional = await readDaemonState(input.settings, deps.fs);
|
|
2035
|
+
// A launcher-reserved PID can be rebound by the child heartbeat. Only a
|
|
2036
|
+
// state marked by bindProvisionalDaemonPid has an authoritative child PID.
|
|
2037
|
+
const hasAuthoritativeChildPid =
|
|
2038
|
+
Number.isSafeInteger(provisional?.launcherPid) && (provisional?.launcherPid as number) > 0;
|
|
2039
|
+
const ready = await waitForTelegramDaemonReady({
|
|
2040
|
+
settings: input.settings,
|
|
2041
|
+
ownerId: provisional?.ownerId,
|
|
2042
|
+
acquisitionId: provisional?.acquisitionId,
|
|
2043
|
+
pid: hasAuthoritativeChildPid ? provisional?.pid : undefined,
|
|
2044
|
+
excludedPid: hasAuthoritativeChildPid ? undefined : provisional?.pid,
|
|
2045
|
+
tokenFingerprint: fp,
|
|
2046
|
+
chatId: cfg.chatId,
|
|
2047
|
+
fs: deps.fs,
|
|
2048
|
+
now: deps.now,
|
|
2049
|
+
pidAlive: deps.pidAlive,
|
|
2050
|
+
pidIncarnation: deps.pidIncarnation,
|
|
2051
|
+
sleep: deps.sleep,
|
|
2052
|
+
waitStepMs: deps.waitStepMs,
|
|
2053
|
+
timeoutMs: deps.readinessTimeoutMs,
|
|
2054
|
+
});
|
|
2055
|
+
if (ready) {
|
|
2056
|
+
spawned = await withTelegramSetupLease(
|
|
2057
|
+
cfg.botToken,
|
|
2058
|
+
async () =>
|
|
2059
|
+
await spawnTelegramDaemonOwner(
|
|
2060
|
+
{ settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
|
|
2061
|
+
deps,
|
|
2062
|
+
),
|
|
2063
|
+
);
|
|
963
2064
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
2065
|
+
}
|
|
2066
|
+
if (spawned.result === "blocked") {
|
|
2067
|
+
logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
|
|
2068
|
+
return "blocked_identity";
|
|
2069
|
+
}
|
|
2070
|
+
if (spawned.result === "attached" && spawned.reloadRequired) {
|
|
2071
|
+
const previous = await readNotificationRootRegistration({ ...input, fs: deps.fs });
|
|
2072
|
+
await registerNotificationRoot({ ...input, fs: deps.fs });
|
|
2073
|
+
const controller = new TelegramDaemonController(input.settings, telegramControllerDeps(deps));
|
|
2074
|
+
const upgrade = await controller.reloadForGenerationUpgrade();
|
|
2075
|
+
if (upgrade.outcome !== "ready") {
|
|
2076
|
+
await restoreNotificationRootRegistration({
|
|
2077
|
+
settings: input.settings,
|
|
2078
|
+
sessionId: input.sessionId,
|
|
2079
|
+
registeredRoot: root,
|
|
2080
|
+
previous,
|
|
2081
|
+
fs: deps.fs,
|
|
2082
|
+
});
|
|
2083
|
+
throw new Error(`Unable to replace stale Telegram daemon: ${upgrade.operation.message}`);
|
|
968
2084
|
}
|
|
2085
|
+
return "reloaded";
|
|
2086
|
+
}
|
|
2087
|
+
if (spawned.result !== "owner_spawned") {
|
|
969
2088
|
await registerNotificationRoot({ ...input, fs: deps.fs });
|
|
970
|
-
return
|
|
971
|
-
}
|
|
2089
|
+
return "attached";
|
|
2090
|
+
}
|
|
2091
|
+
if (
|
|
2092
|
+
await confirmTelegramDaemonSpawn({
|
|
2093
|
+
settings: input.settings,
|
|
2094
|
+
spawned,
|
|
2095
|
+
tokenFingerprint: fp,
|
|
2096
|
+
chatId: cfg.chatId,
|
|
2097
|
+
pid: deps.pid ?? process.pid,
|
|
2098
|
+
fs: deps.fs,
|
|
2099
|
+
now: deps.now,
|
|
2100
|
+
pidAlive: deps.pidAlive,
|
|
2101
|
+
pidIncarnation: deps.pidIncarnation,
|
|
2102
|
+
sleep: deps.sleep,
|
|
2103
|
+
waitStepMs: deps.waitStepMs,
|
|
2104
|
+
timeoutMs: deps.readinessTimeoutMs,
|
|
2105
|
+
})
|
|
2106
|
+
) {
|
|
2107
|
+
await registerNotificationRoot({ ...input, fs: deps.fs });
|
|
2108
|
+
return "spawned";
|
|
2109
|
+
}
|
|
2110
|
+
throw new Error("Telegram daemon did not become ready after spawning");
|
|
972
2111
|
}
|
|
973
2112
|
|
|
974
2113
|
/**
|
|
@@ -993,26 +2132,21 @@ export async function ensureTelegramDaemonRunning(
|
|
|
993
2132
|
}
|
|
994
2133
|
}
|
|
995
2134
|
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
* cooperative SIGTERM/control handoff. Lazily imports the controller to avoid a
|
|
999
|
-
* static import cycle (the controller module imports ownership helpers here).
|
|
1000
|
-
*/
|
|
1001
|
-
async function reloadStaleGenerationOwner(settings: Settings, deps: TelegramDaemonDeps): Promise<void> {
|
|
1002
|
-
const { TelegramDaemonController } = await import("./telegram-daemon-control");
|
|
1003
|
-
const controller = new TelegramDaemonController(settings, {
|
|
2135
|
+
function telegramControllerDeps(deps: TelegramDaemonDeps): TelegramDaemonControlDeps {
|
|
2136
|
+
return {
|
|
1004
2137
|
fs: deps.fs,
|
|
1005
2138
|
now: deps.now,
|
|
1006
2139
|
pidAlive: deps.pidAlive,
|
|
1007
|
-
|
|
2140
|
+
processReference: deps.processReference,
|
|
2141
|
+
pidIncarnation: deps.pidIncarnation,
|
|
1008
2142
|
spawn: deps.spawn,
|
|
1009
2143
|
execPath: deps.execPath,
|
|
1010
2144
|
ownerPid: deps.pid,
|
|
1011
2145
|
randomId: deps.randomId,
|
|
1012
2146
|
sleep: deps.sleep,
|
|
1013
2147
|
waitStepMs: deps.waitStepMs,
|
|
1014
|
-
|
|
1015
|
-
|
|
2148
|
+
readinessTimeoutMs: deps.readinessTimeoutMs,
|
|
2149
|
+
};
|
|
1016
2150
|
}
|
|
1017
2151
|
|
|
1018
2152
|
export interface BotApi {
|
|
@@ -1293,6 +2427,20 @@ export interface DaemonControlHooks {
|
|
|
1293
2427
|
clear?(ownerId: string): Promise<void>;
|
|
1294
2428
|
}
|
|
1295
2429
|
|
|
2430
|
+
type BtwTerminalDeliveryOutcome = "accepted" | "not_delivered" | "uncertain" | "partial_accepted" | "stale";
|
|
2431
|
+
|
|
2432
|
+
interface BtwTerminalDeliveryReceipt {
|
|
2433
|
+
requestId: string;
|
|
2434
|
+
logicalSessionId: string;
|
|
2435
|
+
transportSessionId: string;
|
|
2436
|
+
threadId: string;
|
|
2437
|
+
updateId: number;
|
|
2438
|
+
messageId: number;
|
|
2439
|
+
outcome: BtwTerminalDeliveryOutcome;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
const BTW_TERMINAL_DELIVERY_TEST_OBSERVER = Symbol.for("gjc.test.btw-terminal-delivery-observer");
|
|
2443
|
+
|
|
1296
2444
|
export interface TelegramDaemonOptions {
|
|
1297
2445
|
settings: Settings;
|
|
1298
2446
|
ownerId: string;
|
|
@@ -1313,6 +2461,7 @@ export interface TelegramDaemonOptions {
|
|
|
1313
2461
|
pid?: number;
|
|
1314
2462
|
/** Liveness probe for skipping dead-PID endpoint records in {@link TelegramNotificationDaemon.scanRoots}. */
|
|
1315
2463
|
pidAlive?: (pid: number) => boolean;
|
|
2464
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1316
2465
|
botApi?: BotApi;
|
|
1317
2466
|
control?: DaemonControlHooks;
|
|
1318
2467
|
/**
|
|
@@ -1336,6 +2485,8 @@ export interface TelegramDaemonOptions {
|
|
|
1336
2485
|
rich?: { enabled: boolean };
|
|
1337
2486
|
/** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */
|
|
1338
2487
|
richDraft?: { enabled: boolean };
|
|
2488
|
+
/** Tool start/completion messages (enabled by default). */
|
|
2489
|
+
toolActivity?: { enabled: boolean };
|
|
1339
2490
|
/**
|
|
1340
2491
|
* Per-session Telegram forum-topic naming. `nameTemplate` supports the
|
|
1341
2492
|
* `{repo}`, `{branch}`, and `{title}` placeholders; unset preserves the
|
|
@@ -1391,9 +2542,21 @@ interface TopicAuthorityLease {
|
|
|
1391
2542
|
authorityEpoch: number;
|
|
1392
2543
|
}
|
|
1393
2544
|
|
|
2545
|
+
interface ToolActivityOwner {
|
|
2546
|
+
sessionId: string;
|
|
2547
|
+
toolCallId: string;
|
|
2548
|
+
toolName: string;
|
|
2549
|
+
endpointDigest: string;
|
|
2550
|
+
session: SessionSocket;
|
|
2551
|
+
phase: "started" | "terminal";
|
|
2552
|
+
policyEpoch?: number;
|
|
2553
|
+
}
|
|
2554
|
+
|
|
1394
2555
|
interface PendingThreadedFrame {
|
|
1395
2556
|
send: ThreadedSend;
|
|
1396
2557
|
msg: Record<string, unknown>;
|
|
2558
|
+
endpointDigest: string;
|
|
2559
|
+
toolActivity?: ToolActivityOwner;
|
|
1397
2560
|
}
|
|
1398
2561
|
|
|
1399
2562
|
type SelectedAckOutcome =
|
|
@@ -1427,6 +2590,7 @@ interface TelegramQueuePayload {
|
|
|
1427
2590
|
topicLease?: TopicAuthorityLease;
|
|
1428
2591
|
selectedAck?: SelectedAckQueueItem;
|
|
1429
2592
|
btwDelivery?: BtwQueuedDelivery;
|
|
2593
|
+
toolActivity?: ToolActivityOwner;
|
|
1430
2594
|
}
|
|
1431
2595
|
|
|
1432
2596
|
interface PendingBtwTurn {
|
|
@@ -1517,6 +2681,16 @@ export class TelegramNotificationDaemon {
|
|
|
1517
2681
|
readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
|
|
1518
2682
|
/** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
|
|
1519
2683
|
private readonly liveMessages = new Map<string, number>();
|
|
2684
|
+
/** Endpoint-bound ownership for visible or dispatching tool bubbles. */
|
|
2685
|
+
private readonly toolActivityOwners = new Map<string, ToolActivityOwner>();
|
|
2686
|
+
private readonly revokedToolEndpoints = new Set<string>();
|
|
2687
|
+
private readonly unresolvedToolTerminalizations = new Map<string, { messageId: number; owner: ToolActivityOwner }>();
|
|
2688
|
+
private toolTerminalizationChain: Promise<void> = Promise.resolve();
|
|
2689
|
+
private toolActivityPolicyEpoch = 0;
|
|
2690
|
+
private toolActivityStopping = false;
|
|
2691
|
+
private readonly replayToolActivityEpochs = new WeakMap<Record<string, unknown>, number>();
|
|
2692
|
+
private toolShutdownBarrier: Promise<void> = Promise.resolve();
|
|
2693
|
+
private toolActivityAmbiguous = false;
|
|
1520
2694
|
readonly sessions = new Map<string, SessionSocket>();
|
|
1521
2695
|
/** Ephemeral aliases for model choices; deliberately never serialized across daemon restarts. */
|
|
1522
2696
|
#modelChoiceAliases = new Map<string, ModelChoiceRoute>();
|
|
@@ -1626,7 +2800,13 @@ export class TelegramNotificationDaemon {
|
|
|
1626
2800
|
* ~25s getUpdates timeout. Safe to call from a signal handler.
|
|
1627
2801
|
*/
|
|
1628
2802
|
requestStop(_reason?: "reload" | "stop" | "signal"): void {
|
|
1629
|
-
this.
|
|
2803
|
+
const toolShutdown = this.beginToolActivityShutdown();
|
|
2804
|
+
void toolShutdown
|
|
2805
|
+
.finally(() => {
|
|
2806
|
+
this.effects.beginShutdown();
|
|
2807
|
+
this.#deliveryAbort.abort();
|
|
2808
|
+
})
|
|
2809
|
+
.catch(() => undefined);
|
|
1630
2810
|
for (const item of new Set(this.selectedAckPending.values())) {
|
|
1631
2811
|
if (item.state === "queued") this.pool.removeById(item.itemId);
|
|
1632
2812
|
else item.controller?.abort();
|
|
@@ -1637,7 +2817,6 @@ export class TelegramNotificationDaemon {
|
|
|
1637
2817
|
delivery.invalidated = true;
|
|
1638
2818
|
delivery.controller.abort("daemon_shutdown");
|
|
1639
2819
|
}
|
|
1640
|
-
this.#deliveryAbort.abort();
|
|
1641
2820
|
this.runtime.requestStop();
|
|
1642
2821
|
this.running = false;
|
|
1643
2822
|
}
|
|
@@ -2444,7 +3623,131 @@ export class TelegramNotificationDaemon {
|
|
|
2444
3623
|
* (so a delayed old close cannot delete a replacement), and best-effort closes
|
|
2445
3624
|
* the socket. `scanRoots()` then reconnects the session.
|
|
2446
3625
|
*/
|
|
3626
|
+
private enqueueToolTerminalization(
|
|
3627
|
+
claimed:
|
|
3628
|
+
| Array<{ messageId: number; owner: ToolActivityOwner }>
|
|
3629
|
+
| (() => Array<{ messageId: number; owner: ToolActivityOwner }>),
|
|
3630
|
+
awaitDispatch: boolean,
|
|
3631
|
+
strict = false,
|
|
3632
|
+
): Promise<void> {
|
|
3633
|
+
const terminalize = (): Promise<void> =>
|
|
3634
|
+
this.effects.allowTerminal(async () => {
|
|
3635
|
+
if (awaitDispatch) await this.flushChain;
|
|
3636
|
+
const strictFailures: Error[] = [];
|
|
3637
|
+
const claimedItems = typeof claimed === "function" ? claimed() : claimed;
|
|
3638
|
+
for (const item of claimedItems) {
|
|
3639
|
+
const { messageId, owner } = item;
|
|
3640
|
+
const backlogKey = `${owner.endpointDigest}\0${owner.sessionId}\0${owner.toolCallId}\0${messageId}`;
|
|
3641
|
+
const send = renderThreadedFrame({
|
|
3642
|
+
type: "tool_activity",
|
|
3643
|
+
sessionId: owner.sessionId,
|
|
3644
|
+
toolCallId: owner.toolCallId,
|
|
3645
|
+
toolName: owner.toolName,
|
|
3646
|
+
phase: "unknown",
|
|
3647
|
+
});
|
|
3648
|
+
if (!send?.text) continue;
|
|
3649
|
+
let failure: unknown;
|
|
3650
|
+
let delivered = false;
|
|
3651
|
+
for (let attempt = 0; attempt < (strict ? 5 : 1); attempt++) {
|
|
3652
|
+
try {
|
|
3653
|
+
const response = (await this.botApi.call("editMessageText", {
|
|
3654
|
+
chat_id: this.opts.chatId,
|
|
3655
|
+
message_id: messageId,
|
|
3656
|
+
text: send.text,
|
|
3657
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
3658
|
+
})) as { ok?: boolean; description?: string } | undefined;
|
|
3659
|
+
delivered = response?.ok === true || /not modified/i.test(String(response?.description ?? ""));
|
|
3660
|
+
if (delivered) break;
|
|
3661
|
+
failure = new Error(String(response?.description ?? "Telegram rejected tool terminalization."));
|
|
3662
|
+
} catch (error) {
|
|
3663
|
+
failure = error;
|
|
3664
|
+
}
|
|
3665
|
+
if (strict && attempt < 4) await this.runtime.sleep(50 * 2 ** attempt);
|
|
3666
|
+
}
|
|
3667
|
+
if (delivered) {
|
|
3668
|
+
this.unresolvedToolTerminalizations.delete(backlogKey);
|
|
3669
|
+
continue;
|
|
3670
|
+
}
|
|
3671
|
+
this.unresolvedToolTerminalizations.set(backlogKey, item);
|
|
3672
|
+
if (strict) {
|
|
3673
|
+
const key = `${owner.sessionId}:tool:${owner.toolCallId}`;
|
|
3674
|
+
if (!this.toolActivityOwners.has(key)) {
|
|
3675
|
+
this.toolActivityOwners.set(key, owner);
|
|
3676
|
+
this.liveMessages.set(key, messageId);
|
|
3677
|
+
}
|
|
3678
|
+
strictFailures.push(failure instanceof Error ? failure : new Error("Tool terminalization failed."));
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
if (strictFailures.length > 0) {
|
|
3682
|
+
throw new AggregateError(strictFailures, strictFailures.map(failure => failure.message).join("; "));
|
|
3683
|
+
}
|
|
3684
|
+
});
|
|
3685
|
+
const next = this.toolTerminalizationChain.then(terminalize);
|
|
3686
|
+
this.toolTerminalizationChain = next.catch(() => {});
|
|
3687
|
+
return next;
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
private scheduleVisibleToolTerminalization(endpointDigest?: string, strict = false): Promise<void> {
|
|
3691
|
+
if (endpointDigest !== undefined) this.revokedToolEndpoints.add(endpointDigest);
|
|
3692
|
+
const claimVisible = (): Array<{ messageId: number; owner: ToolActivityOwner }> => {
|
|
3693
|
+
const claimedByKey = new Map<string, { messageId: number; owner: ToolActivityOwner }>();
|
|
3694
|
+
for (const [backlogKey, item] of this.unresolvedToolTerminalizations) {
|
|
3695
|
+
if (endpointDigest !== undefined && item.owner.endpointDigest !== endpointDigest) continue;
|
|
3696
|
+
this.unresolvedToolTerminalizations.delete(backlogKey);
|
|
3697
|
+
claimedByKey.set(backlogKey, item);
|
|
3698
|
+
}
|
|
3699
|
+
for (const [key, owner] of this.toolActivityOwners) {
|
|
3700
|
+
if (endpointDigest !== undefined && owner.endpointDigest !== endpointDigest) continue;
|
|
3701
|
+
this.toolActivityOwners.delete(key);
|
|
3702
|
+
const messageId = this.liveMessages.get(key);
|
|
3703
|
+
this.liveMessages.delete(key);
|
|
3704
|
+
if (messageId !== undefined) {
|
|
3705
|
+
const backlogKey = `${owner.endpointDigest}\0${owner.sessionId}\0${owner.toolCallId}\0${messageId}`;
|
|
3706
|
+
claimedByKey.set(backlogKey, { messageId, owner });
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
return [...claimedByKey.values()];
|
|
3710
|
+
};
|
|
3711
|
+
this.pool.removeWhere(
|
|
3712
|
+
item =>
|
|
3713
|
+
item.payload.toolActivity !== undefined &&
|
|
3714
|
+
(endpointDigest === undefined || item.payload.toolActivity.endpointDigest === endpointDigest),
|
|
3715
|
+
);
|
|
3716
|
+
const next = this.enqueueToolTerminalization(claimVisible, false, strict);
|
|
3717
|
+
if (endpointDigest !== undefined) {
|
|
3718
|
+
void next.then(
|
|
3719
|
+
() => this.revokedToolEndpoints.delete(endpointDigest),
|
|
3720
|
+
() => this.revokedToolEndpoints.delete(endpointDigest),
|
|
3721
|
+
);
|
|
3722
|
+
}
|
|
3723
|
+
return next;
|
|
3724
|
+
}
|
|
3725
|
+
|
|
3726
|
+
private beginToolActivityShutdown(): Promise<void> {
|
|
3727
|
+
if (this.toolActivityStopping) return this.toolShutdownBarrier;
|
|
3728
|
+
this.toolActivityStopping = true;
|
|
3729
|
+
this.toolActivityPolicyEpoch++;
|
|
3730
|
+
this.toolShutdownBarrier = (async () => {
|
|
3731
|
+
await this.flushChain;
|
|
3732
|
+
const failures: Error[] = [];
|
|
3733
|
+
if (this.toolActivityAmbiguous) {
|
|
3734
|
+
failures.push(new Error("Tool activity delivery became ambiguous during daemon shutdown."));
|
|
3735
|
+
}
|
|
3736
|
+
try {
|
|
3737
|
+
await this.scheduleVisibleToolTerminalization(undefined, true);
|
|
3738
|
+
} catch (error) {
|
|
3739
|
+
failures.push(error instanceof Error ? error : new Error(String(error)));
|
|
3740
|
+
}
|
|
3741
|
+
if (failures.length > 0) {
|
|
3742
|
+
throw new AggregateError(failures, failures.map(failure => failure.message).join("; "));
|
|
3743
|
+
}
|
|
3744
|
+
})();
|
|
3745
|
+
return this.toolShutdownBarrier;
|
|
3746
|
+
}
|
|
3747
|
+
|
|
2447
3748
|
private dropSession(session: SessionSocket, reason: string): void {
|
|
3749
|
+
const isCurrentSession = this.sessions.get(session.sessionId) === session;
|
|
3750
|
+
if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
|
|
2448
3751
|
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
2449
3752
|
if (session.pingTimer) {
|
|
2450
3753
|
clearIntervalImpl(session.pingTimer);
|
|
@@ -2455,7 +3758,6 @@ export class TelegramNotificationDaemon {
|
|
|
2455
3758
|
} else {
|
|
2456
3759
|
void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
|
|
2457
3760
|
}
|
|
2458
|
-
const isCurrentSession = this.sessions.get(session.sessionId) === session;
|
|
2459
3761
|
if (isCurrentSession || reason === "session_closed") {
|
|
2460
3762
|
this.deleteMessageRoutes(session.sessionId);
|
|
2461
3763
|
}
|
|
@@ -2915,16 +4217,39 @@ export class TelegramNotificationDaemon {
|
|
|
2915
4217
|
return !ownerId || ownerId === session.sessionId;
|
|
2916
4218
|
}
|
|
2917
4219
|
|
|
4220
|
+
private toolActivityOwner(session: SessionSocket, msg: Record<string, unknown>): ToolActivityOwner | undefined {
|
|
4221
|
+
if (msg.type !== "tool_activity") return undefined;
|
|
4222
|
+
const toolCallId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
|
|
4223
|
+
const toolName = typeof msg.toolName === "string" ? msg.toolName : undefined;
|
|
4224
|
+
const phase = typeof msg.phase === "string" ? msg.phase : undefined;
|
|
4225
|
+
if (!toolCallId || !toolName || !phase) return undefined;
|
|
4226
|
+
return {
|
|
4227
|
+
sessionId: session.sessionId,
|
|
4228
|
+
toolCallId,
|
|
4229
|
+
toolName,
|
|
4230
|
+
endpointDigest: session.endpointDigest,
|
|
4231
|
+
session,
|
|
4232
|
+
phase: phase === "started" ? "started" : "terminal",
|
|
4233
|
+
};
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
private toolActivityAuthorityIsCurrent(toolActivity: ToolActivityOwner): boolean {
|
|
4237
|
+
if (this.revokedToolEndpoints.has(toolActivity.endpointDigest)) return false;
|
|
4238
|
+
const session = this.sessions.get(toolActivity.sessionId);
|
|
4239
|
+
if (toolActivity.endpointDigest === undefined) return session === undefined;
|
|
4240
|
+
return session === toolActivity.session && session.endpointDigest === toolActivity.endpointDigest;
|
|
4241
|
+
}
|
|
2918
4242
|
private async submitThreadedFrame(
|
|
2919
4243
|
sessionId: string,
|
|
2920
4244
|
send: ThreadedSend,
|
|
2921
4245
|
topicLease: TopicAuthorityLease,
|
|
4246
|
+
toolActivity?: ToolActivityOwner,
|
|
2922
4247
|
): Promise<void> {
|
|
2923
4248
|
this.submitPool({
|
|
2924
4249
|
sessionId,
|
|
2925
4250
|
lane: send.lane,
|
|
2926
4251
|
coalesceKey: send.coalesceKey,
|
|
2927
|
-
payload: { send, topicLease },
|
|
4252
|
+
payload: { send, topicLease, ...(toolActivity ? { toolActivity } : {}) },
|
|
2928
4253
|
});
|
|
2929
4254
|
await this.flushPool();
|
|
2930
4255
|
}
|
|
@@ -2986,18 +4311,33 @@ export class TelegramNotificationDaemon {
|
|
|
2986
4311
|
}
|
|
2987
4312
|
}
|
|
2988
4313
|
|
|
2989
|
-
private rememberPendingThreadedFrame(
|
|
2990
|
-
|
|
2991
|
-
|
|
4314
|
+
private rememberPendingThreadedFrame(
|
|
4315
|
+
session: SessionSocket,
|
|
4316
|
+
send: ThreadedSend,
|
|
4317
|
+
msg: Record<string, unknown>,
|
|
4318
|
+
toolActivity?: ToolActivityOwner,
|
|
4319
|
+
): void {
|
|
4320
|
+
const frames = this.pendingThreadedFrames.get(session.sessionId) ?? [];
|
|
4321
|
+
frames.push({
|
|
4322
|
+
send,
|
|
4323
|
+
msg,
|
|
4324
|
+
endpointDigest: session.endpointDigest,
|
|
4325
|
+
...(toolActivity ? { toolActivity } : {}),
|
|
4326
|
+
});
|
|
2992
4327
|
if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
|
|
2993
|
-
this.pendingThreadedFrames.set(sessionId, frames);
|
|
4328
|
+
this.pendingThreadedFrames.set(session.sessionId, frames);
|
|
2994
4329
|
}
|
|
2995
4330
|
|
|
2996
4331
|
private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
|
|
2997
4332
|
const frames = this.pendingThreadedFrames.get(sessionId);
|
|
2998
4333
|
if (!frames || frames.length === 0) return;
|
|
2999
4334
|
this.pendingThreadedFrames.delete(sessionId);
|
|
3000
|
-
|
|
4335
|
+
const session = this.sessions.get(sessionId);
|
|
4336
|
+
for (const frame of frames) {
|
|
4337
|
+
if (session && session.endpointDigest !== frame.endpointDigest) continue;
|
|
4338
|
+
if (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled === false) continue;
|
|
4339
|
+
await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity);
|
|
4340
|
+
}
|
|
3001
4341
|
}
|
|
3002
4342
|
|
|
3003
4343
|
/**
|
|
@@ -3082,7 +4422,11 @@ export class TelegramNotificationDaemon {
|
|
|
3082
4422
|
})) as { ok?: boolean };
|
|
3083
4423
|
if (!topicDeleteSettled(res)) return;
|
|
3084
4424
|
this.topics.settleDelete(sessionId, record.topicId);
|
|
3085
|
-
for (const k of [...this.liveMessages.keys()])
|
|
4425
|
+
for (const k of [...this.liveMessages.keys()])
|
|
4426
|
+
if (k.startsWith(`${sessionId}:`)) {
|
|
4427
|
+
this.liveMessages.delete(k);
|
|
4428
|
+
this.toolActivityOwners.delete(k);
|
|
4429
|
+
}
|
|
3086
4430
|
this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
|
|
3087
4431
|
if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
|
|
3088
4432
|
});
|
|
@@ -3266,6 +4610,21 @@ export class TelegramNotificationDaemon {
|
|
|
3266
4610
|
);
|
|
3267
4611
|
}
|
|
3268
4612
|
for (const item of batch) {
|
|
4613
|
+
const toolActivity = item.payload.toolActivity;
|
|
4614
|
+
if (
|
|
4615
|
+
toolActivity?.phase === "started" &&
|
|
4616
|
+
(this.toolActivityStopping ||
|
|
4617
|
+
this.opts.toolActivity?.enabled === false ||
|
|
4618
|
+
toolActivity.policyEpoch !== this.toolActivityPolicyEpoch ||
|
|
4619
|
+
!this.toolActivityAuthorityIsCurrent(toolActivity))
|
|
4620
|
+
) {
|
|
4621
|
+
const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
4622
|
+
const owner = this.toolActivityOwners.get(key);
|
|
4623
|
+
if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
|
|
4624
|
+
this.toolActivityOwners.delete(key);
|
|
4625
|
+
this.pool.settle(item.itemId!, "removed");
|
|
4626
|
+
continue;
|
|
4627
|
+
}
|
|
3269
4628
|
const btwDelivery = item.payload.btwDelivery;
|
|
3270
4629
|
if (btwDelivery) {
|
|
3271
4630
|
if (!btwDelivery.isAuthoritative()) {
|
|
@@ -3360,6 +4719,10 @@ export class TelegramNotificationDaemon {
|
|
|
3360
4719
|
this.pool.settle(item.itemId!, "rejected");
|
|
3361
4720
|
continue;
|
|
3362
4721
|
}
|
|
4722
|
+
if (item.payload.toolActivity && !this.toolActivityAuthorityIsCurrent(item.payload.toolActivity)) {
|
|
4723
|
+
this.pool.settle(item.itemId!, "removed");
|
|
4724
|
+
continue;
|
|
4725
|
+
}
|
|
3363
4726
|
// Threaded topic when available; otherwise deliver flat to the paired chat.
|
|
3364
4727
|
const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
|
|
3365
4728
|
const ckey = send.editable ? item.coalesceKey : undefined;
|
|
@@ -3558,19 +4921,25 @@ export class TelegramNotificationDaemon {
|
|
|
3558
4921
|
}
|
|
3559
4922
|
}
|
|
3560
4923
|
if (editKey && ckey !== undefined && firstMessageId !== undefined && !send.terminal) {
|
|
3561
|
-
this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
|
|
4924
|
+
this.recordLiveMessage(item.sessionId, ckey, firstMessageId, item.payload.toolActivity);
|
|
3562
4925
|
}
|
|
3563
4926
|
}
|
|
3564
4927
|
}
|
|
3565
4928
|
} catch {
|
|
3566
4929
|
// Best-effort: a failed send/edit must never stop the daemon.
|
|
3567
4930
|
disposition = "ambiguous";
|
|
4931
|
+
if (item.payload.toolActivity?.phase === "started") this.toolActivityAmbiguous = true;
|
|
3568
4932
|
} finally {
|
|
3569
4933
|
this.pool.settle(item.itemId!, disposition);
|
|
3570
4934
|
// A terminal tool frame owns the end of this coalescing key even when both
|
|
3571
4935
|
// edit and fallback delivery fail. Retaining the old message id would leak
|
|
3572
4936
|
// one entry per failure and let a later reused key edit stale Telegram state.
|
|
3573
|
-
if (send.terminal && editKey)
|
|
4937
|
+
if (send.terminal && editKey) {
|
|
4938
|
+
this.liveMessages.delete(editKey);
|
|
4939
|
+
const owner = this.toolActivityOwners.get(editKey);
|
|
4940
|
+
if (!item.payload.toolActivity || owner?.session === item.payload.toolActivity.session)
|
|
4941
|
+
this.toolActivityOwners.delete(editKey);
|
|
4942
|
+
}
|
|
3574
4943
|
}
|
|
3575
4944
|
}
|
|
3576
4945
|
}
|
|
@@ -3580,7 +4949,12 @@ export class TelegramNotificationDaemon {
|
|
|
3580
4949
|
* so later live/finalized frames edit it in place. Evicts this session's stale
|
|
3581
4950
|
* same-category entries (e.g. prior turns) so the map stays bounded.
|
|
3582
4951
|
*/
|
|
3583
|
-
private recordLiveMessage(
|
|
4952
|
+
private recordLiveMessage(
|
|
4953
|
+
sessionId: string,
|
|
4954
|
+
coalesceKey: string,
|
|
4955
|
+
messageId: number,
|
|
4956
|
+
toolActivity?: ToolActivityOwner,
|
|
4957
|
+
): void {
|
|
3584
4958
|
const mapKey = `${sessionId}:${coalesceKey}`;
|
|
3585
4959
|
const category = coalesceKey.split(":")[0] ?? "";
|
|
3586
4960
|
// Single-slot categories (rolling turn/context/reasoning previews) evict prior
|
|
@@ -3595,7 +4969,15 @@ export class TelegramNotificationDaemon {
|
|
|
3595
4969
|
if (k !== mapKey && k.startsWith(prefix)) this.liveMessages.delete(k);
|
|
3596
4970
|
}
|
|
3597
4971
|
}
|
|
4972
|
+
if (toolActivity) {
|
|
4973
|
+
const owner = this.toolActivityOwners.get(mapKey);
|
|
4974
|
+
if (this.revokedToolEndpoints.has(toolActivity.endpointDigest) || owner?.session !== toolActivity.session) {
|
|
4975
|
+
void this.enqueueToolTerminalization([{ messageId, owner: toolActivity }], false);
|
|
4976
|
+
return;
|
|
4977
|
+
}
|
|
4978
|
+
}
|
|
3598
4979
|
this.liveMessages.set(mapKey, messageId);
|
|
4980
|
+
if (toolActivity) this.toolActivityOwners.set(mapKey, toolActivity);
|
|
3599
4981
|
}
|
|
3600
4982
|
|
|
3601
4983
|
/**
|
|
@@ -3607,11 +4989,20 @@ export class TelegramNotificationDaemon {
|
|
|
3607
4989
|
* content never lands in a shared chat. Identity headers are sent at most once per
|
|
3608
4990
|
* session in flat mode.
|
|
3609
4991
|
*/
|
|
3610
|
-
private async deliverFlatFallback(
|
|
4992
|
+
private async deliverFlatFallback(
|
|
4993
|
+
sessionId: string,
|
|
4994
|
+
send: ThreadedSend,
|
|
4995
|
+
toolActivity?: ToolActivityOwner,
|
|
4996
|
+
): Promise<void> {
|
|
3611
4997
|
if (!(await this.pairedChatIsPrivate())) return;
|
|
3612
4998
|
await this.notifyThreadedFallback();
|
|
3613
4999
|
if (send.identity && this.flatIdentitySent.has(sessionId)) return;
|
|
3614
|
-
this.submitPool({
|
|
5000
|
+
this.submitPool({
|
|
5001
|
+
sessionId,
|
|
5002
|
+
lane: send.lane,
|
|
5003
|
+
coalesceKey: send.coalesceKey,
|
|
5004
|
+
payload: { send, ...(toolActivity ? { toolActivity } : {}) },
|
|
5005
|
+
});
|
|
3615
5006
|
await this.flushPool();
|
|
3616
5007
|
if (send.identity) this.flatIdentitySent.add(sessionId);
|
|
3617
5008
|
}
|
|
@@ -3683,11 +5074,13 @@ export class TelegramNotificationDaemon {
|
|
|
3683
5074
|
return renewDaemonHeartbeat({
|
|
3684
5075
|
settings: this.opts.settings,
|
|
3685
5076
|
ownerId: this.opts.ownerId,
|
|
5077
|
+
acquisitionId: this.opts.ownerId,
|
|
3686
5078
|
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
3687
5079
|
chatId: this.opts.chatId,
|
|
3688
5080
|
fs: this.fsImpl,
|
|
3689
5081
|
now: this.opts.now,
|
|
3690
5082
|
pid: this.opts.pid ?? process.pid,
|
|
5083
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
3691
5084
|
});
|
|
3692
5085
|
}
|
|
3693
5086
|
|
|
@@ -3834,9 +5227,13 @@ export class TelegramNotificationDaemon {
|
|
|
3834
5227
|
|
|
3835
5228
|
async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
|
|
3836
5229
|
if (session.replayPending) {
|
|
5230
|
+
if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled === false || this.toolActivityStopping))
|
|
5231
|
+
return;
|
|
3837
5232
|
const matchingReplay = msg?.type === "event_replay_result" && msg.id === session.replayId;
|
|
3838
5233
|
if (!matchingReplay) {
|
|
3839
|
-
|
|
5234
|
+
const frame = msg as Record<string, unknown>;
|
|
5235
|
+
if (frame.type === "tool_activity") this.replayToolActivityEpochs.set(frame, this.toolActivityPolicyEpoch);
|
|
5236
|
+
session.replayQueue.push(frame);
|
|
3840
5237
|
return;
|
|
3841
5238
|
}
|
|
3842
5239
|
session.replayPending = false;
|
|
@@ -3881,6 +5278,11 @@ export class TelegramNotificationDaemon {
|
|
|
3881
5278
|
}
|
|
3882
5279
|
const queued = session.replayQueue.splice(0);
|
|
3883
5280
|
for (const frame of queued) {
|
|
5281
|
+
if (
|
|
5282
|
+
frame.type === "tool_activity" &&
|
|
5283
|
+
this.replayToolActivityEpochs.get(frame) !== this.toolActivityPolicyEpoch
|
|
5284
|
+
)
|
|
5285
|
+
continue;
|
|
3884
5286
|
const fingerprint = JSON.stringify(frame);
|
|
3885
5287
|
const remaining = replayCounts.get(fingerprint) ?? 0;
|
|
3886
5288
|
if (remaining > 0) {
|
|
@@ -3958,6 +5360,7 @@ export class TelegramNotificationDaemon {
|
|
|
3958
5360
|
};
|
|
3959
5361
|
this.#btwTerminalDeliveries.set(requestId, terminalDelivery);
|
|
3960
5362
|
let deliveryOutcome: "accepted" | "not_delivered" | "uncertain" | "partial_accepted" = "not_delivered";
|
|
5363
|
+
let observerOutcome: BtwTerminalDeliveryOutcome = deliveryOutcome;
|
|
3961
5364
|
try {
|
|
3962
5365
|
if (msg.status !== "ok") {
|
|
3963
5366
|
const text =
|
|
@@ -4023,7 +5426,7 @@ export class TelegramNotificationDaemon {
|
|
|
4023
5426
|
}
|
|
4024
5427
|
};
|
|
4025
5428
|
const html = markdownToTelegramHtml(markdown);
|
|
4026
|
-
const fallback = async (): Promise<
|
|
5429
|
+
const fallback = async (): Promise<BtwTerminalDeliveryOutcome> => {
|
|
4027
5430
|
let acceptedChunks = 0;
|
|
4028
5431
|
for (const [index, text] of splitTelegramHtml(html).entries()) {
|
|
4029
5432
|
const outcome = await this.#queueBtwFallbackChunk({
|
|
@@ -4045,6 +5448,7 @@ export class TelegramNotificationDaemon {
|
|
|
4045
5448
|
continue;
|
|
4046
5449
|
}
|
|
4047
5450
|
if (outcome === "partial_accepted" || acceptedChunks > 0) return "partial_accepted";
|
|
5451
|
+
if (outcome === "stale") return "stale";
|
|
4048
5452
|
return outcome === "uncertain" ? "uncertain" : "not_delivered";
|
|
4049
5453
|
}
|
|
4050
5454
|
return "accepted";
|
|
@@ -4056,16 +5460,21 @@ export class TelegramNotificationDaemon {
|
|
|
4056
5460
|
reply_parameters: { message_id: pending.messageId },
|
|
4057
5461
|
rich_message: { markdown, skip_entity_detection: true },
|
|
4058
5462
|
});
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
5463
|
+
if (outcome === "accepted") {
|
|
5464
|
+
deliveryOutcome = "accepted";
|
|
5465
|
+
observerOutcome = "accepted";
|
|
5466
|
+
} else if (outcome === "rejected") {
|
|
5467
|
+
const fallbackOutcome = await fallback();
|
|
5468
|
+
observerOutcome = fallbackOutcome;
|
|
5469
|
+
deliveryOutcome = fallbackOutcome === "stale" ? "not_delivered" : fallbackOutcome;
|
|
5470
|
+
} else {
|
|
5471
|
+
deliveryOutcome = outcome === "uncertain" ? "uncertain" : "not_delivered";
|
|
5472
|
+
observerOutcome = outcome === "stale" ? "stale" : deliveryOutcome;
|
|
5473
|
+
}
|
|
4067
5474
|
} else {
|
|
4068
|
-
|
|
5475
|
+
const fallbackOutcome = await fallback();
|
|
5476
|
+
observerOutcome = fallbackOutcome;
|
|
5477
|
+
deliveryOutcome = fallbackOutcome === "stale" ? "not_delivered" : fallbackOutcome;
|
|
4069
5478
|
}
|
|
4070
5479
|
} finally {
|
|
4071
5480
|
if (this.#btwTerminalDeliveries.get(requestId) === terminalDelivery) {
|
|
@@ -4084,17 +5493,87 @@ export class TelegramNotificationDaemon {
|
|
|
4084
5493
|
}
|
|
4085
5494
|
} finally {
|
|
4086
5495
|
terminalDelivery.finish();
|
|
5496
|
+
observerOutcome = observerOutcome === "stale" ? "stale" : deliveryOutcome;
|
|
5497
|
+
if (this.#pendingBtwTurns.get(requestId) !== pending) {
|
|
5498
|
+
try {
|
|
5499
|
+
const observer = (
|
|
5500
|
+
this as unknown as Record<symbol, ((receipt: BtwTerminalDeliveryReceipt) => void) | undefined>
|
|
5501
|
+
)[BTW_TERMINAL_DELIVERY_TEST_OBSERVER];
|
|
5502
|
+
observer?.({
|
|
5503
|
+
requestId,
|
|
5504
|
+
logicalSessionId: pending.logicalSessionId,
|
|
5505
|
+
transportSessionId: pending.transportSessionId,
|
|
5506
|
+
threadId: pending.threadId,
|
|
5507
|
+
updateId: pending.updateId,
|
|
5508
|
+
messageId: pending.messageId,
|
|
5509
|
+
outcome: observerOutcome,
|
|
5510
|
+
});
|
|
5511
|
+
} catch {
|
|
5512
|
+
logger.warn("notifications: /btw terminal delivery observer failed");
|
|
5513
|
+
}
|
|
5514
|
+
}
|
|
4087
5515
|
}
|
|
4088
5516
|
}
|
|
4089
5517
|
return;
|
|
4090
5518
|
}
|
|
4091
5519
|
if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
|
|
5520
|
+
const threadedFrame = msg as Record<string, unknown>;
|
|
5521
|
+
const toolActivity = this.toolActivityOwner(session, threadedFrame);
|
|
5522
|
+
const toolAdmissionEpoch = toolActivity
|
|
5523
|
+
? (this.replayToolActivityEpochs.get(threadedFrame) ?? this.toolActivityPolicyEpoch)
|
|
5524
|
+
: undefined;
|
|
5525
|
+
if (toolActivity) toolActivity.policyEpoch = toolAdmissionEpoch;
|
|
5526
|
+
const toolStartIsCurrent = (): boolean =>
|
|
5527
|
+
toolActivity?.phase !== "started" ||
|
|
5528
|
+
(this.toolActivityAuthorityIsCurrent(toolActivity) &&
|
|
5529
|
+
!this.toolActivityStopping &&
|
|
5530
|
+
this.opts.toolActivity?.enabled !== false &&
|
|
5531
|
+
toolAdmissionEpoch === this.toolActivityPolicyEpoch);
|
|
5532
|
+
const abandonStaleToolStart = (): void => {
|
|
5533
|
+
if (toolActivity?.phase !== "started") return;
|
|
5534
|
+
const key = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
5535
|
+
const owner = this.toolActivityOwners.get(key);
|
|
5536
|
+
if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
|
|
5537
|
+
this.toolActivityOwners.delete(key);
|
|
5538
|
+
};
|
|
5539
|
+
if (toolActivity) {
|
|
5540
|
+
const liveKey = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
5541
|
+
const currentOwner = this.toolActivityOwners.get(liveKey);
|
|
5542
|
+
if (toolActivity.phase === "started") {
|
|
5543
|
+
if (!toolStartIsCurrent()) return;
|
|
5544
|
+
this.toolActivityOwners.set(liveKey, toolActivity);
|
|
5545
|
+
} else {
|
|
5546
|
+
if (currentOwner && currentOwner.session !== session) return;
|
|
5547
|
+
if (this.opts.toolActivity?.enabled === false) {
|
|
5548
|
+
if (!currentOwner) return;
|
|
5549
|
+
if (!this.liveMessages.has(liveKey)) {
|
|
5550
|
+
// A start may already be granted to the serialized dispatcher but not
|
|
5551
|
+
// yet recorded as visible. Wait for that exact dispatch boundary before
|
|
5552
|
+
// deciding whether its terminal frame must close a visible message.
|
|
5553
|
+
await this.flushChain;
|
|
5554
|
+
}
|
|
5555
|
+
const settledOwner = this.toolActivityOwners.get(liveKey);
|
|
5556
|
+
if (!this.liveMessages.has(liveKey) || settledOwner?.session !== session) {
|
|
5557
|
+
if (settledOwner?.session === session) this.toolActivityOwners.delete(liveKey);
|
|
5558
|
+
return;
|
|
5559
|
+
}
|
|
5560
|
+
}
|
|
5561
|
+
}
|
|
5562
|
+
}
|
|
4092
5563
|
const send = renderThreadedFrame(msg);
|
|
4093
5564
|
if (!send) return;
|
|
4094
5565
|
const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
|
|
5566
|
+
if (!toolStartIsCurrent()) {
|
|
5567
|
+
abandonStaleToolStart();
|
|
5568
|
+
return;
|
|
5569
|
+
}
|
|
4095
5570
|
if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
|
|
4096
5571
|
if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
|
|
4097
|
-
|
|
5572
|
+
if (!toolStartIsCurrent()) {
|
|
5573
|
+
abandonStaleToolStart();
|
|
5574
|
+
return;
|
|
5575
|
+
}
|
|
5576
|
+
this.rememberPendingThreadedFrame(session, send, threadedFrame, toolActivity);
|
|
4098
5577
|
return;
|
|
4099
5578
|
}
|
|
4100
5579
|
if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
|
|
@@ -4108,9 +5587,13 @@ export class TelegramNotificationDaemon {
|
|
|
4108
5587
|
const topicId =
|
|
4109
5588
|
existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
|
|
4110
5589
|
const topicLease = await this.topicAuthorityLease(session.sessionId);
|
|
5590
|
+
if (!toolStartIsCurrent()) {
|
|
5591
|
+
abandonStaleToolStart();
|
|
5592
|
+
return;
|
|
5593
|
+
}
|
|
4111
5594
|
if (!topicId || !topicLease || topicLease.topicId !== topicId) {
|
|
4112
5595
|
if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
|
|
4113
|
-
await this.deliverFlatFallback(session.sessionId, send);
|
|
5596
|
+
await this.deliverFlatFallback(session.sessionId, send, toolActivity);
|
|
4114
5597
|
return;
|
|
4115
5598
|
}
|
|
4116
5599
|
if (send.identity) {
|
|
@@ -4158,7 +5641,11 @@ export class TelegramNotificationDaemon {
|
|
|
4158
5641
|
await this.persistTopics();
|
|
4159
5642
|
return;
|
|
4160
5643
|
}
|
|
4161
|
-
|
|
5644
|
+
if (!toolStartIsCurrent()) {
|
|
5645
|
+
abandonStaleToolStart();
|
|
5646
|
+
return;
|
|
5647
|
+
}
|
|
5648
|
+
await this.submitThreadedFrame(session.sessionId, send, topicLease, toolActivity);
|
|
4162
5649
|
return;
|
|
4163
5650
|
}
|
|
4164
5651
|
if (msg.type === "action_needed" && msg.id) {
|
|
@@ -4473,28 +5960,32 @@ export class TelegramNotificationDaemon {
|
|
|
4473
5960
|
}
|
|
4474
5961
|
}
|
|
4475
5962
|
}
|
|
4476
|
-
//
|
|
4477
|
-
//
|
|
4478
|
-
//
|
|
4479
|
-
// no session is connected and never becomes an ask answer.
|
|
5963
|
+
// Telegram delivery toggles are daemon-local policy, NOT session config
|
|
5964
|
+
// forwards. Handle them before threaded injection and independently of any
|
|
5965
|
+
// session WebSocket, so they work even when no session is connected.
|
|
4480
5966
|
{
|
|
4481
5967
|
const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
|
|
4482
5968
|
const chat = m?.chat as { id?: unknown } | undefined;
|
|
4483
5969
|
const cmdText = typeof m?.text === "string" ? m.text : undefined;
|
|
4484
|
-
const
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
5970
|
+
const commandToken = cmdText?.trim().split(/\s+/)[0]?.toLowerCase();
|
|
5971
|
+
const [command, commandSuffix] = commandToken?.split("@", 2) ?? [];
|
|
5972
|
+
const isRichCommand = command === "/rich";
|
|
5973
|
+
const isToolActivityCommand = command === "/toolactivity";
|
|
5974
|
+
if (
|
|
5975
|
+
isToolActivityCommand &&
|
|
5976
|
+
commandSuffix &&
|
|
5977
|
+
(!this.botUsername || commandSuffix !== this.botUsername.toLowerCase())
|
|
5978
|
+
)
|
|
5979
|
+
return;
|
|
5980
|
+
if (
|
|
5981
|
+
m !== undefined &&
|
|
5982
|
+
String(chat?.id) === String(this.opts.chatId) &&
|
|
5983
|
+
(isRichCommand || isToolActivityCommand)
|
|
5984
|
+
) {
|
|
5985
|
+
// These commands mutate global config, so honor them ONLY in the
|
|
5986
|
+
// configured private chat. Fail closed for legacy or hand-edited group IDs.
|
|
4495
5987
|
if (!(await this.pairedChatIsPrivate())) return;
|
|
4496
5988
|
const updateId = (update as { update_id?: number }).update_id;
|
|
4497
|
-
// Dedupe redelivered updates so a toggle+confirmation runs at most once.
|
|
4498
5989
|
if (typeof updateId === "number") {
|
|
4499
5990
|
if (this.dispatchState.seenUpdateIds.has(updateId)) return;
|
|
4500
5991
|
await this.rememberSeenUpdateId(updateId);
|
|
@@ -4513,31 +6004,70 @@ export class TelegramNotificationDaemon {
|
|
|
4513
6004
|
// Best-effort confirmation; never block on the notice.
|
|
4514
6005
|
}
|
|
4515
6006
|
};
|
|
4516
|
-
const desired =
|
|
6007
|
+
const desired = isRichCommand
|
|
6008
|
+
? parseRichToggleCommand(cmdText ?? "")
|
|
6009
|
+
: parseToolActivityToggleCommand(cmdText ?? "", this.botUsername);
|
|
6010
|
+
const usage = isRichCommand ? "Usage: /rich on|off" : "Usage: /toolactivity on|off";
|
|
4517
6011
|
if (desired === undefined) {
|
|
4518
|
-
await reply(
|
|
6012
|
+
await reply(usage);
|
|
4519
6013
|
return;
|
|
4520
6014
|
}
|
|
6015
|
+
const settingPath = isRichCommand
|
|
6016
|
+
? "notifications.telegram.rich.enabled"
|
|
6017
|
+
: "notifications.telegram.toolActivity.enabled";
|
|
6018
|
+
const label = isRichCommand ? "Rich messages" : "Tool activity";
|
|
4521
6019
|
try {
|
|
4522
|
-
await this.opts.settings.set(
|
|
4523
|
-
// Confirm
|
|
4524
|
-
|
|
4525
|
-
// swallows write errors, and Settings.flush() inherits that — neither
|
|
4526
|
-
// rejects on a failed config.yml write. flushOrThrow() rethrows the
|
|
4527
|
-
// durable-write failure so it lands in the catch below (in-memory
|
|
4528
|
-
// isolated Settings short-circuit and never throw). The lightweight
|
|
4529
|
-
// daemon settings has no flushOrThrow: its set() already wrote durably
|
|
4530
|
-
// (and throws on failure), so its flush() is only a no-op drain.
|
|
4531
|
-
await flushRichToggleSettings(this.opts.settings);
|
|
6020
|
+
await this.opts.settings.set(settingPath, desired);
|
|
6021
|
+
// Confirm only after the global config write is durable.
|
|
6022
|
+
await flushTelegramToggleSettings(this.opts.settings);
|
|
4532
6023
|
} catch (err) {
|
|
4533
6024
|
logger.warn(
|
|
4534
|
-
`notifications:
|
|
6025
|
+
`notifications: ${command} settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
|
|
4535
6026
|
);
|
|
4536
|
-
await reply(
|
|
6027
|
+
await reply(`${label}: unchanged (settings write failed)`);
|
|
4537
6028
|
return;
|
|
4538
6029
|
}
|
|
4539
|
-
|
|
4540
|
-
|
|
6030
|
+
if (isRichCommand) {
|
|
6031
|
+
this.opts.rich = { enabled: desired };
|
|
6032
|
+
} else {
|
|
6033
|
+
this.toolActivityPolicyEpoch++;
|
|
6034
|
+
this.opts.toolActivity = { enabled: desired };
|
|
6035
|
+
if (!desired) {
|
|
6036
|
+
const removedTools = this.pool.removeWhere(
|
|
6037
|
+
item => item.lane === "live" && item.coalesceKey?.startsWith("tool:") === true,
|
|
6038
|
+
);
|
|
6039
|
+
for (const item of removedTools) {
|
|
6040
|
+
const toolActivity = item.payload.toolActivity;
|
|
6041
|
+
if (!toolActivity) continue;
|
|
6042
|
+
const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
6043
|
+
const owner = this.toolActivityOwners.get(key);
|
|
6044
|
+
if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
|
|
6045
|
+
this.toolActivityOwners.delete(key);
|
|
6046
|
+
}
|
|
6047
|
+
for (const [sessionId, frames] of this.pendingThreadedFrames) {
|
|
6048
|
+
for (const frame of frames) {
|
|
6049
|
+
const toolActivity = frame.toolActivity;
|
|
6050
|
+
if (!toolActivity || frame.msg.type !== "tool_activity") continue;
|
|
6051
|
+
const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
6052
|
+
const owner = this.toolActivityOwners.get(key);
|
|
6053
|
+
if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
|
|
6054
|
+
this.toolActivityOwners.delete(key);
|
|
6055
|
+
}
|
|
6056
|
+
const retained = frames.filter(frame => frame.msg.type !== "tool_activity");
|
|
6057
|
+
if (retained.length === 0) this.pendingThreadedFrames.delete(sessionId);
|
|
6058
|
+
else this.pendingThreadedFrames.set(sessionId, retained);
|
|
6059
|
+
}
|
|
6060
|
+
for (const session of this.sessions.values()) {
|
|
6061
|
+
session.replayQueue = session.replayQueue.filter(frame => frame.type !== "tool_activity");
|
|
6062
|
+
}
|
|
6063
|
+
}
|
|
6064
|
+
// The policy flips before joining the serialized dispatch chain:
|
|
6065
|
+
// future starts are rejected immediately, queued starts are removed,
|
|
6066
|
+
// and any already-granted Bot API effect settles before the off
|
|
6067
|
+
// acknowledgement. Visible starts may still receive their terminal edit.
|
|
6068
|
+
await this.flushChain;
|
|
6069
|
+
}
|
|
6070
|
+
await reply(`${label}: ${desired ? "on" : "off"}`);
|
|
4541
6071
|
return;
|
|
4542
6072
|
}
|
|
4543
6073
|
}
|
|
@@ -4855,6 +6385,10 @@ export class TelegramNotificationDaemon {
|
|
|
4855
6385
|
{ command: "lean", description: "Mirror assistant text + tool names only (default)" },
|
|
4856
6386
|
{ command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
|
|
4857
6387
|
{ command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
|
|
6388
|
+
{
|
|
6389
|
+
command: "toolactivity",
|
|
6390
|
+
description: "Toggle tool activity updates: /toolactivity <on|off>",
|
|
6391
|
+
},
|
|
4858
6392
|
{ command: "reasoning", description: "Show or change reasoning effort in this session" },
|
|
4859
6393
|
{ command: "usage", description: "Show provider/local usage for this session" },
|
|
4860
6394
|
{ command: "model", description: "Select a model for this session" },
|
|
@@ -4883,11 +6417,13 @@ export class TelegramNotificationDaemon {
|
|
|
4883
6417
|
this.running = await renewDaemonHeartbeat({
|
|
4884
6418
|
settings: this.opts.settings,
|
|
4885
6419
|
ownerId: this.opts.ownerId,
|
|
6420
|
+
acquisitionId: this.opts.ownerId,
|
|
4886
6421
|
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
4887
6422
|
chatId: this.opts.chatId,
|
|
4888
6423
|
fs: this.fsImpl,
|
|
4889
6424
|
now: this.opts.now,
|
|
4890
6425
|
pid: this.opts.pid ?? process.pid,
|
|
6426
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
4891
6427
|
});
|
|
4892
6428
|
if (!this.running) return;
|
|
4893
6429
|
this.runtime.start();
|
|
@@ -4909,7 +6445,20 @@ export class TelegramNotificationDaemon {
|
|
|
4909
6445
|
let idleSince = this.runtime.now();
|
|
4910
6446
|
while (this.running) {
|
|
4911
6447
|
if (await this.controlStopRequested()) break;
|
|
4912
|
-
if (
|
|
6448
|
+
if (
|
|
6449
|
+
!(await renewDaemonHeartbeat({
|
|
6450
|
+
settings: this.opts.settings,
|
|
6451
|
+
ownerId: this.opts.ownerId,
|
|
6452
|
+
acquisitionId: this.opts.ownerId,
|
|
6453
|
+
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
6454
|
+
chatId: this.opts.chatId,
|
|
6455
|
+
fs: this.fsImpl,
|
|
6456
|
+
now: this.opts.now,
|
|
6457
|
+
pid: this.opts.pid ?? process.pid,
|
|
6458
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
6459
|
+
}))
|
|
6460
|
+
)
|
|
6461
|
+
break;
|
|
4913
6462
|
await this.runScan();
|
|
4914
6463
|
if (await this.controlStopRequested()) break;
|
|
4915
6464
|
const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
|
|
@@ -4944,10 +6493,19 @@ export class TelegramNotificationDaemon {
|
|
|
4944
6493
|
await this.runtime.sleep(10);
|
|
4945
6494
|
}
|
|
4946
6495
|
} finally {
|
|
6496
|
+
let toolShutdownError: unknown;
|
|
6497
|
+
try {
|
|
6498
|
+
await this.beginToolActivityShutdown();
|
|
6499
|
+
} catch (error) {
|
|
6500
|
+
toolShutdownError = error;
|
|
6501
|
+
}
|
|
4947
6502
|
this.effects.beginShutdown();
|
|
6503
|
+
this.#deliveryAbort.abort();
|
|
4948
6504
|
let persisted = false;
|
|
4949
6505
|
const shutdown = this.effects.allowTerminal(async () => {
|
|
6506
|
+
if (toolShutdownError) throw toolShutdownError;
|
|
4950
6507
|
await this.#drainBtwTurns();
|
|
6508
|
+
await this.toolTerminalizationChain;
|
|
4951
6509
|
this.runtime.stop();
|
|
4952
6510
|
this.stopOwnershipHeartbeatTimer();
|
|
4953
6511
|
this.stopFlushTimer();
|
|
@@ -4981,9 +6539,12 @@ export class TelegramNotificationDaemon {
|
|
|
4981
6539
|
await releaseDaemonOwnership({
|
|
4982
6540
|
settings: this.opts.settings,
|
|
4983
6541
|
ownerId: this.opts.ownerId,
|
|
6542
|
+
acquisitionId: this.opts.ownerId,
|
|
4984
6543
|
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
4985
6544
|
chatId: this.opts.chatId,
|
|
4986
6545
|
pid: this.opts.pid ?? process.pid,
|
|
6546
|
+
generation: DAEMON_GENERATION,
|
|
6547
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
4987
6548
|
fs: this.fsImpl,
|
|
4988
6549
|
now: this.opts.now,
|
|
4989
6550
|
});
|