@gajae-code/coding-agent 0.11.4 → 0.11.6
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 +31 -1
- 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 +42 -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 +11 -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 +98 -18
- package/dist/types/session/session-storage.d.ts +27 -0
- package/dist/types/session/streaming-output.d.ts +4 -0
- package/dist/types/session/yield-queue.d.ts +8 -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 +3 -0
- package/src/cli/notify-cli.ts +40 -18
- package/src/commands/sdk.ts +7 -2
- package/src/config/settings-schema.ts +31 -1
- 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 +8 -7
- package/src/internal-urls/local-protocol.ts +416 -18
- package/src/lsp/index.ts +4 -88
- package/src/main.ts +36 -12
- 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/command-controller.ts +15 -0
- package/src/modes/controllers/extension-ui-controller.ts +14 -2
- 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/runtime-init.ts +16 -2
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
- package/src/plan-mode/approved-plan.ts +3 -1
- package/src/prompts/agents/architect.md +1 -0
- package/src/prompts/agents/critic.md +1 -0
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/system-prompt.md +2 -1
- package/src/prompts/tools/lsp.md +3 -3
- package/src/runtime-mcp/manager.ts +16 -1
- package/src/runtime-mcp/oauth-discovery.ts +116 -26
- package/src/sdk/broker/session-index.ts +122 -64
- package/src/sdk/bus/chat-daemon-cli.ts +72 -10
- package/src/sdk/bus/chat-daemon-control.ts +538 -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 +388 -94
- 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 +11 -3
- package/src/sdk/bus/telegram-daemon-control.ts +312 -76
- package/src/sdk/bus/telegram-daemon.ts +1916 -285
- package/src/sdk/host/control/dispatch.ts +14 -5
- package/src/sdk/protocol/operation-inventory.generated.json +29 -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 +1447 -852
- 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 +1121 -148
- package/src/session/session-storage.ts +109 -11
- package/src/session/streaming-output.ts +6 -0
- package/src/session/yield-queue.ts +20 -0
- package/src/slash-commands/builtin-registry.ts +36 -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-answer-registry.ts +15 -1
- 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,245 @@ 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
|
+
* v0.11.4 and earlier generations wrote stopped tombstones before acquisition
|
|
870
|
+
* and incarnation fields existed. A live, reused PID must not turn that durable
|
|
871
|
+
* stop marker into a foreign live owner during an upgrade.
|
|
872
|
+
*/
|
|
873
|
+
function isLegacyStoppedDaemonState(state: unknown): boolean {
|
|
874
|
+
const candidate = state as Partial<DaemonState> | undefined;
|
|
875
|
+
return Boolean(
|
|
876
|
+
candidate &&
|
|
877
|
+
Number.isSafeInteger(candidate.pid) &&
|
|
878
|
+
(candidate.pid ?? 0) > 0 &&
|
|
879
|
+
typeof candidate.ownerId === "string" &&
|
|
880
|
+
candidate.ownerId.length > 0 &&
|
|
881
|
+
typeof candidate.tokenFingerprint === "string" &&
|
|
882
|
+
typeof candidate.chatId === "string" &&
|
|
883
|
+
Number.isSafeInteger(candidate.startedAt) &&
|
|
884
|
+
Number.isSafeInteger(candidate.heartbeatAt) &&
|
|
885
|
+
Number.isSafeInteger(candidate.stoppedAt) &&
|
|
886
|
+
(candidate.launcherPid === undefined ||
|
|
887
|
+
(Number.isSafeInteger(candidate.launcherPid) && (candidate.launcherPid ?? 0) > 0)) &&
|
|
888
|
+
Array.isArray(candidate.roots) &&
|
|
889
|
+
candidate.roots.every(root => typeof root === "string") &&
|
|
890
|
+
candidate.version === DAEMON_VERSION &&
|
|
891
|
+
isRecognizedLegacyGeneration(candidate.generation) &&
|
|
892
|
+
candidate.incarnation === undefined &&
|
|
893
|
+
candidate.acquisitionId === undefined &&
|
|
894
|
+
candidate.ownershipPhase === undefined,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function isStoppedDaemonState(state: unknown): boolean {
|
|
899
|
+
return isExplicitlyStoppedDaemonState(state) || isLegacyStoppedDaemonState(state);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function ownerIdentityMatches(
|
|
903
|
+
state: Pick<DaemonState, "tokenFingerprint" | "chatId">,
|
|
904
|
+
tokenFingerprint: string,
|
|
905
|
+
chatId: string,
|
|
906
|
+
): boolean {
|
|
528
907
|
return state.tokenFingerprint === tokenFingerprint && state.chatId === chatId;
|
|
529
908
|
}
|
|
530
909
|
|
|
531
|
-
function
|
|
910
|
+
function ownerProvenanceMatches(state: DaemonState, pidIncarnation?: (pid: number) => string | undefined): boolean {
|
|
911
|
+
const current = (pidIncarnation ?? defaultPidIncarnation)(state.pid);
|
|
912
|
+
return isProcessIncarnation(state.incarnation) && isProcessIncarnation(current) && current === state.incarnation;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
export function hasSafeDaemonStateShape(state: unknown): state is DaemonState {
|
|
916
|
+
if (!state || typeof state !== "object" || Array.isArray(state)) return false;
|
|
917
|
+
const candidate = state as Partial<DaemonState>;
|
|
918
|
+
return Boolean(
|
|
919
|
+
Number.isSafeInteger(candidate.pid) &&
|
|
920
|
+
(candidate.pid as number) > 0 &&
|
|
921
|
+
typeof candidate.ownerId === "string" &&
|
|
922
|
+
candidate.ownerId.length > 0 &&
|
|
923
|
+
(candidate.acquisitionId === undefined ||
|
|
924
|
+
(typeof candidate.acquisitionId === "string" && candidate.acquisitionId.length > 0)) &&
|
|
925
|
+
(candidate.ownershipPhase === undefined ||
|
|
926
|
+
candidate.ownershipPhase === "provisional" ||
|
|
927
|
+
candidate.ownershipPhase === "ready" ||
|
|
928
|
+
candidate.ownershipPhase === "retired") &&
|
|
929
|
+
typeof candidate.tokenFingerprint === "string" &&
|
|
930
|
+
typeof candidate.chatId === "string" &&
|
|
931
|
+
Number.isSafeInteger(candidate.startedAt) &&
|
|
932
|
+
Number.isSafeInteger(candidate.heartbeatAt) &&
|
|
933
|
+
isProcessIncarnation(candidate.incarnation) &&
|
|
934
|
+
(candidate.launcherPid === undefined ||
|
|
935
|
+
(Number.isSafeInteger(candidate.launcherPid) && (candidate.launcherPid as number) > 0)) &&
|
|
936
|
+
Array.isArray(candidate.roots) &&
|
|
937
|
+
candidate.roots.every(root => typeof root === "string") &&
|
|
938
|
+
candidate.version === DAEMON_VERSION &&
|
|
939
|
+
(candidate.generation === undefined ||
|
|
940
|
+
(Number.isSafeInteger(candidate.generation) && (candidate.generation as number) > 0)) &&
|
|
941
|
+
(candidate.stoppedAt === undefined || Number.isSafeInteger(candidate.stoppedAt)),
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
type LegacyParentDaemonState = Omit<DaemonState, "incarnation" | "acquisitionId" | "ownershipPhase" | "generation"> & {
|
|
946
|
+
incarnation?: undefined;
|
|
947
|
+
acquisitionId?: undefined;
|
|
948
|
+
ownershipPhase?: undefined;
|
|
949
|
+
generation?: undefined;
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
interface LegacyMigrationAttestation {
|
|
953
|
+
pid: number;
|
|
954
|
+
incarnation: string;
|
|
955
|
+
heartbeatAt: number;
|
|
956
|
+
observedAt: number;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function isLegacyParentDaemonState(state: unknown): state is LegacyParentDaemonState {
|
|
960
|
+
const candidate = state as Partial<LegacyParentDaemonState> | undefined;
|
|
961
|
+
return Boolean(
|
|
962
|
+
candidate &&
|
|
963
|
+
Number.isSafeInteger(candidate.pid) &&
|
|
964
|
+
(candidate.pid ?? 0) > 0 &&
|
|
965
|
+
typeof candidate.ownerId === "string" &&
|
|
966
|
+
candidate.ownerId.length > 0 &&
|
|
967
|
+
typeof candidate.tokenFingerprint === "string" &&
|
|
968
|
+
typeof candidate.chatId === "string" &&
|
|
969
|
+
Number.isSafeInteger(candidate.startedAt) &&
|
|
970
|
+
Number.isSafeInteger(candidate.heartbeatAt) &&
|
|
971
|
+
Array.isArray(candidate.roots) &&
|
|
972
|
+
candidate.roots.every(root => typeof root === "string") &&
|
|
973
|
+
candidate.version === DAEMON_VERSION &&
|
|
974
|
+
candidate.incarnation === undefined &&
|
|
975
|
+
candidate.acquisitionId === undefined &&
|
|
976
|
+
candidate.ownershipPhase === undefined &&
|
|
977
|
+
candidate.generation === undefined &&
|
|
978
|
+
candidate.stoppedAt === undefined,
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function legacyMigrationAttestationPath(statePath: string): string {
|
|
983
|
+
return `${statePath}.legacy-migration.json`;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async function legacyParentHandoffDecision(input: {
|
|
987
|
+
fs: TelegramDaemonFs;
|
|
988
|
+
statePath: string;
|
|
989
|
+
state: LegacyParentDaemonState;
|
|
990
|
+
now: number;
|
|
991
|
+
pidAlive: (pid: number) => boolean;
|
|
992
|
+
pidIncarnation: (pid: number) => string | undefined;
|
|
993
|
+
tokenFingerprint: string;
|
|
994
|
+
chatId: string;
|
|
995
|
+
}): Promise<
|
|
996
|
+
{ acquired: false; attached: false; provisional?: boolean; reloadRequired?: boolean; blocked?: boolean } | undefined
|
|
997
|
+
> {
|
|
998
|
+
const { state } = input;
|
|
999
|
+
if (!input.pidAlive(state.pid)) return undefined;
|
|
1000
|
+
if (!ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
|
|
1001
|
+
return { acquired: false, attached: false, blocked: true };
|
|
1002
|
+
const incarnation = input.pidIncarnation(state.pid);
|
|
1003
|
+
if (!isProcessIncarnation(incarnation)) return { acquired: false, attached: false, blocked: true };
|
|
1004
|
+
const attestationPath = legacyMigrationAttestationPath(input.statePath);
|
|
1005
|
+
const previous = await readJson<LegacyMigrationAttestation>(input.fs, attestationPath);
|
|
1006
|
+
const attested = Boolean(
|
|
1007
|
+
previous &&
|
|
1008
|
+
Number.isSafeInteger(previous.pid) &&
|
|
1009
|
+
isProcessIncarnation(previous.incarnation) &&
|
|
1010
|
+
Number.isSafeInteger(previous.heartbeatAt) &&
|
|
1011
|
+
Number.isSafeInteger(previous.observedAt) &&
|
|
1012
|
+
previous.pid === state.pid &&
|
|
1013
|
+
previous.incarnation === incarnation &&
|
|
1014
|
+
state.heartbeatAt > previous.heartbeatAt &&
|
|
1015
|
+
input.now - previous.observedAt <= HEARTBEAT_TTL_MS,
|
|
1016
|
+
);
|
|
1017
|
+
await writeJsonAtomic(input.fs, attestationPath, {
|
|
1018
|
+
pid: state.pid,
|
|
1019
|
+
incarnation,
|
|
1020
|
+
heartbeatAt: state.heartbeatAt,
|
|
1021
|
+
observedAt: input.now,
|
|
1022
|
+
} satisfies LegacyMigrationAttestation);
|
|
1023
|
+
return attested
|
|
1024
|
+
? { acquired: false, attached: false, reloadRequired: true }
|
|
1025
|
+
: { acquired: false, attached: false, provisional: true };
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function isRecognizedLegacyGeneration(generation: number | undefined): boolean {
|
|
1029
|
+
return (
|
|
1030
|
+
generation === undefined || (Number.isSafeInteger(generation) && generation > 0 && generation < DAEMON_GENERATION)
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Classifies a physically live daemon record before any replacement action.
|
|
1036
|
+
*
|
|
1037
|
+
* A canonical, differing incarnation is the only authoritative proof that a
|
|
1038
|
+
* live PID is no longer the recorded owner. This applies equally to matching
|
|
1039
|
+
* and foreign Telegram identities: identity never substitutes for provenance.
|
|
1040
|
+
*/
|
|
1041
|
+
function classifyForeignLiveOwner(input: {
|
|
1042
|
+
state: unknown;
|
|
1043
|
+
tokenFingerprint: string;
|
|
1044
|
+
chatId: string;
|
|
1045
|
+
pidAlive: (pid: number) => boolean;
|
|
1046
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1047
|
+
}): "identity_mismatch" | "ambiguous" | undefined {
|
|
1048
|
+
const state = input.state as Partial<DaemonState> | undefined;
|
|
1049
|
+
// Validate before probing so malformed PIDs never reach the liveness source.
|
|
1050
|
+
if (!state || !validDaemonPid(state.pid) || !input.pidAlive(state.pid)) return undefined;
|
|
1051
|
+
// A stopped tombstone with a canonical acquisition is not a live owner.
|
|
1052
|
+
if (isStoppedDaemonState(state) || isLegacyParentDaemonState(state)) return undefined;
|
|
1053
|
+
const currentIncarnation = input.pidIncarnation?.(state.pid) ?? defaultPidIncarnation(state.pid);
|
|
1054
|
+
if (
|
|
1055
|
+
!hasSafeDaemonStateShape(state) ||
|
|
1056
|
+
!isProcessIncarnation(state.incarnation) ||
|
|
1057
|
+
!isProcessIncarnation(currentIncarnation)
|
|
1058
|
+
)
|
|
1059
|
+
return "ambiguous";
|
|
1060
|
+
if (currentIncarnation !== state.incarnation) return undefined;
|
|
1061
|
+
return ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) ? undefined : "identity_mismatch";
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/** True for a physically live owner with this configuration, including legacy generations. */
|
|
1065
|
+
export function isPhysicalMatchingOwner(input: {
|
|
532
1066
|
state: DaemonState | undefined;
|
|
533
1067
|
tokenFingerprint: string;
|
|
534
1068
|
chatId: string;
|
|
535
1069
|
pidAlive: (pid: number) => boolean;
|
|
1070
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
536
1071
|
}): boolean {
|
|
537
1072
|
const { state } = input;
|
|
538
1073
|
return Boolean(
|
|
539
1074
|
state &&
|
|
540
|
-
state
|
|
1075
|
+
hasSafeDaemonStateShape(state) &&
|
|
541
1076
|
state.stoppedAt === undefined &&
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
1077
|
+
ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
|
|
1078
|
+
input.pidAlive(state.pid) &&
|
|
1079
|
+
ownerProvenanceMatches(state, input.pidIncarnation),
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** True only for a signalable legacy owner or this generation's ready bound child. */
|
|
1084
|
+
export function isSignalableMatchingOwner(input: {
|
|
1085
|
+
state: DaemonState | undefined;
|
|
1086
|
+
tokenFingerprint: string;
|
|
1087
|
+
chatId: string;
|
|
1088
|
+
pidAlive: (pid: number) => boolean;
|
|
1089
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1090
|
+
}): boolean {
|
|
1091
|
+
const { state } = input;
|
|
1092
|
+
return Boolean(
|
|
1093
|
+
isPhysicalMatchingOwner(input) &&
|
|
1094
|
+
(isRecognizedLegacyGeneration(state?.generation) ||
|
|
1095
|
+
(state?.generation === DAEMON_GENERATION &&
|
|
1096
|
+
state.ownershipPhase === "ready" &&
|
|
1097
|
+
typeof state.acquisitionId === "string" &&
|
|
1098
|
+
state.acquisitionId.length > 0)),
|
|
545
1099
|
);
|
|
546
1100
|
}
|
|
547
1101
|
|
|
@@ -551,16 +1105,37 @@ export function isFreshLiveOwner(input: {
|
|
|
551
1105
|
tokenFingerprint: string;
|
|
552
1106
|
chatId: string;
|
|
553
1107
|
pidAlive: (pid: number) => boolean;
|
|
1108
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
554
1109
|
}): boolean {
|
|
555
1110
|
const { state } = input;
|
|
556
1111
|
return Boolean(
|
|
557
1112
|
state &&
|
|
558
|
-
state
|
|
1113
|
+
hasSafeDaemonStateShape(state) &&
|
|
559
1114
|
state.stoppedAt === undefined &&
|
|
560
1115
|
ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
|
|
561
1116
|
input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
|
|
562
|
-
|
|
563
|
-
|
|
1117
|
+
input.pidAlive(state.pid) &&
|
|
1118
|
+
ownerProvenanceMatches(state, input.pidIncarnation),
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** True only when a physically live matching owner can serve this build's daemon lifecycle contract. */
|
|
1123
|
+
export function isCurrentCompatibleOwner(input: {
|
|
1124
|
+
state: DaemonState | undefined;
|
|
1125
|
+
now: number;
|
|
1126
|
+
tokenFingerprint: string;
|
|
1127
|
+
chatId: string;
|
|
1128
|
+
pidAlive: (pid: number) => boolean;
|
|
1129
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1130
|
+
}): boolean {
|
|
1131
|
+
const state = input.state;
|
|
1132
|
+
return Boolean(
|
|
1133
|
+
isFreshLiveOwner(input) &&
|
|
1134
|
+
state?.ownershipPhase === "ready" &&
|
|
1135
|
+
typeof state.acquisitionId === "string" &&
|
|
1136
|
+
state.acquisitionId.length > 0 &&
|
|
1137
|
+
Number.isSafeInteger(state.generation) &&
|
|
1138
|
+
(state.generation as number) >= DAEMON_GENERATION,
|
|
564
1139
|
);
|
|
565
1140
|
}
|
|
566
1141
|
|
|
@@ -573,6 +1148,7 @@ export async function acquireDaemonOwnership(input: {
|
|
|
573
1148
|
now?: () => number;
|
|
574
1149
|
pid?: number;
|
|
575
1150
|
pidAlive?: (pid: number) => boolean;
|
|
1151
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
576
1152
|
randomId?: () => string;
|
|
577
1153
|
/** Permit one Windows source launcher PID to daemon PID handoff. */
|
|
578
1154
|
allowPidRebind?: boolean;
|
|
@@ -581,8 +1157,10 @@ export async function acquireDaemonOwnership(input: {
|
|
|
581
1157
|
}): Promise<{
|
|
582
1158
|
acquired: boolean;
|
|
583
1159
|
ownerId?: string;
|
|
1160
|
+
acquisitionId?: string;
|
|
584
1161
|
attached?: boolean;
|
|
585
1162
|
blocked?: boolean;
|
|
1163
|
+
provisional?: boolean;
|
|
586
1164
|
reason?: "identity_mismatch";
|
|
587
1165
|
reloadRequired?: boolean;
|
|
588
1166
|
}> {
|
|
@@ -591,190 +1169,672 @@ export async function acquireDaemonOwnership(input: {
|
|
|
591
1169
|
const pid = input.pid ?? process.pid;
|
|
592
1170
|
if (!validDaemonPid(pid)) return { acquired: false, attached: true };
|
|
593
1171
|
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
1172
|
+
const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
|
|
1173
|
+
const incarnation = pidIncarnation(pid);
|
|
1174
|
+
if (!isProcessIncarnation(incarnation)) return { acquired: false, attached: false, provisional: true };
|
|
594
1175
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
595
1176
|
await ensureDir(fsImpl, paths.dir);
|
|
596
1177
|
const ownerId =
|
|
597
1178
|
input.ownerId ?? input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
598
1179
|
const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
1180
|
+
|
|
1181
|
+
// A fresh, identity-matching live owner running an OLDER generation than this
|
|
1182
|
+
// build cannot serve our newer wire frames; signal a reload instead of a
|
|
1183
|
+
// silent attach. Newer/equal generations attach as before (no downgrade).
|
|
1184
|
+
const attachDecision = (
|
|
1185
|
+
state: DaemonState | undefined,
|
|
1186
|
+
):
|
|
1187
|
+
| { acquired: false; attached: boolean; blocked?: boolean; provisional?: boolean; reloadRequired?: boolean }
|
|
1188
|
+
| undefined => {
|
|
1189
|
+
if (state && !hasSafeDaemonStateShape(state)) {
|
|
1190
|
+
const malformed = state as Partial<DaemonState>;
|
|
610
1191
|
if (
|
|
611
|
-
!
|
|
612
|
-
|
|
613
|
-
|
|
1192
|
+
!isStoppedDaemonState(malformed) &&
|
|
1193
|
+
validDaemonPid(malformed.pid) &&
|
|
1194
|
+
typeof malformed.incarnation === "string" &&
|
|
1195
|
+
pidAlive(malformed.pid)
|
|
614
1196
|
)
|
|
615
|
-
return true;
|
|
616
|
-
return
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1197
|
+
return { acquired: false, attached: false, blocked: true };
|
|
1198
|
+
return undefined;
|
|
1199
|
+
}
|
|
1200
|
+
if (!state || state.stoppedAt !== undefined || !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
|
|
1201
|
+
return undefined;
|
|
1202
|
+
// Unavailable provenance is ambiguous and must remain fail-closed. A
|
|
1203
|
+
// different authoritative incarnation proves PID reuse, so allow only the
|
|
1204
|
+
// transition-locked path below to reclaim the stale owner artifacts.
|
|
1205
|
+
if (pidAlive(state.pid)) {
|
|
1206
|
+
const currentIncarnation = pidIncarnation(state.pid);
|
|
1207
|
+
if (!isProcessIncarnation(currentIncarnation) || !isProcessIncarnation(state.incarnation))
|
|
1208
|
+
return { acquired: false, attached: false, blocked: true };
|
|
1209
|
+
if (currentIncarnation !== state.incarnation) return undefined;
|
|
1210
|
+
}
|
|
1211
|
+
if (!pidAlive(state.pid)) return undefined;
|
|
1212
|
+
if (
|
|
1213
|
+
isCurrentCompatibleOwner({
|
|
1214
|
+
state,
|
|
1215
|
+
now: now(),
|
|
628
1216
|
tokenFingerprint: input.tokenFingerprint,
|
|
629
1217
|
chatId: input.chatId,
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
state
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
1218
|
+
pidAlive,
|
|
1219
|
+
pidIncarnation,
|
|
1220
|
+
})
|
|
1221
|
+
)
|
|
1222
|
+
return { acquired: false, attached: true };
|
|
1223
|
+
// A physical owner that cannot prove current compatibility is never safe to
|
|
1224
|
+
// attach. Legacy, stale, and malformed generations require a handoff;
|
|
1225
|
+
// otherwise retain the explicit provisional classification.
|
|
1226
|
+
if (
|
|
1227
|
+
state.version !== DAEMON_VERSION ||
|
|
1228
|
+
!isFreshLiveOwner({
|
|
1229
|
+
state,
|
|
1230
|
+
now: now(),
|
|
1231
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1232
|
+
chatId: input.chatId,
|
|
1233
|
+
pidAlive,
|
|
1234
|
+
pidIncarnation,
|
|
1235
|
+
}) ||
|
|
1236
|
+
isRecognizedLegacyGeneration(state.generation)
|
|
1237
|
+
)
|
|
1238
|
+
return { acquired: false, attached: false, reloadRequired: true };
|
|
1239
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1240
|
+
};
|
|
1241
|
+
const existing = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1242
|
+
const foreignOwner = classifyForeignLiveOwner({
|
|
1243
|
+
state: existing,
|
|
1244
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1245
|
+
chatId: input.chatId,
|
|
1246
|
+
pidAlive,
|
|
1247
|
+
pidIncarnation,
|
|
1248
|
+
});
|
|
1249
|
+
if (foreignOwner) {
|
|
1250
|
+
return foreignOwner === "identity_mismatch"
|
|
1251
|
+
? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
|
|
1252
|
+
: { acquired: false, attached: false, blocked: true };
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const transition = await acquireTransitionLock({ fs: fsImpl, path: paths.steal, pidAlive, pidIncarnation });
|
|
1256
|
+
if (!transition) return { acquired: false, attached: false, provisional: true };
|
|
1257
|
+
try {
|
|
1258
|
+
const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1259
|
+
const recheckedLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
1260
|
+
const recheckedDecision = isLegacyParentDaemonState(rechecked) ? undefined : attachDecision(rechecked);
|
|
1261
|
+
if (
|
|
1262
|
+
recheckedDecision &&
|
|
1263
|
+
(recheckedDecision.attached ||
|
|
1264
|
+
recheckedDecision.blocked ||
|
|
1265
|
+
recheckedLock.kind === "missing" ||
|
|
1266
|
+
ownershipLockMatchesState(recheckedLock, rechecked))
|
|
1267
|
+
)
|
|
1268
|
+
return recheckedDecision;
|
|
1269
|
+
// A stopped tombstone authorizes removal only of its own canonical lock.
|
|
1270
|
+
// A newer initializer may have replaced the pathname while that tombstone
|
|
1271
|
+
// remained, and must receive the same liveness/freshness protection as any
|
|
1272
|
+
// other live reservation.
|
|
1273
|
+
const stoppedLockMatches = ownershipLockMatchesStoppedState(recheckedLock, rechecked);
|
|
1274
|
+
const lockDecision = stoppedLockMatches
|
|
1275
|
+
? undefined
|
|
1276
|
+
: liveOwnershipLockDecision({ lock: recheckedLock, pidAlive, pidIncarnation });
|
|
1277
|
+
if (lockDecision) return lockDecision;
|
|
1278
|
+
if (
|
|
1279
|
+
!stoppedLockMatches &&
|
|
1280
|
+
!(await ownershipLockIsReclaimable({
|
|
1281
|
+
fs: fsImpl,
|
|
1282
|
+
path: paths.lock,
|
|
1283
|
+
lock: recheckedLock,
|
|
1284
|
+
now: now(),
|
|
1285
|
+
pidAlive,
|
|
1286
|
+
pidIncarnation,
|
|
1287
|
+
}))
|
|
1288
|
+
)
|
|
1289
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1290
|
+
if (isLegacyParentDaemonState(rechecked)) {
|
|
1291
|
+
const legacyDecision = await legacyParentHandoffDecision({
|
|
1292
|
+
fs: fsImpl,
|
|
1293
|
+
statePath: paths.state,
|
|
1294
|
+
state: rechecked,
|
|
1295
|
+
now: now(),
|
|
1296
|
+
pidAlive,
|
|
1297
|
+
pidIncarnation,
|
|
1298
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1299
|
+
chatId: input.chatId,
|
|
1300
|
+
});
|
|
1301
|
+
if (legacyDecision) return legacyDecision;
|
|
1302
|
+
} else if (recheckedDecision) {
|
|
1303
|
+
return recheckedDecision;
|
|
1304
|
+
}
|
|
1305
|
+
const recheckedForeignOwner = classifyForeignLiveOwner({
|
|
1306
|
+
state: rechecked,
|
|
1307
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1308
|
+
chatId: input.chatId,
|
|
1309
|
+
pidAlive,
|
|
1310
|
+
pidIncarnation,
|
|
1311
|
+
});
|
|
1312
|
+
if (recheckedForeignOwner) {
|
|
1313
|
+
return recheckedForeignOwner === "identity_mismatch"
|
|
1314
|
+
? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
|
|
1315
|
+
: { acquired: false, attached: false, blocked: true };
|
|
1316
|
+
}
|
|
1317
|
+
if (
|
|
1318
|
+
hasSafeDaemonStateShape(rechecked) &&
|
|
1319
|
+
rechecked.stoppedAt === undefined &&
|
|
1320
|
+
pidAlive(rechecked.pid) &&
|
|
1321
|
+
ownerProvenanceMatches(rechecked, pidIncarnation)
|
|
1322
|
+
)
|
|
1323
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1324
|
+
const currentLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
1325
|
+
if (!ownershipLockMatches(recheckedLock, currentLock))
|
|
1326
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1327
|
+
if (currentLock.kind !== "missing") {
|
|
1328
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
|
|
1329
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1330
|
+
await fsImpl.unlink(paths.lock);
|
|
1331
|
+
}
|
|
1332
|
+
const ownershipLock: OwnershipLockMetadata = {
|
|
1333
|
+
pid,
|
|
1334
|
+
incarnation,
|
|
1335
|
+
ownerId,
|
|
1336
|
+
acquisitionId: ownerId,
|
|
1337
|
+
startedAt: now(),
|
|
655
1338
|
};
|
|
656
|
-
|
|
657
|
-
|
|
1339
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
|
|
1340
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1341
|
+
if (!(await tryCreateOwnershipLock(fsImpl, paths.lock, ownershipLock)))
|
|
1342
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1343
|
+
if (
|
|
1344
|
+
!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })) ||
|
|
1345
|
+
!ownershipLockMatchesMetadata(await readOwnershipLock(fsImpl, paths.lock), ownershipLock)
|
|
1346
|
+
)
|
|
1347
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1348
|
+
await writeJsonAtomic(fsImpl, paths.state, {
|
|
1349
|
+
pid,
|
|
1350
|
+
incarnation,
|
|
1351
|
+
ownerId,
|
|
1352
|
+
acquisitionId: ownerId,
|
|
1353
|
+
ownershipPhase: "provisional",
|
|
1354
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1355
|
+
chatId: input.chatId,
|
|
1356
|
+
startedAt: now(),
|
|
1357
|
+
heartbeatAt: now(),
|
|
1358
|
+
roots,
|
|
1359
|
+
version: DAEMON_VERSION,
|
|
1360
|
+
generation: DAEMON_GENERATION,
|
|
1361
|
+
} satisfies DaemonState);
|
|
1362
|
+
if (
|
|
1363
|
+
!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })) ||
|
|
1364
|
+
!ownershipLockMatchesMetadata(await readOwnershipLock(fsImpl, paths.lock), ownershipLock)
|
|
1365
|
+
)
|
|
1366
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1367
|
+
return { acquired: true, ownerId, acquisitionId: ownerId };
|
|
1368
|
+
} finally {
|
|
1369
|
+
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
export async function renewDaemonHeartbeat(input: {
|
|
1374
|
+
settings: Settings;
|
|
1375
|
+
ownerId: string;
|
|
1376
|
+
acquisitionId?: string;
|
|
1377
|
+
tokenFingerprint?: string;
|
|
1378
|
+
chatId?: string;
|
|
1379
|
+
fs?: TelegramDaemonFs;
|
|
1380
|
+
now?: () => number;
|
|
1381
|
+
pid?: number;
|
|
1382
|
+
generation?: number;
|
|
1383
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1384
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1385
|
+
stealRetries?: number;
|
|
1386
|
+
stealRetryDelayMs?: number;
|
|
1387
|
+
}): Promise<boolean> {
|
|
1388
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
1389
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
1390
|
+
const acquisitionId = input.acquisitionId ?? input.ownerId;
|
|
1391
|
+
// The steal lock is held only briefly by concurrent lifecycle operations.
|
|
1392
|
+
// A contended lock never proves readiness: only the holder may validate and
|
|
1393
|
+
// publish the exact ready PID/generation state.
|
|
1394
|
+
const transition = await acquireTransitionLock({
|
|
1395
|
+
fs: fsImpl,
|
|
1396
|
+
path: paths.steal,
|
|
1397
|
+
pidIncarnation: input.pidIncarnation,
|
|
1398
|
+
sleep: input.sleep,
|
|
1399
|
+
retries: input.stealRetries,
|
|
1400
|
+
retryDelayMs: input.stealRetryDelayMs,
|
|
1401
|
+
});
|
|
1402
|
+
if (!transition) return false;
|
|
1403
|
+
try {
|
|
1404
|
+
const state = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1405
|
+
const pid = input.pid ?? state?.pid;
|
|
1406
|
+
const generation = input.generation ?? state?.generation;
|
|
1407
|
+
const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(pid ?? 0);
|
|
1408
|
+
// A daemon child may atomically bind its own PID only while its launcher
|
|
1409
|
+
// reservation is still provisional and carries the same acquisition secret.
|
|
1410
|
+
const canBindProvisionalPid =
|
|
1411
|
+
state?.ownershipPhase === "provisional" &&
|
|
1412
|
+
state.pid !== pid &&
|
|
1413
|
+
state.ownerId === input.ownerId &&
|
|
1414
|
+
state.acquisitionId === acquisitionId;
|
|
1415
|
+
if (
|
|
1416
|
+
!state ||
|
|
1417
|
+
!hasSafeDaemonStateShape(state) ||
|
|
1418
|
+
typeof pid !== "number" ||
|
|
1419
|
+
!Number.isSafeInteger(pid) ||
|
|
1420
|
+
pid <= 0 ||
|
|
1421
|
+
!isProcessIncarnation(incarnation) ||
|
|
1422
|
+
generation !== DAEMON_GENERATION ||
|
|
1423
|
+
state.ownerId !== input.ownerId ||
|
|
1424
|
+
state.acquisitionId !== acquisitionId ||
|
|
1425
|
+
(input.tokenFingerprint !== undefined && state.tokenFingerprint !== input.tokenFingerprint) ||
|
|
1426
|
+
(input.chatId !== undefined && state.chatId !== input.chatId) ||
|
|
1427
|
+
(!canBindProvisionalPid && state.incarnation !== incarnation) ||
|
|
1428
|
+
(!canBindProvisionalPid && state.pid !== pid) ||
|
|
1429
|
+
state.generation !== generation ||
|
|
1430
|
+
state.stoppedAt !== undefined ||
|
|
1431
|
+
state.ownershipPhase === "retired"
|
|
1432
|
+
)
|
|
1433
|
+
return false;
|
|
1434
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
|
|
1435
|
+
const previousLock = canBindProvisionalPid ? await readOwnershipLock(fsImpl, paths.lock) : undefined;
|
|
1436
|
+
const expectedLock =
|
|
1437
|
+
previousLock?.kind === "valid" &&
|
|
1438
|
+
previousLock.metadata.pid === state.pid &&
|
|
1439
|
+
previousLock.metadata.incarnation === state.incarnation &&
|
|
1440
|
+
previousLock.metadata.ownerId === state.ownerId &&
|
|
1441
|
+
previousLock.metadata.acquisitionId === state.acquisitionId
|
|
1442
|
+
? previousLock.metadata
|
|
1443
|
+
: undefined;
|
|
1444
|
+
const reboundLock = expectedLock ? { ...expectedLock, pid, incarnation } : undefined;
|
|
1445
|
+
if (
|
|
1446
|
+
canBindProvisionalPid &&
|
|
1447
|
+
(!expectedLock ||
|
|
1448
|
+
!reboundLock ||
|
|
1449
|
+
!(await rebindOwnershipLock({
|
|
1450
|
+
fs: fsImpl,
|
|
1451
|
+
path: paths.lock,
|
|
1452
|
+
transitionPath: paths.steal,
|
|
1453
|
+
transition,
|
|
1454
|
+
|
|
1455
|
+
expected: expectedLock,
|
|
1456
|
+
rebound: reboundLock,
|
|
1457
|
+
})))
|
|
1458
|
+
)
|
|
1459
|
+
return false;
|
|
1460
|
+
try {
|
|
1461
|
+
await writeJsonAtomic(fsImpl, paths.state, {
|
|
1462
|
+
...state,
|
|
1463
|
+
// Preserve the source launcher PID so concurrent ensures can recognize
|
|
1464
|
+
// this ready PID as its child rather than excluding it as the launcher.
|
|
1465
|
+
launcherPid: state.pid,
|
|
1466
|
+
pid,
|
|
1467
|
+
incarnation,
|
|
1468
|
+
ownershipPhase: "ready",
|
|
1469
|
+
heartbeatAt: (input.now ?? Date.now)(),
|
|
1470
|
+
});
|
|
1471
|
+
} catch {
|
|
1472
|
+
if (expectedLock && reboundLock)
|
|
1473
|
+
await rollbackOwnershipLockRebind({
|
|
1474
|
+
fs: fsImpl,
|
|
1475
|
+
path: paths.lock,
|
|
1476
|
+
transitionPath: paths.steal,
|
|
1477
|
+
transition,
|
|
1478
|
+
|
|
1479
|
+
previous: expectedLock,
|
|
1480
|
+
rebound: reboundLock,
|
|
1481
|
+
});
|
|
1482
|
+
return false;
|
|
1483
|
+
}
|
|
1484
|
+
return true;
|
|
1485
|
+
} finally {
|
|
1486
|
+
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/** Acquire the lifecycle transition lock with bounded retry for bind/retire races. */
|
|
1491
|
+
export async function acquireTransitionLock(input: {
|
|
1492
|
+
fs: TelegramDaemonFs;
|
|
1493
|
+
path: string;
|
|
1494
|
+
pidAlive?: (pid: number) => boolean;
|
|
1495
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1496
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1497
|
+
retries?: number;
|
|
1498
|
+
retryDelayMs?: number;
|
|
1499
|
+
}): Promise<DaemonTransitionLock | undefined> {
|
|
1500
|
+
return await acquireDaemonTransitionLock({
|
|
1501
|
+
fs: input.fs,
|
|
1502
|
+
path: input.path,
|
|
1503
|
+
pid: process.pid,
|
|
1504
|
+
pidAlive: input.pidAlive ?? defaultPidAlive,
|
|
1505
|
+
pidIncarnation: input.pidIncarnation ?? defaultPidIncarnation,
|
|
1506
|
+
sleep: input.sleep,
|
|
1507
|
+
retries: input.retries,
|
|
1508
|
+
retryDelayMs: input.retryDelayMs,
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/** Retire only the unchanged provisional acquisition after bounded readiness fails. */
|
|
1513
|
+
export async function retireProvisionalDaemonOwnership(input: {
|
|
1514
|
+
settings: Settings;
|
|
1515
|
+
ownerId: string;
|
|
1516
|
+
acquisitionId?: string;
|
|
1517
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1518
|
+
pidAlive?: (pid: number) => boolean;
|
|
1519
|
+
/** Detached child PID, when the launcher successfully reported it. */
|
|
1520
|
+
pid: number;
|
|
1521
|
+
/** PID which created the provisional reservation before the child was bound. */
|
|
1522
|
+
launcherPid?: number;
|
|
1523
|
+
fs?: TelegramDaemonFs;
|
|
1524
|
+
now?: () => number;
|
|
1525
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1526
|
+
stealRetries?: number;
|
|
1527
|
+
stealRetryDelayMs?: number;
|
|
1528
|
+
/** Only no-child confirmation may retire a ready-like launcher publication. */
|
|
1529
|
+
allowReadyWithoutChildPid?: boolean;
|
|
1530
|
+
}): Promise<boolean> {
|
|
1531
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
1532
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
1533
|
+
const transition = await acquireTransitionLock({
|
|
1534
|
+
fs: fsImpl,
|
|
1535
|
+
path: paths.steal,
|
|
1536
|
+
pidAlive: input.pidAlive,
|
|
1537
|
+
pidIncarnation: input.pidIncarnation,
|
|
1538
|
+
sleep: input.sleep,
|
|
1539
|
+
retries: input.stealRetries,
|
|
1540
|
+
retryDelayMs: input.stealRetryDelayMs,
|
|
1541
|
+
});
|
|
1542
|
+
if (!transition) return false;
|
|
1543
|
+
try {
|
|
1544
|
+
const state = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1545
|
+
const acquisitionId = input.acquisitionId ?? input.ownerId;
|
|
1546
|
+
const expectedPid = state?.pid === input.pid ? input.pid : input.launcherPid;
|
|
1547
|
+
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
1548
|
+
const expectedPidAlive = expectedPid !== undefined && pidAlive(expectedPid);
|
|
1549
|
+
const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(expectedPid ?? 0);
|
|
1550
|
+
if (
|
|
1551
|
+
!state ||
|
|
1552
|
+
state.ownerId !== input.ownerId ||
|
|
1553
|
+
state.acquisitionId !== acquisitionId ||
|
|
1554
|
+
(expectedPidAlive &&
|
|
1555
|
+
(!isProcessIncarnation(incarnation) ||
|
|
1556
|
+
!isProcessIncarnation(state.incarnation) ||
|
|
1557
|
+
state.incarnation !== incarnation)) ||
|
|
1558
|
+
(state.pid !== input.pid && state.pid !== input.launcherPid) ||
|
|
1559
|
+
state.generation !== DAEMON_GENERATION ||
|
|
1560
|
+
!Number.isSafeInteger(state.generation) ||
|
|
1561
|
+
(state.ownershipPhase !== "provisional" &&
|
|
1562
|
+
!(input.allowReadyWithoutChildPid === true && state.ownershipPhase === "ready"))
|
|
1563
|
+
)
|
|
1564
|
+
return false;
|
|
1565
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
|
|
1566
|
+
await writeJsonAtomic(fsImpl, paths.state, {
|
|
1567
|
+
...state,
|
|
1568
|
+
ownershipPhase: "retired",
|
|
1569
|
+
stoppedAt: (input.now ?? Date.now)(),
|
|
1570
|
+
});
|
|
1571
|
+
if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
|
|
1572
|
+
await fsImpl.unlink(paths.lock).catch(() => undefined);
|
|
1573
|
+
return true;
|
|
1574
|
+
} finally {
|
|
1575
|
+
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/** Bind a launcher-reserved provisional acquisition to its actual detached child. */
|
|
1580
|
+
async function bindProvisionalDaemonPid(input: {
|
|
1581
|
+
settings: Settings;
|
|
1582
|
+
ownerId: string;
|
|
1583
|
+
acquisitionId: string;
|
|
1584
|
+
pid: number;
|
|
1585
|
+
incarnation: string;
|
|
1586
|
+
fs?: TelegramDaemonFs;
|
|
1587
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1588
|
+
stealRetries?: number;
|
|
1589
|
+
stealRetryDelayMs?: number;
|
|
1590
|
+
}): Promise<boolean> {
|
|
1591
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
1592
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
1593
|
+
const transition = await acquireTransitionLock({
|
|
1594
|
+
fs: fsImpl,
|
|
1595
|
+
path: paths.steal,
|
|
1596
|
+
sleep: input.sleep,
|
|
1597
|
+
retries: input.stealRetries,
|
|
1598
|
+
retryDelayMs: input.stealRetryDelayMs,
|
|
1599
|
+
});
|
|
1600
|
+
if (!transition) return false;
|
|
1601
|
+
try {
|
|
1602
|
+
const state = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1603
|
+
if (
|
|
1604
|
+
!state ||
|
|
1605
|
+
state.ownerId !== input.ownerId ||
|
|
1606
|
+
state.acquisitionId !== input.acquisitionId ||
|
|
1607
|
+
state.ownershipPhase !== "provisional" ||
|
|
1608
|
+
state.generation !== DAEMON_GENERATION ||
|
|
1609
|
+
!isProcessIncarnation(state.incarnation) ||
|
|
1610
|
+
!isProcessIncarnation(input.incarnation)
|
|
1611
|
+
)
|
|
1612
|
+
return false;
|
|
1613
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
|
|
1614
|
+
const previousLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
1615
|
+
if (
|
|
1616
|
+
previousLock.kind !== "valid" ||
|
|
1617
|
+
previousLock.metadata.pid !== state.pid ||
|
|
1618
|
+
previousLock.metadata.incarnation !== state.incarnation ||
|
|
1619
|
+
previousLock.metadata.ownerId !== state.ownerId ||
|
|
1620
|
+
previousLock.metadata.acquisitionId !== state.acquisitionId
|
|
1621
|
+
)
|
|
1622
|
+
return false;
|
|
1623
|
+
const reboundLock = { ...previousLock.metadata, pid: input.pid, incarnation: input.incarnation };
|
|
1624
|
+
if (
|
|
1625
|
+
!(await rebindOwnershipLock({
|
|
1626
|
+
fs: fsImpl,
|
|
1627
|
+
path: paths.lock,
|
|
1628
|
+
transitionPath: paths.steal,
|
|
1629
|
+
transition,
|
|
1630
|
+
|
|
1631
|
+
expected: previousLock.metadata,
|
|
1632
|
+
rebound: reboundLock,
|
|
1633
|
+
}))
|
|
1634
|
+
)
|
|
1635
|
+
return false;
|
|
1636
|
+
try {
|
|
1637
|
+
await writeJsonAtomic(fsImpl, paths.state, {
|
|
1638
|
+
...state,
|
|
1639
|
+
// This durable marker distinguishes a launcher reservation from a PID
|
|
1640
|
+
// the launcher authoritatively rebound to its child.
|
|
1641
|
+
launcherPid: state.launcherPid ?? state.pid,
|
|
1642
|
+
pid: input.pid,
|
|
1643
|
+
incarnation: input.incarnation,
|
|
1644
|
+
});
|
|
1645
|
+
} catch {
|
|
1646
|
+
await rollbackOwnershipLockRebind({
|
|
1647
|
+
fs: fsImpl,
|
|
1648
|
+
path: paths.lock,
|
|
1649
|
+
transitionPath: paths.steal,
|
|
1650
|
+
transition,
|
|
1651
|
+
previous: previousLock.metadata,
|
|
1652
|
+
rebound: reboundLock,
|
|
1653
|
+
});
|
|
1654
|
+
return false;
|
|
1655
|
+
}
|
|
1656
|
+
return true;
|
|
1657
|
+
} finally {
|
|
1658
|
+
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/** Wait for a matching current-generation daemon to publish a ready state. */
|
|
1663
|
+
export async function waitForTelegramDaemonReady(input: {
|
|
1664
|
+
settings: Settings;
|
|
1665
|
+
ownerId?: string;
|
|
1666
|
+
acquisitionId?: string;
|
|
1667
|
+
pid?: number;
|
|
1668
|
+
excludedPid?: number;
|
|
1669
|
+
tokenFingerprint: string;
|
|
1670
|
+
chatId: string;
|
|
1671
|
+
fs?: TelegramDaemonFs;
|
|
1672
|
+
now?: () => number;
|
|
1673
|
+
pidAlive?: (pid: number) => boolean;
|
|
1674
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1675
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1676
|
+
waitStepMs?: number;
|
|
1677
|
+
timeoutMs?: number;
|
|
1678
|
+
}): Promise<boolean> {
|
|
1679
|
+
const now = input.now ?? Date.now;
|
|
1680
|
+
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
1681
|
+
const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
|
|
1682
|
+
const sleep = input.sleep ?? (async (ms: number) => await Bun.sleep(ms));
|
|
1683
|
+
const timeoutMs = Math.max(input.timeoutMs ?? 8_000, 0);
|
|
1684
|
+
const waitStepMs = Math.max(input.waitStepMs ?? 25, 1);
|
|
1685
|
+
const deadline = now() + timeoutMs;
|
|
1686
|
+
const maxPolls = Math.ceil(timeoutMs / waitStepMs);
|
|
1687
|
+
for (let poll = 0; poll <= maxPolls; poll++) {
|
|
1688
|
+
const state = await readDaemonState(input.settings, input.fs);
|
|
1689
|
+
if (
|
|
1690
|
+
(!input.ownerId || state?.ownerId === input.ownerId) &&
|
|
1691
|
+
(!input.acquisitionId || state?.acquisitionId === input.acquisitionId) &&
|
|
1692
|
+
state?.ownershipPhase === "ready" &&
|
|
1693
|
+
state.generation === DAEMON_GENERATION &&
|
|
1694
|
+
ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
|
|
1695
|
+
ownerProvenanceMatches(state, pidIncarnation) &&
|
|
1696
|
+
pidAlive(state.pid) &&
|
|
1697
|
+
(Number.isSafeInteger(input.pid) && (input.pid as number) > 0
|
|
1698
|
+
? state.pid === input.pid
|
|
1699
|
+
: !Number.isSafeInteger(input.excludedPid) || state.pid !== input.excludedPid) &&
|
|
1700
|
+
isCurrentCompatibleOwner({
|
|
658
1701
|
state,
|
|
1702
|
+
now: now(),
|
|
659
1703
|
tokenFingerprint: input.tokenFingerprint,
|
|
660
1704
|
chatId: input.chatId,
|
|
661
1705
|
pidAlive,
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
return
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
if (await tryCreateOwnershipLock(fsImpl, paths.lock, { pid, startedAt: createdAt })) {
|
|
671
|
-
await writeState();
|
|
672
|
-
return { acquired: true, ownerId };
|
|
673
|
-
}
|
|
674
|
-
const afterLock = await readJson<DaemonState>(fsImpl, paths.state);
|
|
675
|
-
if (rejectLiveDifferentIdentity(afterLock)) {
|
|
676
|
-
return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
|
|
677
|
-
}
|
|
678
|
-
const afterLockDecision = attachDecision(afterLock);
|
|
679
|
-
if (afterLockDecision) return afterLockDecision;
|
|
680
|
-
if (!afterLock && !(await lockIsPositivelyStale())) return { acquired: false, attached: true };
|
|
681
|
-
if (!(await tryOpenWx(fsImpl, paths.steal))) return { acquired: false, attached: true };
|
|
682
|
-
try {
|
|
683
|
-
const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
|
|
684
|
-
const recheckedDecision = attachDecision(rechecked);
|
|
685
|
-
if (recheckedDecision) return recheckedDecision;
|
|
686
|
-
if (rejectLiveDifferentIdentity(rechecked)) {
|
|
687
|
-
return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
|
|
688
|
-
}
|
|
689
|
-
if (
|
|
690
|
-
rechecked &&
|
|
691
|
-
rechecked.stoppedAt === undefined &&
|
|
692
|
-
validDaemonPid(rechecked.pid) &&
|
|
693
|
-
pidAlive(rechecked.pid)
|
|
694
|
-
) {
|
|
695
|
-
return { acquired: false, attached: true };
|
|
696
|
-
}
|
|
697
|
-
if (!rechecked && !(await lockIsPositivelyStale())) {
|
|
698
|
-
return { acquired: false, attached: true };
|
|
699
|
-
}
|
|
700
|
-
if (!(await ownershipIsPositivelyStale(rechecked))) {
|
|
701
|
-
return { acquired: false, attached: true };
|
|
702
|
-
}
|
|
703
|
-
await fsImpl.unlink(paths.lock).catch(() => undefined);
|
|
704
|
-
if (!(await tryCreateOwnershipLock(fsImpl, paths.lock, { pid, startedAt: createdAt }))) {
|
|
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);
|
|
711
|
-
}
|
|
712
|
-
});
|
|
1706
|
+
pidIncarnation,
|
|
1707
|
+
})
|
|
1708
|
+
)
|
|
1709
|
+
return true;
|
|
1710
|
+
if (now() >= deadline || poll === maxPolls) break;
|
|
1711
|
+
await sleep(waitStepMs);
|
|
1712
|
+
}
|
|
1713
|
+
return false;
|
|
713
1714
|
}
|
|
714
1715
|
|
|
715
|
-
|
|
1716
|
+
/** Confirm the provisional owner or retire only its unchanged acquisition. */
|
|
1717
|
+
export async function confirmTelegramDaemonSpawn(input: {
|
|
716
1718
|
settings: Settings;
|
|
717
|
-
|
|
718
|
-
/** Bind a daemon heartbeat to the Telegram identity that acquired the lock. */
|
|
1719
|
+
spawned: TelegramSpawnOwnerResult;
|
|
719
1720
|
tokenFingerprint: string;
|
|
720
1721
|
chatId: string;
|
|
721
|
-
/** Require the current daemon PID after the launcher-to-daemon PID rebind. */
|
|
722
1722
|
pid: number;
|
|
723
1723
|
fs?: TelegramDaemonFs;
|
|
724
1724
|
now?: () => number;
|
|
1725
|
+
pidAlive?: (pid: number) => boolean;
|
|
1726
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1727
|
+
sleep?: (ms: number) => Promise<void>;
|
|
1728
|
+
waitStepMs?: number;
|
|
1729
|
+
timeoutMs?: number;
|
|
725
1730
|
}): Promise<boolean> {
|
|
726
|
-
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
1731
|
+
if (input.spawned.result !== "owner_spawned") return true;
|
|
1732
|
+
const childPid = input.spawned.acquisition.pid;
|
|
1733
|
+
const hasExactChildPid = Number.isSafeInteger(childPid) && (childPid as number) > 0;
|
|
1734
|
+
const ready = await waitForTelegramDaemonReady({
|
|
1735
|
+
settings: input.settings,
|
|
1736
|
+
ownerId: input.spawned.acquisition.ownerId,
|
|
1737
|
+
acquisitionId: input.spawned.acquisition.acquisitionId,
|
|
1738
|
+
pid: hasExactChildPid ? childPid : undefined,
|
|
1739
|
+
excludedPid: hasExactChildPid ? undefined : input.pid,
|
|
1740
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1741
|
+
chatId: input.chatId,
|
|
1742
|
+
fs: input.fs,
|
|
1743
|
+
now: input.now,
|
|
1744
|
+
pidAlive: input.pidAlive,
|
|
1745
|
+
pidIncarnation: input.pidIncarnation,
|
|
1746
|
+
sleep: input.sleep,
|
|
1747
|
+
waitStepMs: input.waitStepMs,
|
|
1748
|
+
timeoutMs: input.timeoutMs,
|
|
1749
|
+
});
|
|
1750
|
+
if (ready) return true;
|
|
1751
|
+
const launcherPid = input.spawned.acquisition.launcherPid ?? input.pid;
|
|
1752
|
+
const retired = await retireProvisionalDaemonOwnership({
|
|
1753
|
+
settings: input.settings,
|
|
1754
|
+
ownerId: input.spawned.acquisition.ownerId,
|
|
1755
|
+
acquisitionId: input.spawned.acquisition.acquisitionId,
|
|
1756
|
+
pid: hasExactChildPid ? (childPid as number) : launcherPid,
|
|
1757
|
+
launcherPid,
|
|
1758
|
+
allowReadyWithoutChildPid: !hasExactChildPid,
|
|
1759
|
+
fs: input.fs,
|
|
1760
|
+
now: input.now,
|
|
1761
|
+
pidAlive: input.pidAlive,
|
|
1762
|
+
pidIncarnation: input.pidIncarnation,
|
|
1763
|
+
sleep: input.sleep,
|
|
750
1764
|
});
|
|
1765
|
+
if (retired) return false;
|
|
1766
|
+
const state = await readDaemonState(input.settings, input.fs);
|
|
1767
|
+
if (
|
|
1768
|
+
hasExactChildPid &&
|
|
1769
|
+
isCurrentCompatibleOwner({
|
|
1770
|
+
state,
|
|
1771
|
+
now: (input.now ?? Date.now)(),
|
|
1772
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1773
|
+
chatId: input.chatId,
|
|
1774
|
+
pidAlive: input.pidAlive ?? defaultPidAlive,
|
|
1775
|
+
pidIncarnation: input.pidIncarnation ?? defaultPidIncarnation,
|
|
1776
|
+
}) &&
|
|
1777
|
+
(state?.ownerId !== input.spawned.acquisition.ownerId ||
|
|
1778
|
+
state.acquisitionId !== input.spawned.acquisition.acquisitionId ||
|
|
1779
|
+
state.pid === childPid)
|
|
1780
|
+
)
|
|
1781
|
+
return true;
|
|
1782
|
+
if (
|
|
1783
|
+
state?.ownerId === input.spawned.acquisition.ownerId &&
|
|
1784
|
+
state.acquisitionId === input.spawned.acquisition.acquisitionId &&
|
|
1785
|
+
(state.pid === childPid || state.pid === launcherPid || state.pid === input.pid) &&
|
|
1786
|
+
state.ownershipPhase === "retired"
|
|
1787
|
+
)
|
|
1788
|
+
return false;
|
|
1789
|
+
throw new Error("Telegram daemon provisional ownership could not be retired safely");
|
|
751
1790
|
}
|
|
752
1791
|
|
|
753
1792
|
export async function releaseDaemonOwnership(input: {
|
|
754
1793
|
settings: Settings;
|
|
755
1794
|
ownerId: string;
|
|
756
|
-
|
|
757
|
-
tokenFingerprint
|
|
758
|
-
chatId
|
|
759
|
-
|
|
760
|
-
|
|
1795
|
+
acquisitionId?: string;
|
|
1796
|
+
tokenFingerprint?: string;
|
|
1797
|
+
chatId?: string;
|
|
1798
|
+
pid?: number;
|
|
1799
|
+
generation?: number;
|
|
1800
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
761
1801
|
fs?: TelegramDaemonFs;
|
|
762
1802
|
now?: () => number;
|
|
763
1803
|
}): Promise<void> {
|
|
764
1804
|
const fsImpl = input.fs ?? nodeFs;
|
|
765
1805
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
766
|
-
|
|
1806
|
+
const transition = await acquireTransitionLock({
|
|
1807
|
+
fs: fsImpl,
|
|
1808
|
+
path: paths.steal,
|
|
1809
|
+
pidIncarnation: input.pidIncarnation,
|
|
1810
|
+
});
|
|
1811
|
+
if (!transition) return;
|
|
1812
|
+
try {
|
|
767
1813
|
const state = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1814
|
+
const acquisitionId = input.acquisitionId ?? input.ownerId;
|
|
1815
|
+
const pid = input.pid ?? state?.pid;
|
|
1816
|
+
const generation = input.generation ?? state?.generation;
|
|
1817
|
+
const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(pid ?? 0);
|
|
768
1818
|
if (
|
|
769
|
-
!state ||
|
|
1819
|
+
!hasSafeDaemonStateShape(state) ||
|
|
770
1820
|
state.ownerId !== input.ownerId ||
|
|
771
|
-
|
|
772
|
-
state.
|
|
1821
|
+
state.acquisitionId !== acquisitionId ||
|
|
1822
|
+
(input.tokenFingerprint !== undefined && state.tokenFingerprint !== input.tokenFingerprint) ||
|
|
1823
|
+
(input.chatId !== undefined && state.chatId !== input.chatId) ||
|
|
1824
|
+
state.pid !== pid ||
|
|
1825
|
+
!isProcessIncarnation(incarnation) ||
|
|
1826
|
+
state.incarnation !== incarnation ||
|
|
1827
|
+
state.generation !== generation ||
|
|
1828
|
+
!Number.isSafeInteger(generation)
|
|
773
1829
|
)
|
|
774
1830
|
return;
|
|
1831
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return;
|
|
775
1832
|
await writeJsonAtomic(fsImpl, paths.state, { ...state, stoppedAt: (input.now ?? Date.now)() });
|
|
776
|
-
await fsImpl
|
|
777
|
-
|
|
1833
|
+
if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
|
|
1834
|
+
await fsImpl.unlink(paths.lock).catch(() => undefined);
|
|
1835
|
+
} finally {
|
|
1836
|
+
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1837
|
+
}
|
|
778
1838
|
}
|
|
779
1839
|
|
|
780
1840
|
/** Read the persisted daemon ownership state (or undefined when absent). */
|
|
@@ -803,6 +1863,15 @@ function defaultPidAlive(pid: number): boolean {
|
|
|
803
1863
|
}
|
|
804
1864
|
}
|
|
805
1865
|
|
|
1866
|
+
/**
|
|
1867
|
+
* Use immutable process-start provenance on every supported OS. In particular,
|
|
1868
|
+
* Windows is authorized by the native process API or PowerShell StartTime/FileTime;
|
|
1869
|
+
* absent authority fails closed and never trusts the numeric PID alone.
|
|
1870
|
+
*/
|
|
1871
|
+
function defaultPidIncarnation(pid: number): string | undefined {
|
|
1872
|
+
return processIncarnation(pid);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
806
1875
|
/** True for AbortError-shaped rejections raised when an in-flight fetch is aborted. */
|
|
807
1876
|
function isAbortError(err: unknown): boolean {
|
|
808
1877
|
return err instanceof Error && (err.name === "AbortError" || /\baborted\b/i.test(err.message));
|
|
@@ -828,7 +1897,7 @@ function defaultDaemonSpawn(
|
|
|
828
1897
|
const child = childProcessSpawn(command, args, { detached: opts.detached, stdio });
|
|
829
1898
|
// Best-effort autostart: a spawn failure must never crash the host session.
|
|
830
1899
|
child.on("error", () => undefined);
|
|
831
|
-
return { unref: () => child.unref() };
|
|
1900
|
+
return { pid: child.pid, unref: () => child.unref() };
|
|
832
1901
|
}
|
|
833
1902
|
|
|
834
1903
|
export interface TelegramSpawnOwnerInput {
|
|
@@ -838,19 +1907,20 @@ export interface TelegramSpawnOwnerInput {
|
|
|
838
1907
|
chatId: string;
|
|
839
1908
|
}
|
|
840
1909
|
|
|
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;
|
|
1910
|
+
export interface TelegramSpawnAcquisition {
|
|
1911
|
+
readonly ownerId: string;
|
|
1912
|
+
readonly acquisitionId: string;
|
|
1913
|
+
/** PID of the launcher which reserved provisional ownership before spawn. */
|
|
1914
|
+
readonly launcherPid?: number;
|
|
1915
|
+
/** Actual detached child which must publish the ready owner state. */
|
|
1916
|
+
readonly pid?: number;
|
|
852
1917
|
}
|
|
853
1918
|
|
|
1919
|
+
export type TelegramSpawnOwnerResult =
|
|
1920
|
+
| { result: "owner_spawned"; acquisition: TelegramSpawnAcquisition; runtime: DaemonRuntimeInfo; warnings: string[] }
|
|
1921
|
+
| { result: "attached"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean }
|
|
1922
|
+
| { result: "blocked"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean };
|
|
1923
|
+
|
|
854
1924
|
/**
|
|
855
1925
|
* Build the detached spawn command/args for the daemon-internal entrypoint.
|
|
856
1926
|
* Source mode prepends the entry script so the respawn loads edited source;
|
|
@@ -910,26 +1980,41 @@ export async function spawnTelegramDaemonOwner(
|
|
|
910
1980
|
now: deps.now,
|
|
911
1981
|
pid: deps.pid,
|
|
912
1982
|
pidAlive: deps.pidAlive,
|
|
1983
|
+
pidIncarnation: deps.pidIncarnation,
|
|
913
1984
|
randomId: ownerId ? undefined : deps.randomId,
|
|
914
1985
|
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
1986
|
});
|
|
923
1987
|
if (!ownership.acquired) {
|
|
924
|
-
if (ownership.blocked) {
|
|
1988
|
+
if (ownership.blocked || ownership.provisional) {
|
|
925
1989
|
return {
|
|
926
1990
|
result: "blocked",
|
|
927
|
-
runtime,
|
|
928
|
-
warnings: [
|
|
1991
|
+
runtime: buildTelegramDaemonSpawnArgs({ execPath, ownerId: "", agentDir }).runtime,
|
|
1992
|
+
warnings: [
|
|
1993
|
+
ownership.provisional
|
|
1994
|
+
? "telegram daemon ownership is provisional; refusing to attach"
|
|
1995
|
+
: "live telegram daemon uses a different bot token or chat; refusing to attach",
|
|
1996
|
+
],
|
|
929
1997
|
};
|
|
930
1998
|
}
|
|
931
|
-
return {
|
|
1999
|
+
return {
|
|
2000
|
+
result: "attached",
|
|
2001
|
+
runtime: buildTelegramDaemonSpawnArgs({ execPath, ownerId: "", agentDir }).runtime,
|
|
2002
|
+
warnings: [],
|
|
2003
|
+
reloadRequired: ownership.reloadRequired,
|
|
2004
|
+
};
|
|
932
2005
|
}
|
|
2006
|
+
const launcherPid = deps.pid ?? process.pid;
|
|
2007
|
+
const provisionalAcquisition: TelegramSpawnAcquisition = Object.freeze({
|
|
2008
|
+
ownerId: ownership.ownerId as string,
|
|
2009
|
+
acquisitionId: ownership.acquisitionId as string,
|
|
2010
|
+
launcherPid,
|
|
2011
|
+
});
|
|
2012
|
+
// One source of truth for runtime detection + spawn args (no duplicate resolve).
|
|
2013
|
+
const { command, args, runtime } = buildTelegramDaemonSpawnArgs({
|
|
2014
|
+
execPath,
|
|
2015
|
+
ownerId: provisionalAcquisition.ownerId,
|
|
2016
|
+
agentDir,
|
|
2017
|
+
});
|
|
933
2018
|
const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
|
|
934
2019
|
const child = spawnImpl(command, args, {
|
|
935
2020
|
detached: true,
|
|
@@ -937,7 +2022,79 @@ export async function spawnTelegramDaemonOwner(
|
|
|
937
2022
|
logPath: path.join(daemonPaths(agentDir).dir, "daemon.log"),
|
|
938
2023
|
});
|
|
939
2024
|
child?.unref?.();
|
|
940
|
-
|
|
2025
|
+
// A launcher can reserve ownership, but only the actual child can become
|
|
2026
|
+
// ready. Missing PID provenance is intentionally non-ready and times out.
|
|
2027
|
+
const childPid = child?.pid;
|
|
2028
|
+
if (!Number.isSafeInteger(childPid) || (childPid as number) <= 0)
|
|
2029
|
+
return { result: "owner_spawned", acquisition: provisionalAcquisition, runtime, warnings: [] };
|
|
2030
|
+
const pid = childPid as number;
|
|
2031
|
+
const acquisition: TelegramSpawnAcquisition = Object.freeze({ ...provisionalAcquisition, pid });
|
|
2032
|
+
const incarnation = (deps.pidIncarnation ?? defaultPidIncarnation)(pid);
|
|
2033
|
+
if (!incarnation) return { result: "owner_spawned", acquisition, runtime, warnings: [] };
|
|
2034
|
+
await bindProvisionalDaemonPid({
|
|
2035
|
+
settings: input.settings,
|
|
2036
|
+
ownerId: acquisition.ownerId,
|
|
2037
|
+
acquisitionId: acquisition.acquisitionId,
|
|
2038
|
+
incarnation,
|
|
2039
|
+
pid,
|
|
2040
|
+
fs: deps.fs,
|
|
2041
|
+
sleep: deps.sleep,
|
|
2042
|
+
});
|
|
2043
|
+
return { result: "owner_spawned", acquisition, runtime, warnings: [] };
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/**
|
|
2047
|
+
* Owner-bound reclamation of a confirmed-dead daemon owner's lock, mirroring the
|
|
2048
|
+
* daemon step of `gjc notify recovery`. Removes only the lock of a recorded owner
|
|
2049
|
+
* that is still the same, still-dead process — never a live or superseded owner,
|
|
2050
|
+
* because it re-checks ownership and liveness while holding the same steal-mutex
|
|
2051
|
+
* the daemon's own takeover path uses. Lets a fresh SDK session self-heal a stale
|
|
2052
|
+
* owner (e.g. after a crash, or a `stop --force` that left the record behind)
|
|
2053
|
+
* instead of requiring manual reconciliation. Returns true when a stale lock was
|
|
2054
|
+
* cleared and a spawn retry is worthwhile.
|
|
2055
|
+
*/
|
|
2056
|
+
async function reclaimDeadDaemonOwner(input: {
|
|
2057
|
+
settings: Settings;
|
|
2058
|
+
fs?: TelegramDaemonFs;
|
|
2059
|
+
now?: () => number;
|
|
2060
|
+
pidAlive?: (pid: number) => boolean;
|
|
2061
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
2062
|
+
}): Promise<boolean> {
|
|
2063
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
2064
|
+
const now = input.now ?? Date.now;
|
|
2065
|
+
const pidAlive = input.pidAlive ?? defaultPidAlive;
|
|
2066
|
+
const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
|
|
2067
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
2068
|
+
const state = await readDaemonState(input.settings, fsImpl);
|
|
2069
|
+
if (!state || pidAlive(state.pid)) return false;
|
|
2070
|
+
const lock = await readOwnershipLock(fsImpl, paths.lock);
|
|
2071
|
+
if (lock.kind === "missing") return false;
|
|
2072
|
+
if (
|
|
2073
|
+
!(await ownershipLockIsReclaimable({ fs: fsImpl, path: paths.lock, lock, now: now(), pidAlive, pidIncarnation }))
|
|
2074
|
+
)
|
|
2075
|
+
return false;
|
|
2076
|
+
const transition = await acquireDaemonTransitionLock({
|
|
2077
|
+
fs: fsImpl,
|
|
2078
|
+
path: paths.steal,
|
|
2079
|
+
pid: process.pid,
|
|
2080
|
+
pidAlive: defaultPidAlive,
|
|
2081
|
+
pidIncarnation: defaultPidIncarnation,
|
|
2082
|
+
});
|
|
2083
|
+
if (!transition) return false;
|
|
2084
|
+
try {
|
|
2085
|
+
const current = await readDaemonState(input.settings, fsImpl);
|
|
2086
|
+
if (!current || current.ownerId !== state.ownerId || current.pid !== state.pid) return false;
|
|
2087
|
+
if (pidAlive(current.pid)) return false;
|
|
2088
|
+
if (!(await daemonTransitionLockIsHeld({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
|
|
2089
|
+
try {
|
|
2090
|
+
await fsImpl.unlink(paths.lock);
|
|
2091
|
+
return true;
|
|
2092
|
+
} catch {
|
|
2093
|
+
return false;
|
|
2094
|
+
}
|
|
2095
|
+
} finally {
|
|
2096
|
+
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
2097
|
+
}
|
|
941
2098
|
}
|
|
942
2099
|
|
|
943
2100
|
/**
|
|
@@ -950,25 +2107,117 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
950
2107
|
): Promise<EnsureTelegramDaemonDetailedResult> {
|
|
951
2108
|
const cfg = getNotificationConfig(input.settings);
|
|
952
2109
|
if (!isTelegramConfigured(cfg)) return "disabled";
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
2110
|
+
const root = notificationRootForCwd(input.cwd);
|
|
2111
|
+
const fp = tokenFingerprint(cfg.botToken);
|
|
2112
|
+
let spawned = await withTelegramSetupLease(
|
|
2113
|
+
cfg.botToken,
|
|
2114
|
+
async () =>
|
|
2115
|
+
await spawnTelegramDaemonOwner(
|
|
2116
|
+
{ settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
|
|
2117
|
+
deps,
|
|
2118
|
+
),
|
|
2119
|
+
);
|
|
2120
|
+
if (spawned.result === "blocked" && spawned.warnings[0]?.includes("provisional")) {
|
|
2121
|
+
const provisional = await readDaemonState(input.settings, deps.fs);
|
|
2122
|
+
// A launcher-reserved PID can be rebound by the child heartbeat. Only a
|
|
2123
|
+
// state marked by bindProvisionalDaemonPid has an authoritative child PID.
|
|
2124
|
+
const hasAuthoritativeChildPid =
|
|
2125
|
+
Number.isSafeInteger(provisional?.launcherPid) && (provisional?.launcherPid as number) > 0;
|
|
2126
|
+
const ready = await waitForTelegramDaemonReady({
|
|
2127
|
+
settings: input.settings,
|
|
2128
|
+
ownerId: provisional?.ownerId,
|
|
2129
|
+
acquisitionId: provisional?.acquisitionId,
|
|
2130
|
+
pid: hasAuthoritativeChildPid ? provisional?.pid : undefined,
|
|
2131
|
+
excludedPid: hasAuthoritativeChildPid ? undefined : provisional?.pid,
|
|
2132
|
+
tokenFingerprint: fp,
|
|
2133
|
+
chatId: cfg.chatId,
|
|
2134
|
+
fs: deps.fs,
|
|
2135
|
+
now: deps.now,
|
|
2136
|
+
pidAlive: deps.pidAlive,
|
|
2137
|
+
pidIncarnation: deps.pidIncarnation,
|
|
2138
|
+
sleep: deps.sleep,
|
|
2139
|
+
waitStepMs: deps.waitStepMs,
|
|
2140
|
+
timeoutMs: deps.readinessTimeoutMs,
|
|
2141
|
+
});
|
|
2142
|
+
if (ready) {
|
|
2143
|
+
spawned = await withTelegramSetupLease(
|
|
2144
|
+
cfg.botToken,
|
|
2145
|
+
async () =>
|
|
2146
|
+
await spawnTelegramDaemonOwner(
|
|
2147
|
+
{ settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
|
|
2148
|
+
deps,
|
|
2149
|
+
),
|
|
2150
|
+
);
|
|
963
2151
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
2152
|
+
}
|
|
2153
|
+
if (
|
|
2154
|
+
spawned.result === "blocked" &&
|
|
2155
|
+
!spawned.warnings[0]?.includes("provisional") &&
|
|
2156
|
+
(await reclaimDeadDaemonOwner({
|
|
2157
|
+
settings: input.settings,
|
|
2158
|
+
fs: deps.fs,
|
|
2159
|
+
now: deps.now,
|
|
2160
|
+
pidAlive: deps.pidAlive,
|
|
2161
|
+
pidIncarnation: deps.pidIncarnation,
|
|
2162
|
+
}))
|
|
2163
|
+
) {
|
|
2164
|
+
// A crashed owner (or a `stop --force` that found no live process) can leave a
|
|
2165
|
+
// stale lock behind that blocks re-acquisition. Automate the recovery step so a
|
|
2166
|
+
// fresh session self-heals instead of requiring a manual `gjc notify recovery`.
|
|
2167
|
+
spawned = await withTelegramSetupLease(
|
|
2168
|
+
cfg.botToken,
|
|
2169
|
+
async () =>
|
|
2170
|
+
await spawnTelegramDaemonOwner(
|
|
2171
|
+
{ settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
|
|
2172
|
+
deps,
|
|
2173
|
+
),
|
|
2174
|
+
);
|
|
2175
|
+
}
|
|
2176
|
+
if (spawned.result === "blocked") {
|
|
2177
|
+
logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
|
|
2178
|
+
return "blocked_identity";
|
|
2179
|
+
}
|
|
2180
|
+
if (spawned.result === "attached" && spawned.reloadRequired) {
|
|
2181
|
+
const previous = await readNotificationRootRegistration({ ...input, fs: deps.fs });
|
|
2182
|
+
await registerNotificationRoot({ ...input, fs: deps.fs });
|
|
2183
|
+
const controller = new TelegramDaemonController(input.settings, telegramControllerDeps(deps));
|
|
2184
|
+
const upgrade = await controller.reloadForGenerationUpgrade();
|
|
2185
|
+
if (upgrade.outcome !== "ready") {
|
|
2186
|
+
await restoreNotificationRootRegistration({
|
|
2187
|
+
settings: input.settings,
|
|
2188
|
+
sessionId: input.sessionId,
|
|
2189
|
+
registeredRoot: root,
|
|
2190
|
+
previous,
|
|
2191
|
+
fs: deps.fs,
|
|
2192
|
+
});
|
|
2193
|
+
throw new Error(`Unable to replace stale Telegram daemon: ${upgrade.operation.message}`);
|
|
968
2194
|
}
|
|
2195
|
+
return "reloaded";
|
|
2196
|
+
}
|
|
2197
|
+
if (spawned.result !== "owner_spawned") {
|
|
969
2198
|
await registerNotificationRoot({ ...input, fs: deps.fs });
|
|
970
|
-
return
|
|
971
|
-
}
|
|
2199
|
+
return "attached";
|
|
2200
|
+
}
|
|
2201
|
+
if (
|
|
2202
|
+
await confirmTelegramDaemonSpawn({
|
|
2203
|
+
settings: input.settings,
|
|
2204
|
+
spawned,
|
|
2205
|
+
tokenFingerprint: fp,
|
|
2206
|
+
chatId: cfg.chatId,
|
|
2207
|
+
pid: deps.pid ?? process.pid,
|
|
2208
|
+
fs: deps.fs,
|
|
2209
|
+
now: deps.now,
|
|
2210
|
+
pidAlive: deps.pidAlive,
|
|
2211
|
+
pidIncarnation: deps.pidIncarnation,
|
|
2212
|
+
sleep: deps.sleep,
|
|
2213
|
+
waitStepMs: deps.waitStepMs,
|
|
2214
|
+
timeoutMs: deps.readinessTimeoutMs,
|
|
2215
|
+
})
|
|
2216
|
+
) {
|
|
2217
|
+
await registerNotificationRoot({ ...input, fs: deps.fs });
|
|
2218
|
+
return "spawned";
|
|
2219
|
+
}
|
|
2220
|
+
throw new Error("Telegram daemon did not become ready after spawning");
|
|
972
2221
|
}
|
|
973
2222
|
|
|
974
2223
|
/**
|
|
@@ -993,26 +2242,21 @@ export async function ensureTelegramDaemonRunning(
|
|
|
993
2242
|
}
|
|
994
2243
|
}
|
|
995
2244
|
|
|
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, {
|
|
2245
|
+
function telegramControllerDeps(deps: TelegramDaemonDeps): TelegramDaemonControlDeps {
|
|
2246
|
+
return {
|
|
1004
2247
|
fs: deps.fs,
|
|
1005
2248
|
now: deps.now,
|
|
1006
2249
|
pidAlive: deps.pidAlive,
|
|
1007
|
-
|
|
2250
|
+
processReference: deps.processReference,
|
|
2251
|
+
pidIncarnation: deps.pidIncarnation,
|
|
1008
2252
|
spawn: deps.spawn,
|
|
1009
2253
|
execPath: deps.execPath,
|
|
1010
2254
|
ownerPid: deps.pid,
|
|
1011
2255
|
randomId: deps.randomId,
|
|
1012
2256
|
sleep: deps.sleep,
|
|
1013
2257
|
waitStepMs: deps.waitStepMs,
|
|
1014
|
-
|
|
1015
|
-
|
|
2258
|
+
readinessTimeoutMs: deps.readinessTimeoutMs,
|
|
2259
|
+
};
|
|
1016
2260
|
}
|
|
1017
2261
|
|
|
1018
2262
|
export interface BotApi {
|
|
@@ -1327,6 +2571,7 @@ export interface TelegramDaemonOptions {
|
|
|
1327
2571
|
pid?: number;
|
|
1328
2572
|
/** Liveness probe for skipping dead-PID endpoint records in {@link TelegramNotificationDaemon.scanRoots}. */
|
|
1329
2573
|
pidAlive?: (pid: number) => boolean;
|
|
2574
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1330
2575
|
botApi?: BotApi;
|
|
1331
2576
|
control?: DaemonControlHooks;
|
|
1332
2577
|
/**
|
|
@@ -1350,6 +2595,8 @@ export interface TelegramDaemonOptions {
|
|
|
1350
2595
|
rich?: { enabled: boolean };
|
|
1351
2596
|
/** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */
|
|
1352
2597
|
richDraft?: { enabled: boolean };
|
|
2598
|
+
/** Tool start/completion messages (enabled by default). */
|
|
2599
|
+
toolActivity?: { enabled: boolean };
|
|
1353
2600
|
/**
|
|
1354
2601
|
* Per-session Telegram forum-topic naming. `nameTemplate` supports the
|
|
1355
2602
|
* `{repo}`, `{branch}`, and `{title}` placeholders; unset preserves the
|
|
@@ -1405,9 +2652,21 @@ interface TopicAuthorityLease {
|
|
|
1405
2652
|
authorityEpoch: number;
|
|
1406
2653
|
}
|
|
1407
2654
|
|
|
2655
|
+
interface ToolActivityOwner {
|
|
2656
|
+
sessionId: string;
|
|
2657
|
+
toolCallId: string;
|
|
2658
|
+
toolName: string;
|
|
2659
|
+
endpointDigest: string;
|
|
2660
|
+
session: SessionSocket;
|
|
2661
|
+
phase: "started" | "terminal";
|
|
2662
|
+
policyEpoch?: number;
|
|
2663
|
+
}
|
|
2664
|
+
|
|
1408
2665
|
interface PendingThreadedFrame {
|
|
1409
2666
|
send: ThreadedSend;
|
|
1410
2667
|
msg: Record<string, unknown>;
|
|
2668
|
+
endpointDigest: string;
|
|
2669
|
+
toolActivity?: ToolActivityOwner;
|
|
1411
2670
|
}
|
|
1412
2671
|
|
|
1413
2672
|
type SelectedAckOutcome =
|
|
@@ -1441,6 +2700,7 @@ interface TelegramQueuePayload {
|
|
|
1441
2700
|
topicLease?: TopicAuthorityLease;
|
|
1442
2701
|
selectedAck?: SelectedAckQueueItem;
|
|
1443
2702
|
btwDelivery?: BtwQueuedDelivery;
|
|
2703
|
+
toolActivity?: ToolActivityOwner;
|
|
1444
2704
|
}
|
|
1445
2705
|
|
|
1446
2706
|
interface PendingBtwTurn {
|
|
@@ -1531,6 +2791,16 @@ export class TelegramNotificationDaemon {
|
|
|
1531
2791
|
readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
|
|
1532
2792
|
/** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
|
|
1533
2793
|
private readonly liveMessages = new Map<string, number>();
|
|
2794
|
+
/** Endpoint-bound ownership for visible or dispatching tool bubbles. */
|
|
2795
|
+
private readonly toolActivityOwners = new Map<string, ToolActivityOwner>();
|
|
2796
|
+
private readonly revokedToolEndpoints = new Set<string>();
|
|
2797
|
+
private readonly unresolvedToolTerminalizations = new Map<string, { messageId: number; owner: ToolActivityOwner }>();
|
|
2798
|
+
private toolTerminalizationChain: Promise<void> = Promise.resolve();
|
|
2799
|
+
private toolActivityPolicyEpoch = 0;
|
|
2800
|
+
private toolActivityStopping = false;
|
|
2801
|
+
private readonly replayToolActivityEpochs = new WeakMap<Record<string, unknown>, number>();
|
|
2802
|
+
private toolShutdownBarrier: Promise<void> = Promise.resolve();
|
|
2803
|
+
private toolActivityAmbiguous = false;
|
|
1534
2804
|
readonly sessions = new Map<string, SessionSocket>();
|
|
1535
2805
|
/** Ephemeral aliases for model choices; deliberately never serialized across daemon restarts. */
|
|
1536
2806
|
#modelChoiceAliases = new Map<string, ModelChoiceRoute>();
|
|
@@ -1640,7 +2910,13 @@ export class TelegramNotificationDaemon {
|
|
|
1640
2910
|
* ~25s getUpdates timeout. Safe to call from a signal handler.
|
|
1641
2911
|
*/
|
|
1642
2912
|
requestStop(_reason?: "reload" | "stop" | "signal"): void {
|
|
1643
|
-
this.
|
|
2913
|
+
const toolShutdown = this.beginToolActivityShutdown();
|
|
2914
|
+
void toolShutdown
|
|
2915
|
+
.finally(() => {
|
|
2916
|
+
this.effects.beginShutdown();
|
|
2917
|
+
this.#deliveryAbort.abort();
|
|
2918
|
+
})
|
|
2919
|
+
.catch(() => undefined);
|
|
1644
2920
|
for (const item of new Set(this.selectedAckPending.values())) {
|
|
1645
2921
|
if (item.state === "queued") this.pool.removeById(item.itemId);
|
|
1646
2922
|
else item.controller?.abort();
|
|
@@ -1651,7 +2927,6 @@ export class TelegramNotificationDaemon {
|
|
|
1651
2927
|
delivery.invalidated = true;
|
|
1652
2928
|
delivery.controller.abort("daemon_shutdown");
|
|
1653
2929
|
}
|
|
1654
|
-
this.#deliveryAbort.abort();
|
|
1655
2930
|
this.runtime.requestStop();
|
|
1656
2931
|
this.running = false;
|
|
1657
2932
|
}
|
|
@@ -2458,7 +3733,131 @@ export class TelegramNotificationDaemon {
|
|
|
2458
3733
|
* (so a delayed old close cannot delete a replacement), and best-effort closes
|
|
2459
3734
|
* the socket. `scanRoots()` then reconnects the session.
|
|
2460
3735
|
*/
|
|
3736
|
+
private enqueueToolTerminalization(
|
|
3737
|
+
claimed:
|
|
3738
|
+
| Array<{ messageId: number; owner: ToolActivityOwner }>
|
|
3739
|
+
| (() => Array<{ messageId: number; owner: ToolActivityOwner }>),
|
|
3740
|
+
awaitDispatch: boolean,
|
|
3741
|
+
strict = false,
|
|
3742
|
+
): Promise<void> {
|
|
3743
|
+
const terminalize = (): Promise<void> =>
|
|
3744
|
+
this.effects.allowTerminal(async () => {
|
|
3745
|
+
if (awaitDispatch) await this.flushChain;
|
|
3746
|
+
const strictFailures: Error[] = [];
|
|
3747
|
+
const claimedItems = typeof claimed === "function" ? claimed() : claimed;
|
|
3748
|
+
for (const item of claimedItems) {
|
|
3749
|
+
const { messageId, owner } = item;
|
|
3750
|
+
const backlogKey = `${owner.endpointDigest}\0${owner.sessionId}\0${owner.toolCallId}\0${messageId}`;
|
|
3751
|
+
const send = renderThreadedFrame({
|
|
3752
|
+
type: "tool_activity",
|
|
3753
|
+
sessionId: owner.sessionId,
|
|
3754
|
+
toolCallId: owner.toolCallId,
|
|
3755
|
+
toolName: owner.toolName,
|
|
3756
|
+
phase: "unknown",
|
|
3757
|
+
});
|
|
3758
|
+
if (!send?.text) continue;
|
|
3759
|
+
let failure: unknown;
|
|
3760
|
+
let delivered = false;
|
|
3761
|
+
for (let attempt = 0; attempt < (strict ? 5 : 1); attempt++) {
|
|
3762
|
+
try {
|
|
3763
|
+
const response = (await this.botApi.call("editMessageText", {
|
|
3764
|
+
chat_id: this.opts.chatId,
|
|
3765
|
+
message_id: messageId,
|
|
3766
|
+
text: send.text,
|
|
3767
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
3768
|
+
})) as { ok?: boolean; description?: string } | undefined;
|
|
3769
|
+
delivered = response?.ok === true || /not modified/i.test(String(response?.description ?? ""));
|
|
3770
|
+
if (delivered) break;
|
|
3771
|
+
failure = new Error(String(response?.description ?? "Telegram rejected tool terminalization."));
|
|
3772
|
+
} catch (error) {
|
|
3773
|
+
failure = error;
|
|
3774
|
+
}
|
|
3775
|
+
if (strict && attempt < 4) await this.runtime.sleep(50 * 2 ** attempt);
|
|
3776
|
+
}
|
|
3777
|
+
if (delivered) {
|
|
3778
|
+
this.unresolvedToolTerminalizations.delete(backlogKey);
|
|
3779
|
+
continue;
|
|
3780
|
+
}
|
|
3781
|
+
this.unresolvedToolTerminalizations.set(backlogKey, item);
|
|
3782
|
+
if (strict) {
|
|
3783
|
+
const key = `${owner.sessionId}:tool:${owner.toolCallId}`;
|
|
3784
|
+
if (!this.toolActivityOwners.has(key)) {
|
|
3785
|
+
this.toolActivityOwners.set(key, owner);
|
|
3786
|
+
this.liveMessages.set(key, messageId);
|
|
3787
|
+
}
|
|
3788
|
+
strictFailures.push(failure instanceof Error ? failure : new Error("Tool terminalization failed."));
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
if (strictFailures.length > 0) {
|
|
3792
|
+
throw new AggregateError(strictFailures, strictFailures.map(failure => failure.message).join("; "));
|
|
3793
|
+
}
|
|
3794
|
+
});
|
|
3795
|
+
const next = this.toolTerminalizationChain.then(terminalize);
|
|
3796
|
+
this.toolTerminalizationChain = next.catch(() => {});
|
|
3797
|
+
return next;
|
|
3798
|
+
}
|
|
3799
|
+
|
|
3800
|
+
private scheduleVisibleToolTerminalization(endpointDigest?: string, strict = false): Promise<void> {
|
|
3801
|
+
if (endpointDigest !== undefined) this.revokedToolEndpoints.add(endpointDigest);
|
|
3802
|
+
const claimVisible = (): Array<{ messageId: number; owner: ToolActivityOwner }> => {
|
|
3803
|
+
const claimedByKey = new Map<string, { messageId: number; owner: ToolActivityOwner }>();
|
|
3804
|
+
for (const [backlogKey, item] of this.unresolvedToolTerminalizations) {
|
|
3805
|
+
if (endpointDigest !== undefined && item.owner.endpointDigest !== endpointDigest) continue;
|
|
3806
|
+
this.unresolvedToolTerminalizations.delete(backlogKey);
|
|
3807
|
+
claimedByKey.set(backlogKey, item);
|
|
3808
|
+
}
|
|
3809
|
+
for (const [key, owner] of this.toolActivityOwners) {
|
|
3810
|
+
if (endpointDigest !== undefined && owner.endpointDigest !== endpointDigest) continue;
|
|
3811
|
+
this.toolActivityOwners.delete(key);
|
|
3812
|
+
const messageId = this.liveMessages.get(key);
|
|
3813
|
+
this.liveMessages.delete(key);
|
|
3814
|
+
if (messageId !== undefined) {
|
|
3815
|
+
const backlogKey = `${owner.endpointDigest}\0${owner.sessionId}\0${owner.toolCallId}\0${messageId}`;
|
|
3816
|
+
claimedByKey.set(backlogKey, { messageId, owner });
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
return [...claimedByKey.values()];
|
|
3820
|
+
};
|
|
3821
|
+
this.pool.removeWhere(
|
|
3822
|
+
item =>
|
|
3823
|
+
item.payload.toolActivity !== undefined &&
|
|
3824
|
+
(endpointDigest === undefined || item.payload.toolActivity.endpointDigest === endpointDigest),
|
|
3825
|
+
);
|
|
3826
|
+
const next = this.enqueueToolTerminalization(claimVisible, false, strict);
|
|
3827
|
+
if (endpointDigest !== undefined) {
|
|
3828
|
+
void next.then(
|
|
3829
|
+
() => this.revokedToolEndpoints.delete(endpointDigest),
|
|
3830
|
+
() => this.revokedToolEndpoints.delete(endpointDigest),
|
|
3831
|
+
);
|
|
3832
|
+
}
|
|
3833
|
+
return next;
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
private beginToolActivityShutdown(): Promise<void> {
|
|
3837
|
+
if (this.toolActivityStopping) return this.toolShutdownBarrier;
|
|
3838
|
+
this.toolActivityStopping = true;
|
|
3839
|
+
this.toolActivityPolicyEpoch++;
|
|
3840
|
+
this.toolShutdownBarrier = (async () => {
|
|
3841
|
+
await this.flushChain;
|
|
3842
|
+
const failures: Error[] = [];
|
|
3843
|
+
if (this.toolActivityAmbiguous) {
|
|
3844
|
+
failures.push(new Error("Tool activity delivery became ambiguous during daemon shutdown."));
|
|
3845
|
+
}
|
|
3846
|
+
try {
|
|
3847
|
+
await this.scheduleVisibleToolTerminalization(undefined, true);
|
|
3848
|
+
} catch (error) {
|
|
3849
|
+
failures.push(error instanceof Error ? error : new Error(String(error)));
|
|
3850
|
+
}
|
|
3851
|
+
if (failures.length > 0) {
|
|
3852
|
+
throw new AggregateError(failures, failures.map(failure => failure.message).join("; "));
|
|
3853
|
+
}
|
|
3854
|
+
})();
|
|
3855
|
+
return this.toolShutdownBarrier;
|
|
3856
|
+
}
|
|
3857
|
+
|
|
2461
3858
|
private dropSession(session: SessionSocket, reason: string): void {
|
|
3859
|
+
const isCurrentSession = this.sessions.get(session.sessionId) === session;
|
|
3860
|
+
if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
|
|
2462
3861
|
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
2463
3862
|
if (session.pingTimer) {
|
|
2464
3863
|
clearIntervalImpl(session.pingTimer);
|
|
@@ -2469,7 +3868,6 @@ export class TelegramNotificationDaemon {
|
|
|
2469
3868
|
} else {
|
|
2470
3869
|
void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
|
|
2471
3870
|
}
|
|
2472
|
-
const isCurrentSession = this.sessions.get(session.sessionId) === session;
|
|
2473
3871
|
if (isCurrentSession || reason === "session_closed") {
|
|
2474
3872
|
this.deleteMessageRoutes(session.sessionId);
|
|
2475
3873
|
}
|
|
@@ -2929,16 +4327,39 @@ export class TelegramNotificationDaemon {
|
|
|
2929
4327
|
return !ownerId || ownerId === session.sessionId;
|
|
2930
4328
|
}
|
|
2931
4329
|
|
|
4330
|
+
private toolActivityOwner(session: SessionSocket, msg: Record<string, unknown>): ToolActivityOwner | undefined {
|
|
4331
|
+
if (msg.type !== "tool_activity") return undefined;
|
|
4332
|
+
const toolCallId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
|
|
4333
|
+
const toolName = typeof msg.toolName === "string" ? msg.toolName : undefined;
|
|
4334
|
+
const phase = typeof msg.phase === "string" ? msg.phase : undefined;
|
|
4335
|
+
if (!toolCallId || !toolName || !phase) return undefined;
|
|
4336
|
+
return {
|
|
4337
|
+
sessionId: session.sessionId,
|
|
4338
|
+
toolCallId,
|
|
4339
|
+
toolName,
|
|
4340
|
+
endpointDigest: session.endpointDigest,
|
|
4341
|
+
session,
|
|
4342
|
+
phase: phase === "started" ? "started" : "terminal",
|
|
4343
|
+
};
|
|
4344
|
+
}
|
|
4345
|
+
|
|
4346
|
+
private toolActivityAuthorityIsCurrent(toolActivity: ToolActivityOwner): boolean {
|
|
4347
|
+
if (this.revokedToolEndpoints.has(toolActivity.endpointDigest)) return false;
|
|
4348
|
+
const session = this.sessions.get(toolActivity.sessionId);
|
|
4349
|
+
if (toolActivity.endpointDigest === undefined) return session === undefined;
|
|
4350
|
+
return session === toolActivity.session && session.endpointDigest === toolActivity.endpointDigest;
|
|
4351
|
+
}
|
|
2932
4352
|
private async submitThreadedFrame(
|
|
2933
4353
|
sessionId: string,
|
|
2934
4354
|
send: ThreadedSend,
|
|
2935
4355
|
topicLease: TopicAuthorityLease,
|
|
4356
|
+
toolActivity?: ToolActivityOwner,
|
|
2936
4357
|
): Promise<void> {
|
|
2937
4358
|
this.submitPool({
|
|
2938
4359
|
sessionId,
|
|
2939
4360
|
lane: send.lane,
|
|
2940
4361
|
coalesceKey: send.coalesceKey,
|
|
2941
|
-
payload: { send, topicLease },
|
|
4362
|
+
payload: { send, topicLease, ...(toolActivity ? { toolActivity } : {}) },
|
|
2942
4363
|
});
|
|
2943
4364
|
await this.flushPool();
|
|
2944
4365
|
}
|
|
@@ -3000,18 +4421,33 @@ export class TelegramNotificationDaemon {
|
|
|
3000
4421
|
}
|
|
3001
4422
|
}
|
|
3002
4423
|
|
|
3003
|
-
private rememberPendingThreadedFrame(
|
|
3004
|
-
|
|
3005
|
-
|
|
4424
|
+
private rememberPendingThreadedFrame(
|
|
4425
|
+
session: SessionSocket,
|
|
4426
|
+
send: ThreadedSend,
|
|
4427
|
+
msg: Record<string, unknown>,
|
|
4428
|
+
toolActivity?: ToolActivityOwner,
|
|
4429
|
+
): void {
|
|
4430
|
+
const frames = this.pendingThreadedFrames.get(session.sessionId) ?? [];
|
|
4431
|
+
frames.push({
|
|
4432
|
+
send,
|
|
4433
|
+
msg,
|
|
4434
|
+
endpointDigest: session.endpointDigest,
|
|
4435
|
+
...(toolActivity ? { toolActivity } : {}),
|
|
4436
|
+
});
|
|
3006
4437
|
if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
|
|
3007
|
-
this.pendingThreadedFrames.set(sessionId, frames);
|
|
4438
|
+
this.pendingThreadedFrames.set(session.sessionId, frames);
|
|
3008
4439
|
}
|
|
3009
4440
|
|
|
3010
4441
|
private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
|
|
3011
4442
|
const frames = this.pendingThreadedFrames.get(sessionId);
|
|
3012
4443
|
if (!frames || frames.length === 0) return;
|
|
3013
4444
|
this.pendingThreadedFrames.delete(sessionId);
|
|
3014
|
-
|
|
4445
|
+
const session = this.sessions.get(sessionId);
|
|
4446
|
+
for (const frame of frames) {
|
|
4447
|
+
if (session && session.endpointDigest !== frame.endpointDigest) continue;
|
|
4448
|
+
if (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled === false) continue;
|
|
4449
|
+
await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity);
|
|
4450
|
+
}
|
|
3015
4451
|
}
|
|
3016
4452
|
|
|
3017
4453
|
/**
|
|
@@ -3096,7 +4532,11 @@ export class TelegramNotificationDaemon {
|
|
|
3096
4532
|
})) as { ok?: boolean };
|
|
3097
4533
|
if (!topicDeleteSettled(res)) return;
|
|
3098
4534
|
this.topics.settleDelete(sessionId, record.topicId);
|
|
3099
|
-
for (const k of [...this.liveMessages.keys()])
|
|
4535
|
+
for (const k of [...this.liveMessages.keys()])
|
|
4536
|
+
if (k.startsWith(`${sessionId}:`)) {
|
|
4537
|
+
this.liveMessages.delete(k);
|
|
4538
|
+
this.toolActivityOwners.delete(k);
|
|
4539
|
+
}
|
|
3100
4540
|
this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
|
|
3101
4541
|
if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
|
|
3102
4542
|
});
|
|
@@ -3280,6 +4720,21 @@ export class TelegramNotificationDaemon {
|
|
|
3280
4720
|
);
|
|
3281
4721
|
}
|
|
3282
4722
|
for (const item of batch) {
|
|
4723
|
+
const toolActivity = item.payload.toolActivity;
|
|
4724
|
+
if (
|
|
4725
|
+
toolActivity?.phase === "started" &&
|
|
4726
|
+
(this.toolActivityStopping ||
|
|
4727
|
+
this.opts.toolActivity?.enabled === false ||
|
|
4728
|
+
toolActivity.policyEpoch !== this.toolActivityPolicyEpoch ||
|
|
4729
|
+
!this.toolActivityAuthorityIsCurrent(toolActivity))
|
|
4730
|
+
) {
|
|
4731
|
+
const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
4732
|
+
const owner = this.toolActivityOwners.get(key);
|
|
4733
|
+
if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
|
|
4734
|
+
this.toolActivityOwners.delete(key);
|
|
4735
|
+
this.pool.settle(item.itemId!, "removed");
|
|
4736
|
+
continue;
|
|
4737
|
+
}
|
|
3283
4738
|
const btwDelivery = item.payload.btwDelivery;
|
|
3284
4739
|
if (btwDelivery) {
|
|
3285
4740
|
if (!btwDelivery.isAuthoritative()) {
|
|
@@ -3374,6 +4829,10 @@ export class TelegramNotificationDaemon {
|
|
|
3374
4829
|
this.pool.settle(item.itemId!, "rejected");
|
|
3375
4830
|
continue;
|
|
3376
4831
|
}
|
|
4832
|
+
if (item.payload.toolActivity && !this.toolActivityAuthorityIsCurrent(item.payload.toolActivity)) {
|
|
4833
|
+
this.pool.settle(item.itemId!, "removed");
|
|
4834
|
+
continue;
|
|
4835
|
+
}
|
|
3377
4836
|
// Threaded topic when available; otherwise deliver flat to the paired chat.
|
|
3378
4837
|
const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
|
|
3379
4838
|
const ckey = send.editable ? item.coalesceKey : undefined;
|
|
@@ -3572,19 +5031,25 @@ export class TelegramNotificationDaemon {
|
|
|
3572
5031
|
}
|
|
3573
5032
|
}
|
|
3574
5033
|
if (editKey && ckey !== undefined && firstMessageId !== undefined && !send.terminal) {
|
|
3575
|
-
this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
|
|
5034
|
+
this.recordLiveMessage(item.sessionId, ckey, firstMessageId, item.payload.toolActivity);
|
|
3576
5035
|
}
|
|
3577
5036
|
}
|
|
3578
5037
|
}
|
|
3579
5038
|
} catch {
|
|
3580
5039
|
// Best-effort: a failed send/edit must never stop the daemon.
|
|
3581
5040
|
disposition = "ambiguous";
|
|
5041
|
+
if (item.payload.toolActivity?.phase === "started") this.toolActivityAmbiguous = true;
|
|
3582
5042
|
} finally {
|
|
3583
5043
|
this.pool.settle(item.itemId!, disposition);
|
|
3584
5044
|
// A terminal tool frame owns the end of this coalescing key even when both
|
|
3585
5045
|
// edit and fallback delivery fail. Retaining the old message id would leak
|
|
3586
5046
|
// one entry per failure and let a later reused key edit stale Telegram state.
|
|
3587
|
-
if (send.terminal && editKey)
|
|
5047
|
+
if (send.terminal && editKey) {
|
|
5048
|
+
this.liveMessages.delete(editKey);
|
|
5049
|
+
const owner = this.toolActivityOwners.get(editKey);
|
|
5050
|
+
if (!item.payload.toolActivity || owner?.session === item.payload.toolActivity.session)
|
|
5051
|
+
this.toolActivityOwners.delete(editKey);
|
|
5052
|
+
}
|
|
3588
5053
|
}
|
|
3589
5054
|
}
|
|
3590
5055
|
}
|
|
@@ -3594,7 +5059,12 @@ export class TelegramNotificationDaemon {
|
|
|
3594
5059
|
* so later live/finalized frames edit it in place. Evicts this session's stale
|
|
3595
5060
|
* same-category entries (e.g. prior turns) so the map stays bounded.
|
|
3596
5061
|
*/
|
|
3597
|
-
private recordLiveMessage(
|
|
5062
|
+
private recordLiveMessage(
|
|
5063
|
+
sessionId: string,
|
|
5064
|
+
coalesceKey: string,
|
|
5065
|
+
messageId: number,
|
|
5066
|
+
toolActivity?: ToolActivityOwner,
|
|
5067
|
+
): void {
|
|
3598
5068
|
const mapKey = `${sessionId}:${coalesceKey}`;
|
|
3599
5069
|
const category = coalesceKey.split(":")[0] ?? "";
|
|
3600
5070
|
// Single-slot categories (rolling turn/context/reasoning previews) evict prior
|
|
@@ -3609,7 +5079,15 @@ export class TelegramNotificationDaemon {
|
|
|
3609
5079
|
if (k !== mapKey && k.startsWith(prefix)) this.liveMessages.delete(k);
|
|
3610
5080
|
}
|
|
3611
5081
|
}
|
|
5082
|
+
if (toolActivity) {
|
|
5083
|
+
const owner = this.toolActivityOwners.get(mapKey);
|
|
5084
|
+
if (this.revokedToolEndpoints.has(toolActivity.endpointDigest) || owner?.session !== toolActivity.session) {
|
|
5085
|
+
void this.enqueueToolTerminalization([{ messageId, owner: toolActivity }], false);
|
|
5086
|
+
return;
|
|
5087
|
+
}
|
|
5088
|
+
}
|
|
3612
5089
|
this.liveMessages.set(mapKey, messageId);
|
|
5090
|
+
if (toolActivity) this.toolActivityOwners.set(mapKey, toolActivity);
|
|
3613
5091
|
}
|
|
3614
5092
|
|
|
3615
5093
|
/**
|
|
@@ -3621,11 +5099,20 @@ export class TelegramNotificationDaemon {
|
|
|
3621
5099
|
* content never lands in a shared chat. Identity headers are sent at most once per
|
|
3622
5100
|
* session in flat mode.
|
|
3623
5101
|
*/
|
|
3624
|
-
private async deliverFlatFallback(
|
|
5102
|
+
private async deliverFlatFallback(
|
|
5103
|
+
sessionId: string,
|
|
5104
|
+
send: ThreadedSend,
|
|
5105
|
+
toolActivity?: ToolActivityOwner,
|
|
5106
|
+
): Promise<void> {
|
|
3625
5107
|
if (!(await this.pairedChatIsPrivate())) return;
|
|
3626
5108
|
await this.notifyThreadedFallback();
|
|
3627
5109
|
if (send.identity && this.flatIdentitySent.has(sessionId)) return;
|
|
3628
|
-
this.submitPool({
|
|
5110
|
+
this.submitPool({
|
|
5111
|
+
sessionId,
|
|
5112
|
+
lane: send.lane,
|
|
5113
|
+
coalesceKey: send.coalesceKey,
|
|
5114
|
+
payload: { send, ...(toolActivity ? { toolActivity } : {}) },
|
|
5115
|
+
});
|
|
3629
5116
|
await this.flushPool();
|
|
3630
5117
|
if (send.identity) this.flatIdentitySent.add(sessionId);
|
|
3631
5118
|
}
|
|
@@ -3697,11 +5184,13 @@ export class TelegramNotificationDaemon {
|
|
|
3697
5184
|
return renewDaemonHeartbeat({
|
|
3698
5185
|
settings: this.opts.settings,
|
|
3699
5186
|
ownerId: this.opts.ownerId,
|
|
5187
|
+
acquisitionId: this.opts.ownerId,
|
|
3700
5188
|
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
3701
5189
|
chatId: this.opts.chatId,
|
|
3702
5190
|
fs: this.fsImpl,
|
|
3703
5191
|
now: this.opts.now,
|
|
3704
5192
|
pid: this.opts.pid ?? process.pid,
|
|
5193
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
3705
5194
|
});
|
|
3706
5195
|
}
|
|
3707
5196
|
|
|
@@ -3848,9 +5337,13 @@ export class TelegramNotificationDaemon {
|
|
|
3848
5337
|
|
|
3849
5338
|
async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
|
|
3850
5339
|
if (session.replayPending) {
|
|
5340
|
+
if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled === false || this.toolActivityStopping))
|
|
5341
|
+
return;
|
|
3851
5342
|
const matchingReplay = msg?.type === "event_replay_result" && msg.id === session.replayId;
|
|
3852
5343
|
if (!matchingReplay) {
|
|
3853
|
-
|
|
5344
|
+
const frame = msg as Record<string, unknown>;
|
|
5345
|
+
if (frame.type === "tool_activity") this.replayToolActivityEpochs.set(frame, this.toolActivityPolicyEpoch);
|
|
5346
|
+
session.replayQueue.push(frame);
|
|
3854
5347
|
return;
|
|
3855
5348
|
}
|
|
3856
5349
|
session.replayPending = false;
|
|
@@ -3895,6 +5388,11 @@ export class TelegramNotificationDaemon {
|
|
|
3895
5388
|
}
|
|
3896
5389
|
const queued = session.replayQueue.splice(0);
|
|
3897
5390
|
for (const frame of queued) {
|
|
5391
|
+
if (
|
|
5392
|
+
frame.type === "tool_activity" &&
|
|
5393
|
+
this.replayToolActivityEpochs.get(frame) !== this.toolActivityPolicyEpoch
|
|
5394
|
+
)
|
|
5395
|
+
continue;
|
|
3898
5396
|
const fingerprint = JSON.stringify(frame);
|
|
3899
5397
|
const remaining = replayCounts.get(fingerprint) ?? 0;
|
|
3900
5398
|
if (remaining > 0) {
|
|
@@ -4129,12 +5627,63 @@ export class TelegramNotificationDaemon {
|
|
|
4129
5627
|
return;
|
|
4130
5628
|
}
|
|
4131
5629
|
if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
|
|
5630
|
+
const threadedFrame = msg as Record<string, unknown>;
|
|
5631
|
+
const toolActivity = this.toolActivityOwner(session, threadedFrame);
|
|
5632
|
+
const toolAdmissionEpoch = toolActivity
|
|
5633
|
+
? (this.replayToolActivityEpochs.get(threadedFrame) ?? this.toolActivityPolicyEpoch)
|
|
5634
|
+
: undefined;
|
|
5635
|
+
if (toolActivity) toolActivity.policyEpoch = toolAdmissionEpoch;
|
|
5636
|
+
const toolStartIsCurrent = (): boolean =>
|
|
5637
|
+
toolActivity?.phase !== "started" ||
|
|
5638
|
+
(this.toolActivityAuthorityIsCurrent(toolActivity) &&
|
|
5639
|
+
!this.toolActivityStopping &&
|
|
5640
|
+
this.opts.toolActivity?.enabled !== false &&
|
|
5641
|
+
toolAdmissionEpoch === this.toolActivityPolicyEpoch);
|
|
5642
|
+
const abandonStaleToolStart = (): void => {
|
|
5643
|
+
if (toolActivity?.phase !== "started") return;
|
|
5644
|
+
const key = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
5645
|
+
const owner = this.toolActivityOwners.get(key);
|
|
5646
|
+
if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
|
|
5647
|
+
this.toolActivityOwners.delete(key);
|
|
5648
|
+
};
|
|
5649
|
+
if (toolActivity) {
|
|
5650
|
+
const liveKey = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
5651
|
+
const currentOwner = this.toolActivityOwners.get(liveKey);
|
|
5652
|
+
if (toolActivity.phase === "started") {
|
|
5653
|
+
if (!toolStartIsCurrent()) return;
|
|
5654
|
+
this.toolActivityOwners.set(liveKey, toolActivity);
|
|
5655
|
+
} else {
|
|
5656
|
+
if (currentOwner && currentOwner.session !== session) return;
|
|
5657
|
+
if (this.opts.toolActivity?.enabled === false) {
|
|
5658
|
+
if (!currentOwner) return;
|
|
5659
|
+
if (!this.liveMessages.has(liveKey)) {
|
|
5660
|
+
// A start may already be granted to the serialized dispatcher but not
|
|
5661
|
+
// yet recorded as visible. Wait for that exact dispatch boundary before
|
|
5662
|
+
// deciding whether its terminal frame must close a visible message.
|
|
5663
|
+
await this.flushChain;
|
|
5664
|
+
}
|
|
5665
|
+
const settledOwner = this.toolActivityOwners.get(liveKey);
|
|
5666
|
+
if (!this.liveMessages.has(liveKey) || settledOwner?.session !== session) {
|
|
5667
|
+
if (settledOwner?.session === session) this.toolActivityOwners.delete(liveKey);
|
|
5668
|
+
return;
|
|
5669
|
+
}
|
|
5670
|
+
}
|
|
5671
|
+
}
|
|
5672
|
+
}
|
|
4132
5673
|
const send = renderThreadedFrame(msg);
|
|
4133
5674
|
if (!send) return;
|
|
4134
5675
|
const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
|
|
5676
|
+
if (!toolStartIsCurrent()) {
|
|
5677
|
+
abandonStaleToolStart();
|
|
5678
|
+
return;
|
|
5679
|
+
}
|
|
4135
5680
|
if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
|
|
4136
5681
|
if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
|
|
4137
|
-
|
|
5682
|
+
if (!toolStartIsCurrent()) {
|
|
5683
|
+
abandonStaleToolStart();
|
|
5684
|
+
return;
|
|
5685
|
+
}
|
|
5686
|
+
this.rememberPendingThreadedFrame(session, send, threadedFrame, toolActivity);
|
|
4138
5687
|
return;
|
|
4139
5688
|
}
|
|
4140
5689
|
if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
|
|
@@ -4148,9 +5697,13 @@ export class TelegramNotificationDaemon {
|
|
|
4148
5697
|
const topicId =
|
|
4149
5698
|
existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
|
|
4150
5699
|
const topicLease = await this.topicAuthorityLease(session.sessionId);
|
|
5700
|
+
if (!toolStartIsCurrent()) {
|
|
5701
|
+
abandonStaleToolStart();
|
|
5702
|
+
return;
|
|
5703
|
+
}
|
|
4151
5704
|
if (!topicId || !topicLease || topicLease.topicId !== topicId) {
|
|
4152
5705
|
if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
|
|
4153
|
-
await this.deliverFlatFallback(session.sessionId, send);
|
|
5706
|
+
await this.deliverFlatFallback(session.sessionId, send, toolActivity);
|
|
4154
5707
|
return;
|
|
4155
5708
|
}
|
|
4156
5709
|
if (send.identity) {
|
|
@@ -4198,7 +5751,11 @@ export class TelegramNotificationDaemon {
|
|
|
4198
5751
|
await this.persistTopics();
|
|
4199
5752
|
return;
|
|
4200
5753
|
}
|
|
4201
|
-
|
|
5754
|
+
if (!toolStartIsCurrent()) {
|
|
5755
|
+
abandonStaleToolStart();
|
|
5756
|
+
return;
|
|
5757
|
+
}
|
|
5758
|
+
await this.submitThreadedFrame(session.sessionId, send, topicLease, toolActivity);
|
|
4202
5759
|
return;
|
|
4203
5760
|
}
|
|
4204
5761
|
if (msg.type === "action_needed" && msg.id) {
|
|
@@ -4513,28 +6070,32 @@ export class TelegramNotificationDaemon {
|
|
|
4513
6070
|
}
|
|
4514
6071
|
}
|
|
4515
6072
|
}
|
|
4516
|
-
//
|
|
4517
|
-
//
|
|
4518
|
-
//
|
|
4519
|
-
// no session is connected and never becomes an ask answer.
|
|
6073
|
+
// Telegram delivery toggles are daemon-local policy, NOT session config
|
|
6074
|
+
// forwards. Handle them before threaded injection and independently of any
|
|
6075
|
+
// session WebSocket, so they work even when no session is connected.
|
|
4520
6076
|
{
|
|
4521
6077
|
const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
|
|
4522
6078
|
const chat = m?.chat as { id?: unknown } | undefined;
|
|
4523
6079
|
const cmdText = typeof m?.text === "string" ? m.text : undefined;
|
|
4524
|
-
const
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
6080
|
+
const commandToken = cmdText?.trim().split(/\s+/)[0]?.toLowerCase();
|
|
6081
|
+
const [command, commandSuffix] = commandToken?.split("@", 2) ?? [];
|
|
6082
|
+
const isRichCommand = command === "/rich";
|
|
6083
|
+
const isToolActivityCommand = command === "/toolactivity";
|
|
6084
|
+
if (
|
|
6085
|
+
isToolActivityCommand &&
|
|
6086
|
+
commandSuffix &&
|
|
6087
|
+
(!this.botUsername || commandSuffix !== this.botUsername.toLowerCase())
|
|
6088
|
+
)
|
|
6089
|
+
return;
|
|
6090
|
+
if (
|
|
6091
|
+
m !== undefined &&
|
|
6092
|
+
String(chat?.id) === String(this.opts.chatId) &&
|
|
6093
|
+
(isRichCommand || isToolActivityCommand)
|
|
6094
|
+
) {
|
|
6095
|
+
// These commands mutate global config, so honor them ONLY in the
|
|
6096
|
+
// configured private chat. Fail closed for legacy or hand-edited group IDs.
|
|
4535
6097
|
if (!(await this.pairedChatIsPrivate())) return;
|
|
4536
6098
|
const updateId = (update as { update_id?: number }).update_id;
|
|
4537
|
-
// Dedupe redelivered updates so a toggle+confirmation runs at most once.
|
|
4538
6099
|
if (typeof updateId === "number") {
|
|
4539
6100
|
if (this.dispatchState.seenUpdateIds.has(updateId)) return;
|
|
4540
6101
|
await this.rememberSeenUpdateId(updateId);
|
|
@@ -4553,31 +6114,70 @@ export class TelegramNotificationDaemon {
|
|
|
4553
6114
|
// Best-effort confirmation; never block on the notice.
|
|
4554
6115
|
}
|
|
4555
6116
|
};
|
|
4556
|
-
const desired =
|
|
6117
|
+
const desired = isRichCommand
|
|
6118
|
+
? parseRichToggleCommand(cmdText ?? "")
|
|
6119
|
+
: parseToolActivityToggleCommand(cmdText ?? "", this.botUsername);
|
|
6120
|
+
const usage = isRichCommand ? "Usage: /rich on|off" : "Usage: /toolactivity on|off";
|
|
4557
6121
|
if (desired === undefined) {
|
|
4558
|
-
await reply(
|
|
6122
|
+
await reply(usage);
|
|
4559
6123
|
return;
|
|
4560
6124
|
}
|
|
6125
|
+
const settingPath = isRichCommand
|
|
6126
|
+
? "notifications.telegram.rich.enabled"
|
|
6127
|
+
: "notifications.telegram.toolActivity.enabled";
|
|
6128
|
+
const label = isRichCommand ? "Rich messages" : "Tool activity";
|
|
4561
6129
|
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);
|
|
6130
|
+
await this.opts.settings.set(settingPath, desired);
|
|
6131
|
+
// Confirm only after the global config write is durable.
|
|
6132
|
+
await flushTelegramToggleSettings(this.opts.settings);
|
|
4572
6133
|
} catch (err) {
|
|
4573
6134
|
logger.warn(
|
|
4574
|
-
`notifications:
|
|
6135
|
+
`notifications: ${command} settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
|
|
4575
6136
|
);
|
|
4576
|
-
await reply(
|
|
6137
|
+
await reply(`${label}: unchanged (settings write failed)`);
|
|
4577
6138
|
return;
|
|
4578
6139
|
}
|
|
4579
|
-
|
|
4580
|
-
|
|
6140
|
+
if (isRichCommand) {
|
|
6141
|
+
this.opts.rich = { enabled: desired };
|
|
6142
|
+
} else {
|
|
6143
|
+
this.toolActivityPolicyEpoch++;
|
|
6144
|
+
this.opts.toolActivity = { enabled: desired };
|
|
6145
|
+
if (!desired) {
|
|
6146
|
+
const removedTools = this.pool.removeWhere(
|
|
6147
|
+
item => item.lane === "live" && item.coalesceKey?.startsWith("tool:") === true,
|
|
6148
|
+
);
|
|
6149
|
+
for (const item of removedTools) {
|
|
6150
|
+
const toolActivity = item.payload.toolActivity;
|
|
6151
|
+
if (!toolActivity) continue;
|
|
6152
|
+
const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
6153
|
+
const owner = this.toolActivityOwners.get(key);
|
|
6154
|
+
if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
|
|
6155
|
+
this.toolActivityOwners.delete(key);
|
|
6156
|
+
}
|
|
6157
|
+
for (const [sessionId, frames] of this.pendingThreadedFrames) {
|
|
6158
|
+
for (const frame of frames) {
|
|
6159
|
+
const toolActivity = frame.toolActivity;
|
|
6160
|
+
if (!toolActivity || frame.msg.type !== "tool_activity") continue;
|
|
6161
|
+
const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
6162
|
+
const owner = this.toolActivityOwners.get(key);
|
|
6163
|
+
if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
|
|
6164
|
+
this.toolActivityOwners.delete(key);
|
|
6165
|
+
}
|
|
6166
|
+
const retained = frames.filter(frame => frame.msg.type !== "tool_activity");
|
|
6167
|
+
if (retained.length === 0) this.pendingThreadedFrames.delete(sessionId);
|
|
6168
|
+
else this.pendingThreadedFrames.set(sessionId, retained);
|
|
6169
|
+
}
|
|
6170
|
+
for (const session of this.sessions.values()) {
|
|
6171
|
+
session.replayQueue = session.replayQueue.filter(frame => frame.type !== "tool_activity");
|
|
6172
|
+
}
|
|
6173
|
+
}
|
|
6174
|
+
// The policy flips before joining the serialized dispatch chain:
|
|
6175
|
+
// future starts are rejected immediately, queued starts are removed,
|
|
6176
|
+
// and any already-granted Bot API effect settles before the off
|
|
6177
|
+
// acknowledgement. Visible starts may still receive their terminal edit.
|
|
6178
|
+
await this.flushChain;
|
|
6179
|
+
}
|
|
6180
|
+
await reply(`${label}: ${desired ? "on" : "off"}`);
|
|
4581
6181
|
return;
|
|
4582
6182
|
}
|
|
4583
6183
|
}
|
|
@@ -4895,6 +6495,10 @@ export class TelegramNotificationDaemon {
|
|
|
4895
6495
|
{ command: "lean", description: "Mirror assistant text + tool names only (default)" },
|
|
4896
6496
|
{ command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
|
|
4897
6497
|
{ command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
|
|
6498
|
+
{
|
|
6499
|
+
command: "toolactivity",
|
|
6500
|
+
description: "Toggle tool activity updates: /toolactivity <on|off>",
|
|
6501
|
+
},
|
|
4898
6502
|
{ command: "reasoning", description: "Show or change reasoning effort in this session" },
|
|
4899
6503
|
{ command: "usage", description: "Show provider/local usage for this session" },
|
|
4900
6504
|
{ command: "model", description: "Select a model for this session" },
|
|
@@ -4923,11 +6527,13 @@ export class TelegramNotificationDaemon {
|
|
|
4923
6527
|
this.running = await renewDaemonHeartbeat({
|
|
4924
6528
|
settings: this.opts.settings,
|
|
4925
6529
|
ownerId: this.opts.ownerId,
|
|
6530
|
+
acquisitionId: this.opts.ownerId,
|
|
4926
6531
|
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
4927
6532
|
chatId: this.opts.chatId,
|
|
4928
6533
|
fs: this.fsImpl,
|
|
4929
6534
|
now: this.opts.now,
|
|
4930
6535
|
pid: this.opts.pid ?? process.pid,
|
|
6536
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
4931
6537
|
});
|
|
4932
6538
|
if (!this.running) return;
|
|
4933
6539
|
this.runtime.start();
|
|
@@ -4949,7 +6555,20 @@ export class TelegramNotificationDaemon {
|
|
|
4949
6555
|
let idleSince = this.runtime.now();
|
|
4950
6556
|
while (this.running) {
|
|
4951
6557
|
if (await this.controlStopRequested()) break;
|
|
4952
|
-
if (
|
|
6558
|
+
if (
|
|
6559
|
+
!(await renewDaemonHeartbeat({
|
|
6560
|
+
settings: this.opts.settings,
|
|
6561
|
+
ownerId: this.opts.ownerId,
|
|
6562
|
+
acquisitionId: this.opts.ownerId,
|
|
6563
|
+
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
6564
|
+
chatId: this.opts.chatId,
|
|
6565
|
+
fs: this.fsImpl,
|
|
6566
|
+
now: this.opts.now,
|
|
6567
|
+
pid: this.opts.pid ?? process.pid,
|
|
6568
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
6569
|
+
}))
|
|
6570
|
+
)
|
|
6571
|
+
break;
|
|
4953
6572
|
await this.runScan();
|
|
4954
6573
|
if (await this.controlStopRequested()) break;
|
|
4955
6574
|
const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
|
|
@@ -4984,10 +6603,19 @@ export class TelegramNotificationDaemon {
|
|
|
4984
6603
|
await this.runtime.sleep(10);
|
|
4985
6604
|
}
|
|
4986
6605
|
} finally {
|
|
6606
|
+
let toolShutdownError: unknown;
|
|
6607
|
+
try {
|
|
6608
|
+
await this.beginToolActivityShutdown();
|
|
6609
|
+
} catch (error) {
|
|
6610
|
+
toolShutdownError = error;
|
|
6611
|
+
}
|
|
4987
6612
|
this.effects.beginShutdown();
|
|
6613
|
+
this.#deliveryAbort.abort();
|
|
4988
6614
|
let persisted = false;
|
|
4989
6615
|
const shutdown = this.effects.allowTerminal(async () => {
|
|
6616
|
+
if (toolShutdownError) throw toolShutdownError;
|
|
4990
6617
|
await this.#drainBtwTurns();
|
|
6618
|
+
await this.toolTerminalizationChain;
|
|
4991
6619
|
this.runtime.stop();
|
|
4992
6620
|
this.stopOwnershipHeartbeatTimer();
|
|
4993
6621
|
this.stopFlushTimer();
|
|
@@ -5021,9 +6649,12 @@ export class TelegramNotificationDaemon {
|
|
|
5021
6649
|
await releaseDaemonOwnership({
|
|
5022
6650
|
settings: this.opts.settings,
|
|
5023
6651
|
ownerId: this.opts.ownerId,
|
|
6652
|
+
acquisitionId: this.opts.ownerId,
|
|
5024
6653
|
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
5025
6654
|
chatId: this.opts.chatId,
|
|
5026
6655
|
pid: this.opts.pid ?? process.pid,
|
|
6656
|
+
generation: DAEMON_GENERATION,
|
|
6657
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
5027
6658
|
fs: this.fsImpl,
|
|
5028
6659
|
now: this.opts.now,
|
|
5029
6660
|
});
|