@gajae-code/coding-agent 0.11.4 → 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 +23 -0
- package/dist/types/cli/notify-cli.d.ts +5 -1
- 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/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/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/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 +58 -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/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/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 +48 -0
- package/dist/types/tools/bash-skill-urls.d.ts +2 -2
- package/dist/types/tools/index.d.ts +4 -0
- package/package.json +9 -9
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/src/cli/notify-cli.ts +40 -18
- package/src/commands/sdk.ts +7 -2
- 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/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/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-runtime.ts +38 -12
- 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/notifications-settings-editor.ts +26 -2
- package/src/modes/components/session-observer-overlay.ts +40 -24
- package/src/modes/components/settings-selector.ts +3 -1
- package/src/modes/components/tool-transcript-format.ts +321 -0
- package/src/modes/components/transcript-viewer-overlay.ts +39 -17
- package/src/modes/controllers/goal-mode-controller.ts +20 -1
- 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 +70 -8
- package/src/modes/interactive-mode.ts +1 -0
- 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/session-index.ts +122 -64
- 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 +1788 -267
- 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 +200 -38
- 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 +129 -32
- package/src/tools/bash-skill-urls.ts +14 -3
- package/src/tools/bash.ts +2 -2
- package/src/tools/index.ts +4 -0
- package/src/tools/plan-mode-guard.ts +1 -0
- package/src/tools/sqlite-reader.ts +3 -2
- package/src/tools/subagent.ts +2 -0
|
@@ -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: {
|
|
716
1481
|
settings: Settings;
|
|
717
1482
|
ownerId: string;
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
/**
|
|
1483
|
+
acquisitionId?: string;
|
|
1484
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1485
|
+
pidAlive?: (pid: number) => boolean;
|
|
1486
|
+
/** Detached child PID, when the launcher successfully reported it. */
|
|
722
1487
|
pid: number;
|
|
1488
|
+
/** PID which created the provisional reservation before the child was bound. */
|
|
1489
|
+
launcherPid?: number;
|
|
723
1490
|
fs?: TelegramDaemonFs;
|
|
724
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;
|
|
725
1497
|
}): Promise<boolean> {
|
|
726
1498
|
const fsImpl = input.fs ?? nodeFs;
|
|
727
1499
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
728
|
-
|
|
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 {
|
|
729
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);
|
|
730
1517
|
if (
|
|
731
1518
|
!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
1519
|
state.ownerId !== input.ownerId ||
|
|
738
|
-
|
|
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"))
|
|
739
1530
|
)
|
|
740
1531
|
return false;
|
|
741
|
-
if (!
|
|
742
|
-
if (state.pid !== input.pid && (!validDaemonPid(state.launcherPid) || state.pid !== state.launcherPid))
|
|
743
|
-
return false;
|
|
1532
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
|
|
744
1533
|
await writeJsonAtomic(fsImpl, paths.state, {
|
|
745
1534
|
...state,
|
|
746
|
-
|
|
747
|
-
|
|
1535
|
+
ownershipPhase: "retired",
|
|
1536
|
+
stoppedAt: (input.now ?? Date.now)(),
|
|
748
1537
|
});
|
|
1538
|
+
if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
|
|
1539
|
+
await fsImpl.unlink(paths.lock).catch(() => undefined);
|
|
749
1540
|
return true;
|
|
750
|
-
}
|
|
1541
|
+
} finally {
|
|
1542
|
+
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1543
|
+
}
|
|
751
1544
|
}
|
|
752
1545
|
|
|
753
|
-
|
|
1546
|
+
/** Bind a launcher-reserved provisional acquisition to its actual detached child. */
|
|
1547
|
+
async function bindProvisionalDaemonPid(input: {
|
|
754
1548
|
settings: Settings;
|
|
755
1549
|
ownerId: string;
|
|
756
|
-
|
|
1550
|
+
acquisitionId: string;
|
|
1551
|
+
pid: number;
|
|
1552
|
+
incarnation: string;
|
|
1553
|
+
fs?: TelegramDaemonFs;
|
|
1554
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1555
|
+
stealRetries?: number;
|
|
1556
|
+
stealRetryDelayMs?: number;
|
|
1557
|
+
}): Promise<boolean> {
|
|
1558
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
1559
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
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 {
|
|
1569
|
+
const state = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1570
|
+
if (
|
|
1571
|
+
!state ||
|
|
1572
|
+
state.ownerId !== input.ownerId ||
|
|
1573
|
+
state.acquisitionId !== input.acquisitionId ||
|
|
1574
|
+
state.ownershipPhase !== "provisional" ||
|
|
1575
|
+
state.generation !== DAEMON_GENERATION ||
|
|
1576
|
+
!isProcessIncarnation(state.incarnation) ||
|
|
1577
|
+
!isProcessIncarnation(input.incarnation)
|
|
1578
|
+
)
|
|
1579
|
+
return false;
|
|
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
|
+
)
|
|
1589
|
+
return false;
|
|
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
|
+
}
|
|
1623
|
+
return true;
|
|
1624
|
+
} finally {
|
|
1625
|
+
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/** Wait for a matching current-generation daemon to publish a ready state. */
|
|
1630
|
+
export async function waitForTelegramDaemonReady(input: {
|
|
1631
|
+
settings: Settings;
|
|
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 {
|
|
@@ -1327,6 +2461,7 @@ export interface TelegramDaemonOptions {
|
|
|
1327
2461
|
pid?: number;
|
|
1328
2462
|
/** Liveness probe for skipping dead-PID endpoint records in {@link TelegramNotificationDaemon.scanRoots}. */
|
|
1329
2463
|
pidAlive?: (pid: number) => boolean;
|
|
2464
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1330
2465
|
botApi?: BotApi;
|
|
1331
2466
|
control?: DaemonControlHooks;
|
|
1332
2467
|
/**
|
|
@@ -1350,6 +2485,8 @@ export interface TelegramDaemonOptions {
|
|
|
1350
2485
|
rich?: { enabled: boolean };
|
|
1351
2486
|
/** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */
|
|
1352
2487
|
richDraft?: { enabled: boolean };
|
|
2488
|
+
/** Tool start/completion messages (enabled by default). */
|
|
2489
|
+
toolActivity?: { enabled: boolean };
|
|
1353
2490
|
/**
|
|
1354
2491
|
* Per-session Telegram forum-topic naming. `nameTemplate` supports the
|
|
1355
2492
|
* `{repo}`, `{branch}`, and `{title}` placeholders; unset preserves the
|
|
@@ -1405,9 +2542,21 @@ interface TopicAuthorityLease {
|
|
|
1405
2542
|
authorityEpoch: number;
|
|
1406
2543
|
}
|
|
1407
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
|
+
|
|
1408
2555
|
interface PendingThreadedFrame {
|
|
1409
2556
|
send: ThreadedSend;
|
|
1410
2557
|
msg: Record<string, unknown>;
|
|
2558
|
+
endpointDigest: string;
|
|
2559
|
+
toolActivity?: ToolActivityOwner;
|
|
1411
2560
|
}
|
|
1412
2561
|
|
|
1413
2562
|
type SelectedAckOutcome =
|
|
@@ -1441,6 +2590,7 @@ interface TelegramQueuePayload {
|
|
|
1441
2590
|
topicLease?: TopicAuthorityLease;
|
|
1442
2591
|
selectedAck?: SelectedAckQueueItem;
|
|
1443
2592
|
btwDelivery?: BtwQueuedDelivery;
|
|
2593
|
+
toolActivity?: ToolActivityOwner;
|
|
1444
2594
|
}
|
|
1445
2595
|
|
|
1446
2596
|
interface PendingBtwTurn {
|
|
@@ -1531,6 +2681,16 @@ export class TelegramNotificationDaemon {
|
|
|
1531
2681
|
readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
|
|
1532
2682
|
/** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
|
|
1533
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;
|
|
1534
2694
|
readonly sessions = new Map<string, SessionSocket>();
|
|
1535
2695
|
/** Ephemeral aliases for model choices; deliberately never serialized across daemon restarts. */
|
|
1536
2696
|
#modelChoiceAliases = new Map<string, ModelChoiceRoute>();
|
|
@@ -1640,7 +2800,13 @@ export class TelegramNotificationDaemon {
|
|
|
1640
2800
|
* ~25s getUpdates timeout. Safe to call from a signal handler.
|
|
1641
2801
|
*/
|
|
1642
2802
|
requestStop(_reason?: "reload" | "stop" | "signal"): void {
|
|
1643
|
-
this.
|
|
2803
|
+
const toolShutdown = this.beginToolActivityShutdown();
|
|
2804
|
+
void toolShutdown
|
|
2805
|
+
.finally(() => {
|
|
2806
|
+
this.effects.beginShutdown();
|
|
2807
|
+
this.#deliveryAbort.abort();
|
|
2808
|
+
})
|
|
2809
|
+
.catch(() => undefined);
|
|
1644
2810
|
for (const item of new Set(this.selectedAckPending.values())) {
|
|
1645
2811
|
if (item.state === "queued") this.pool.removeById(item.itemId);
|
|
1646
2812
|
else item.controller?.abort();
|
|
@@ -1651,7 +2817,6 @@ export class TelegramNotificationDaemon {
|
|
|
1651
2817
|
delivery.invalidated = true;
|
|
1652
2818
|
delivery.controller.abort("daemon_shutdown");
|
|
1653
2819
|
}
|
|
1654
|
-
this.#deliveryAbort.abort();
|
|
1655
2820
|
this.runtime.requestStop();
|
|
1656
2821
|
this.running = false;
|
|
1657
2822
|
}
|
|
@@ -2458,7 +3623,131 @@ export class TelegramNotificationDaemon {
|
|
|
2458
3623
|
* (so a delayed old close cannot delete a replacement), and best-effort closes
|
|
2459
3624
|
* the socket. `scanRoots()` then reconnects the session.
|
|
2460
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
|
+
|
|
2461
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);
|
|
2462
3751
|
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
2463
3752
|
if (session.pingTimer) {
|
|
2464
3753
|
clearIntervalImpl(session.pingTimer);
|
|
@@ -2469,7 +3758,6 @@ export class TelegramNotificationDaemon {
|
|
|
2469
3758
|
} else {
|
|
2470
3759
|
void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
|
|
2471
3760
|
}
|
|
2472
|
-
const isCurrentSession = this.sessions.get(session.sessionId) === session;
|
|
2473
3761
|
if (isCurrentSession || reason === "session_closed") {
|
|
2474
3762
|
this.deleteMessageRoutes(session.sessionId);
|
|
2475
3763
|
}
|
|
@@ -2929,16 +4217,39 @@ export class TelegramNotificationDaemon {
|
|
|
2929
4217
|
return !ownerId || ownerId === session.sessionId;
|
|
2930
4218
|
}
|
|
2931
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
|
+
}
|
|
2932
4242
|
private async submitThreadedFrame(
|
|
2933
4243
|
sessionId: string,
|
|
2934
4244
|
send: ThreadedSend,
|
|
2935
4245
|
topicLease: TopicAuthorityLease,
|
|
4246
|
+
toolActivity?: ToolActivityOwner,
|
|
2936
4247
|
): Promise<void> {
|
|
2937
4248
|
this.submitPool({
|
|
2938
4249
|
sessionId,
|
|
2939
4250
|
lane: send.lane,
|
|
2940
4251
|
coalesceKey: send.coalesceKey,
|
|
2941
|
-
payload: { send, topicLease },
|
|
4252
|
+
payload: { send, topicLease, ...(toolActivity ? { toolActivity } : {}) },
|
|
2942
4253
|
});
|
|
2943
4254
|
await this.flushPool();
|
|
2944
4255
|
}
|
|
@@ -3000,18 +4311,33 @@ export class TelegramNotificationDaemon {
|
|
|
3000
4311
|
}
|
|
3001
4312
|
}
|
|
3002
4313
|
|
|
3003
|
-
private rememberPendingThreadedFrame(
|
|
3004
|
-
|
|
3005
|
-
|
|
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
|
+
});
|
|
3006
4327
|
if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
|
|
3007
|
-
this.pendingThreadedFrames.set(sessionId, frames);
|
|
4328
|
+
this.pendingThreadedFrames.set(session.sessionId, frames);
|
|
3008
4329
|
}
|
|
3009
4330
|
|
|
3010
4331
|
private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
|
|
3011
4332
|
const frames = this.pendingThreadedFrames.get(sessionId);
|
|
3012
4333
|
if (!frames || frames.length === 0) return;
|
|
3013
4334
|
this.pendingThreadedFrames.delete(sessionId);
|
|
3014
|
-
|
|
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
|
+
}
|
|
3015
4341
|
}
|
|
3016
4342
|
|
|
3017
4343
|
/**
|
|
@@ -3096,7 +4422,11 @@ export class TelegramNotificationDaemon {
|
|
|
3096
4422
|
})) as { ok?: boolean };
|
|
3097
4423
|
if (!topicDeleteSettled(res)) return;
|
|
3098
4424
|
this.topics.settleDelete(sessionId, record.topicId);
|
|
3099
|
-
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
|
+
}
|
|
3100
4430
|
this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
|
|
3101
4431
|
if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
|
|
3102
4432
|
});
|
|
@@ -3280,6 +4610,21 @@ export class TelegramNotificationDaemon {
|
|
|
3280
4610
|
);
|
|
3281
4611
|
}
|
|
3282
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
|
+
}
|
|
3283
4628
|
const btwDelivery = item.payload.btwDelivery;
|
|
3284
4629
|
if (btwDelivery) {
|
|
3285
4630
|
if (!btwDelivery.isAuthoritative()) {
|
|
@@ -3374,6 +4719,10 @@ export class TelegramNotificationDaemon {
|
|
|
3374
4719
|
this.pool.settle(item.itemId!, "rejected");
|
|
3375
4720
|
continue;
|
|
3376
4721
|
}
|
|
4722
|
+
if (item.payload.toolActivity && !this.toolActivityAuthorityIsCurrent(item.payload.toolActivity)) {
|
|
4723
|
+
this.pool.settle(item.itemId!, "removed");
|
|
4724
|
+
continue;
|
|
4725
|
+
}
|
|
3377
4726
|
// Threaded topic when available; otherwise deliver flat to the paired chat.
|
|
3378
4727
|
const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
|
|
3379
4728
|
const ckey = send.editable ? item.coalesceKey : undefined;
|
|
@@ -3572,19 +4921,25 @@ export class TelegramNotificationDaemon {
|
|
|
3572
4921
|
}
|
|
3573
4922
|
}
|
|
3574
4923
|
if (editKey && ckey !== undefined && firstMessageId !== undefined && !send.terminal) {
|
|
3575
|
-
this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
|
|
4924
|
+
this.recordLiveMessage(item.sessionId, ckey, firstMessageId, item.payload.toolActivity);
|
|
3576
4925
|
}
|
|
3577
4926
|
}
|
|
3578
4927
|
}
|
|
3579
4928
|
} catch {
|
|
3580
4929
|
// Best-effort: a failed send/edit must never stop the daemon.
|
|
3581
4930
|
disposition = "ambiguous";
|
|
4931
|
+
if (item.payload.toolActivity?.phase === "started") this.toolActivityAmbiguous = true;
|
|
3582
4932
|
} finally {
|
|
3583
4933
|
this.pool.settle(item.itemId!, disposition);
|
|
3584
4934
|
// A terminal tool frame owns the end of this coalescing key even when both
|
|
3585
4935
|
// edit and fallback delivery fail. Retaining the old message id would leak
|
|
3586
4936
|
// one entry per failure and let a later reused key edit stale Telegram state.
|
|
3587
|
-
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
|
+
}
|
|
3588
4943
|
}
|
|
3589
4944
|
}
|
|
3590
4945
|
}
|
|
@@ -3594,7 +4949,12 @@ export class TelegramNotificationDaemon {
|
|
|
3594
4949
|
* so later live/finalized frames edit it in place. Evicts this session's stale
|
|
3595
4950
|
* same-category entries (e.g. prior turns) so the map stays bounded.
|
|
3596
4951
|
*/
|
|
3597
|
-
private recordLiveMessage(
|
|
4952
|
+
private recordLiveMessage(
|
|
4953
|
+
sessionId: string,
|
|
4954
|
+
coalesceKey: string,
|
|
4955
|
+
messageId: number,
|
|
4956
|
+
toolActivity?: ToolActivityOwner,
|
|
4957
|
+
): void {
|
|
3598
4958
|
const mapKey = `${sessionId}:${coalesceKey}`;
|
|
3599
4959
|
const category = coalesceKey.split(":")[0] ?? "";
|
|
3600
4960
|
// Single-slot categories (rolling turn/context/reasoning previews) evict prior
|
|
@@ -3609,7 +4969,15 @@ export class TelegramNotificationDaemon {
|
|
|
3609
4969
|
if (k !== mapKey && k.startsWith(prefix)) this.liveMessages.delete(k);
|
|
3610
4970
|
}
|
|
3611
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
|
+
}
|
|
3612
4979
|
this.liveMessages.set(mapKey, messageId);
|
|
4980
|
+
if (toolActivity) this.toolActivityOwners.set(mapKey, toolActivity);
|
|
3613
4981
|
}
|
|
3614
4982
|
|
|
3615
4983
|
/**
|
|
@@ -3621,11 +4989,20 @@ export class TelegramNotificationDaemon {
|
|
|
3621
4989
|
* content never lands in a shared chat. Identity headers are sent at most once per
|
|
3622
4990
|
* session in flat mode.
|
|
3623
4991
|
*/
|
|
3624
|
-
private async deliverFlatFallback(
|
|
4992
|
+
private async deliverFlatFallback(
|
|
4993
|
+
sessionId: string,
|
|
4994
|
+
send: ThreadedSend,
|
|
4995
|
+
toolActivity?: ToolActivityOwner,
|
|
4996
|
+
): Promise<void> {
|
|
3625
4997
|
if (!(await this.pairedChatIsPrivate())) return;
|
|
3626
4998
|
await this.notifyThreadedFallback();
|
|
3627
4999
|
if (send.identity && this.flatIdentitySent.has(sessionId)) return;
|
|
3628
|
-
this.submitPool({
|
|
5000
|
+
this.submitPool({
|
|
5001
|
+
sessionId,
|
|
5002
|
+
lane: send.lane,
|
|
5003
|
+
coalesceKey: send.coalesceKey,
|
|
5004
|
+
payload: { send, ...(toolActivity ? { toolActivity } : {}) },
|
|
5005
|
+
});
|
|
3629
5006
|
await this.flushPool();
|
|
3630
5007
|
if (send.identity) this.flatIdentitySent.add(sessionId);
|
|
3631
5008
|
}
|
|
@@ -3697,11 +5074,13 @@ export class TelegramNotificationDaemon {
|
|
|
3697
5074
|
return renewDaemonHeartbeat({
|
|
3698
5075
|
settings: this.opts.settings,
|
|
3699
5076
|
ownerId: this.opts.ownerId,
|
|
5077
|
+
acquisitionId: this.opts.ownerId,
|
|
3700
5078
|
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
3701
5079
|
chatId: this.opts.chatId,
|
|
3702
5080
|
fs: this.fsImpl,
|
|
3703
5081
|
now: this.opts.now,
|
|
3704
5082
|
pid: this.opts.pid ?? process.pid,
|
|
5083
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
3705
5084
|
});
|
|
3706
5085
|
}
|
|
3707
5086
|
|
|
@@ -3848,9 +5227,13 @@ export class TelegramNotificationDaemon {
|
|
|
3848
5227
|
|
|
3849
5228
|
async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
|
|
3850
5229
|
if (session.replayPending) {
|
|
5230
|
+
if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled === false || this.toolActivityStopping))
|
|
5231
|
+
return;
|
|
3851
5232
|
const matchingReplay = msg?.type === "event_replay_result" && msg.id === session.replayId;
|
|
3852
5233
|
if (!matchingReplay) {
|
|
3853
|
-
|
|
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);
|
|
3854
5237
|
return;
|
|
3855
5238
|
}
|
|
3856
5239
|
session.replayPending = false;
|
|
@@ -3895,6 +5278,11 @@ export class TelegramNotificationDaemon {
|
|
|
3895
5278
|
}
|
|
3896
5279
|
const queued = session.replayQueue.splice(0);
|
|
3897
5280
|
for (const frame of queued) {
|
|
5281
|
+
if (
|
|
5282
|
+
frame.type === "tool_activity" &&
|
|
5283
|
+
this.replayToolActivityEpochs.get(frame) !== this.toolActivityPolicyEpoch
|
|
5284
|
+
)
|
|
5285
|
+
continue;
|
|
3898
5286
|
const fingerprint = JSON.stringify(frame);
|
|
3899
5287
|
const remaining = replayCounts.get(fingerprint) ?? 0;
|
|
3900
5288
|
if (remaining > 0) {
|
|
@@ -4129,12 +5517,63 @@ export class TelegramNotificationDaemon {
|
|
|
4129
5517
|
return;
|
|
4130
5518
|
}
|
|
4131
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
|
+
}
|
|
4132
5563
|
const send = renderThreadedFrame(msg);
|
|
4133
5564
|
if (!send) return;
|
|
4134
5565
|
const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
|
|
5566
|
+
if (!toolStartIsCurrent()) {
|
|
5567
|
+
abandonStaleToolStart();
|
|
5568
|
+
return;
|
|
5569
|
+
}
|
|
4135
5570
|
if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
|
|
4136
5571
|
if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
|
|
4137
|
-
|
|
5572
|
+
if (!toolStartIsCurrent()) {
|
|
5573
|
+
abandonStaleToolStart();
|
|
5574
|
+
return;
|
|
5575
|
+
}
|
|
5576
|
+
this.rememberPendingThreadedFrame(session, send, threadedFrame, toolActivity);
|
|
4138
5577
|
return;
|
|
4139
5578
|
}
|
|
4140
5579
|
if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
|
|
@@ -4148,9 +5587,13 @@ export class TelegramNotificationDaemon {
|
|
|
4148
5587
|
const topicId =
|
|
4149
5588
|
existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
|
|
4150
5589
|
const topicLease = await this.topicAuthorityLease(session.sessionId);
|
|
5590
|
+
if (!toolStartIsCurrent()) {
|
|
5591
|
+
abandonStaleToolStart();
|
|
5592
|
+
return;
|
|
5593
|
+
}
|
|
4151
5594
|
if (!topicId || !topicLease || topicLease.topicId !== topicId) {
|
|
4152
5595
|
if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
|
|
4153
|
-
await this.deliverFlatFallback(session.sessionId, send);
|
|
5596
|
+
await this.deliverFlatFallback(session.sessionId, send, toolActivity);
|
|
4154
5597
|
return;
|
|
4155
5598
|
}
|
|
4156
5599
|
if (send.identity) {
|
|
@@ -4198,7 +5641,11 @@ export class TelegramNotificationDaemon {
|
|
|
4198
5641
|
await this.persistTopics();
|
|
4199
5642
|
return;
|
|
4200
5643
|
}
|
|
4201
|
-
|
|
5644
|
+
if (!toolStartIsCurrent()) {
|
|
5645
|
+
abandonStaleToolStart();
|
|
5646
|
+
return;
|
|
5647
|
+
}
|
|
5648
|
+
await this.submitThreadedFrame(session.sessionId, send, topicLease, toolActivity);
|
|
4202
5649
|
return;
|
|
4203
5650
|
}
|
|
4204
5651
|
if (msg.type === "action_needed" && msg.id) {
|
|
@@ -4513,28 +5960,32 @@ export class TelegramNotificationDaemon {
|
|
|
4513
5960
|
}
|
|
4514
5961
|
}
|
|
4515
5962
|
}
|
|
4516
|
-
//
|
|
4517
|
-
//
|
|
4518
|
-
//
|
|
4519
|
-
// 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.
|
|
4520
5966
|
{
|
|
4521
5967
|
const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
|
|
4522
5968
|
const chat = m?.chat as { id?: unknown } | undefined;
|
|
4523
5969
|
const cmdText = typeof m?.text === "string" ? m.text : undefined;
|
|
4524
|
-
const
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
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.
|
|
4535
5987
|
if (!(await this.pairedChatIsPrivate())) return;
|
|
4536
5988
|
const updateId = (update as { update_id?: number }).update_id;
|
|
4537
|
-
// Dedupe redelivered updates so a toggle+confirmation runs at most once.
|
|
4538
5989
|
if (typeof updateId === "number") {
|
|
4539
5990
|
if (this.dispatchState.seenUpdateIds.has(updateId)) return;
|
|
4540
5991
|
await this.rememberSeenUpdateId(updateId);
|
|
@@ -4553,31 +6004,70 @@ export class TelegramNotificationDaemon {
|
|
|
4553
6004
|
// Best-effort confirmation; never block on the notice.
|
|
4554
6005
|
}
|
|
4555
6006
|
};
|
|
4556
|
-
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";
|
|
4557
6011
|
if (desired === undefined) {
|
|
4558
|
-
await reply(
|
|
6012
|
+
await reply(usage);
|
|
4559
6013
|
return;
|
|
4560
6014
|
}
|
|
6015
|
+
const settingPath = isRichCommand
|
|
6016
|
+
? "notifications.telegram.rich.enabled"
|
|
6017
|
+
: "notifications.telegram.toolActivity.enabled";
|
|
6018
|
+
const label = isRichCommand ? "Rich messages" : "Tool activity";
|
|
4561
6019
|
try {
|
|
4562
|
-
await this.opts.settings.set(
|
|
4563
|
-
// Confirm
|
|
4564
|
-
|
|
4565
|
-
// swallows write errors, and Settings.flush() inherits that — neither
|
|
4566
|
-
// rejects on a failed config.yml write. flushOrThrow() rethrows the
|
|
4567
|
-
// durable-write failure so it lands in the catch below (in-memory
|
|
4568
|
-
// isolated Settings short-circuit and never throw). The lightweight
|
|
4569
|
-
// daemon settings has no flushOrThrow: its set() already wrote durably
|
|
4570
|
-
// (and throws on failure), so its flush() is only a no-op drain.
|
|
4571
|
-
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);
|
|
4572
6023
|
} catch (err) {
|
|
4573
6024
|
logger.warn(
|
|
4574
|
-
`notifications:
|
|
6025
|
+
`notifications: ${command} settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
|
|
4575
6026
|
);
|
|
4576
|
-
await reply(
|
|
6027
|
+
await reply(`${label}: unchanged (settings write failed)`);
|
|
4577
6028
|
return;
|
|
4578
6029
|
}
|
|
4579
|
-
|
|
4580
|
-
|
|
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"}`);
|
|
4581
6071
|
return;
|
|
4582
6072
|
}
|
|
4583
6073
|
}
|
|
@@ -4895,6 +6385,10 @@ export class TelegramNotificationDaemon {
|
|
|
4895
6385
|
{ command: "lean", description: "Mirror assistant text + tool names only (default)" },
|
|
4896
6386
|
{ command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
|
|
4897
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
|
+
},
|
|
4898
6392
|
{ command: "reasoning", description: "Show or change reasoning effort in this session" },
|
|
4899
6393
|
{ command: "usage", description: "Show provider/local usage for this session" },
|
|
4900
6394
|
{ command: "model", description: "Select a model for this session" },
|
|
@@ -4923,11 +6417,13 @@ export class TelegramNotificationDaemon {
|
|
|
4923
6417
|
this.running = await renewDaemonHeartbeat({
|
|
4924
6418
|
settings: this.opts.settings,
|
|
4925
6419
|
ownerId: this.opts.ownerId,
|
|
6420
|
+
acquisitionId: this.opts.ownerId,
|
|
4926
6421
|
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
4927
6422
|
chatId: this.opts.chatId,
|
|
4928
6423
|
fs: this.fsImpl,
|
|
4929
6424
|
now: this.opts.now,
|
|
4930
6425
|
pid: this.opts.pid ?? process.pid,
|
|
6426
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
4931
6427
|
});
|
|
4932
6428
|
if (!this.running) return;
|
|
4933
6429
|
this.runtime.start();
|
|
@@ -4949,7 +6445,20 @@ export class TelegramNotificationDaemon {
|
|
|
4949
6445
|
let idleSince = this.runtime.now();
|
|
4950
6446
|
while (this.running) {
|
|
4951
6447
|
if (await this.controlStopRequested()) break;
|
|
4952
|
-
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;
|
|
4953
6462
|
await this.runScan();
|
|
4954
6463
|
if (await this.controlStopRequested()) break;
|
|
4955
6464
|
const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
|
|
@@ -4984,10 +6493,19 @@ export class TelegramNotificationDaemon {
|
|
|
4984
6493
|
await this.runtime.sleep(10);
|
|
4985
6494
|
}
|
|
4986
6495
|
} finally {
|
|
6496
|
+
let toolShutdownError: unknown;
|
|
6497
|
+
try {
|
|
6498
|
+
await this.beginToolActivityShutdown();
|
|
6499
|
+
} catch (error) {
|
|
6500
|
+
toolShutdownError = error;
|
|
6501
|
+
}
|
|
4987
6502
|
this.effects.beginShutdown();
|
|
6503
|
+
this.#deliveryAbort.abort();
|
|
4988
6504
|
let persisted = false;
|
|
4989
6505
|
const shutdown = this.effects.allowTerminal(async () => {
|
|
6506
|
+
if (toolShutdownError) throw toolShutdownError;
|
|
4990
6507
|
await this.#drainBtwTurns();
|
|
6508
|
+
await this.toolTerminalizationChain;
|
|
4991
6509
|
this.runtime.stop();
|
|
4992
6510
|
this.stopOwnershipHeartbeatTimer();
|
|
4993
6511
|
this.stopFlushTimer();
|
|
@@ -5021,9 +6539,12 @@ export class TelegramNotificationDaemon {
|
|
|
5021
6539
|
await releaseDaemonOwnership({
|
|
5022
6540
|
settings: this.opts.settings,
|
|
5023
6541
|
ownerId: this.opts.ownerId,
|
|
6542
|
+
acquisitionId: this.opts.ownerId,
|
|
5024
6543
|
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
5025
6544
|
chatId: this.opts.chatId,
|
|
5026
6545
|
pid: this.opts.pid ?? process.pid,
|
|
6546
|
+
generation: DAEMON_GENERATION,
|
|
6547
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
5027
6548
|
fs: this.fsImpl,
|
|
5028
6549
|
now: this.opts.now,
|
|
5029
6550
|
});
|