@gajae-code/coding-agent 0.11.9 → 0.11.11
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 +56 -1
- package/dist/types/cli.d.ts +8 -1
- package/dist/types/config/model-profile-activation.d.ts +1 -0
- package/dist/types/config/model-profile-contract.d.ts +2 -0
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +57 -2
- package/dist/types/config/settings.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +21 -0
- package/dist/types/gjc-runtime/linux-proc.d.ts +22 -14
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +60 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +3 -1
- package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
- package/dist/types/modes/acp/acp-agent.d.ts +14 -9
- package/dist/types/modes/components/queue-pane.d.ts +8 -0
- package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
- package/dist/types/modes/components/welcome.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
- package/dist/types/modes/interactive-mode.d.ts +5 -1
- package/dist/types/runtime/memory-domain.d.ts +9 -0
- package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
- package/dist/types/runtime/memory-guard.d.ts +39 -0
- package/dist/types/runtime/memory-limit.d.ts +11 -0
- package/dist/types/runtime-mcp/manager.d.ts +8 -0
- package/dist/types/sdk/acp/final-text.d.ts +19 -0
- package/dist/types/sdk/acp/mcp.d.ts +23 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
- package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +11 -2
- package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
- package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +14 -7
- package/dist/types/sdk/bus/telegram-daemon.d.ts +97 -8
- package/dist/types/sdk/host/control/operations.d.ts +1 -0
- package/dist/types/sdk/lifecycle-session.d.ts +10 -1
- package/dist/types/sdk/session.d.ts +7 -0
- package/dist/types/sdk/startup-capability.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +11 -11
- package/dist/types/session/fallback-chain-controller.d.ts +18 -2
- package/dist/types/session/internal/managed-session-scope.d.ts +28 -1
- package/dist/types/session/session-manager.d.ts +34 -0
- package/dist/types/task/executor.d.ts +6 -0
- package/dist/types/task/provider-retry-status.d.ts +13 -0
- package/dist/types/task/types.d.ts +39 -0
- package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
- package/dist/types/tools/index.d.ts +3 -1
- package/dist/types/tools/resource-gc.d.ts +19 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +3 -0
- package/scripts/run-test-manifest.ts +6 -4
- package/src/cli/args.ts +14 -8
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/notify-cli.ts +1 -0
- package/src/cli.ts +48 -2
- package/src/commands/sdk.ts +91 -7
- package/src/config/model-profile-activation.ts +1 -0
- package/src/config/model-profile-contract.ts +13 -0
- package/src/config/model-profiles.ts +31 -7
- package/src/config/model-registry.ts +4 -2
- package/src/config/settings-schema.ts +61 -2
- package/src/config/settings.ts +8 -0
- package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -2
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +1 -1
- package/src/eval/js/tool-bridge.ts +1 -1
- package/src/extensibility/extensions/runner.ts +4 -0
- package/src/extensibility/extensions/types.ts +14 -0
- package/src/gjc-runtime/deep-interview-ambiguity.ts +16 -2
- package/src/gjc-runtime/deep-interview-draft.ts +2 -1
- package/src/gjc-runtime/deep-interview-recorder.ts +16 -1
- package/src/gjc-runtime/deep-interview-state.ts +25 -3
- package/src/gjc-runtime/linux-proc.ts +70 -36
- package/src/gjc-runtime/ralplan-runtime.ts +252 -1
- package/src/gjc-runtime/state-runtime.ts +48 -11
- package/src/gjc-runtime/state-writer.ts +1 -1
- package/src/hooks/skill-state.ts +23 -1
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/internal-urls/local-protocol.ts +23 -4
- package/src/modes/acp/acp-agent.ts +554 -60
- package/src/modes/acp/acp-event-mapper.ts +93 -6
- package/src/modes/components/model-selector.ts +15 -1
- package/src/modes/components/notifications-settings-editor.ts +1 -1
- package/src/modes/components/queue-pane.ts +55 -9
- package/src/modes/components/queued-message-selector.ts +53 -10
- package/src/modes/components/tool-status-header.ts +1 -4
- package/src/modes/components/welcome.ts +18 -3
- package/src/modes/controllers/command-controller.ts +4 -0
- package/src/modes/controllers/extension-ui-controller.ts +4 -10
- package/src/modes/controllers/input-controller.ts +44 -3
- package/src/modes/controllers/selector-controller.ts +38 -8
- package/src/modes/interactive-mode.ts +65 -26
- package/src/modes/runtime-init.ts +1 -0
- package/src/prompts/agent-fragments/restricted-bash.md +1 -1
- package/src/prompts/agents/architect.md +8 -1
- package/src/prompts/agents/critic.md +8 -1
- package/src/prompts/agents/executor.md +1 -1
- package/src/prompts/agents/planner.md +8 -1
- package/src/runtime/memory-domain.ts +66 -0
- package/src/runtime/memory-guard-contract.ts +73 -0
- package/src/runtime/memory-guard.ts +292 -0
- package/src/runtime/memory-limit.ts +50 -0
- package/src/runtime-mcp/manager.ts +26 -6
- package/src/sdk/acp/adapter.ts +8 -1
- package/src/sdk/acp/final-text.ts +40 -0
- package/src/sdk/acp/mcp.ts +26 -0
- package/src/sdk/broker/identity.ts +22 -12
- package/src/sdk/broker/lifecycle.ts +82 -1
- package/src/sdk/bus/chat-daemon-cli.ts +2 -2
- package/src/sdk/bus/chat-daemon-control.ts +4 -3
- package/src/sdk/bus/config.ts +1 -1
- package/src/sdk/bus/daemon-paths.ts +2 -0
- package/src/sdk/bus/index.ts +222 -27
- package/src/sdk/bus/notification-service.ts +19 -5
- package/src/sdk/bus/operator-runtime.ts +31 -5
- package/src/sdk/bus/rate-limit-pool.ts +8 -0
- package/src/sdk/bus/telegram-cli.ts +6 -13
- package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +15 -7
- package/src/sdk/bus/telegram-daemon-control.ts +14 -5
- package/src/sdk/bus/telegram-daemon.ts +1191 -164
- package/src/sdk/host/control/dispatch.ts +2 -0
- package/src/sdk/host/control/operations.ts +1 -0
- package/src/sdk/host/host.ts +2 -2
- package/src/sdk/lifecycle-session.ts +19 -3
- package/src/sdk/protocol/operation-inventory.generated.json +58 -0
- package/src/sdk/protocol/operation-registry.ts +1 -0
- package/src/sdk/session.ts +24 -1
- package/src/sdk/startup-capability.ts +6 -0
- package/src/session/agent-session.ts +290 -132
- package/src/session/fallback-chain-controller.ts +47 -8
- package/src/session/internal/managed-session-scope.ts +173 -127
- package/src/session/internal/native-publish-outcome.ts +8 -3
- package/src/session/session-manager.ts +608 -25
- package/src/skill-state/workflow-mutation-guard.ts +41 -7
- package/src/task/executor.ts +152 -42
- package/src/task/index.ts +2 -0
- package/src/task/provider-retry-status.ts +88 -0
- package/src/task/render.ts +45 -26
- package/src/task/types.ts +25 -11
- package/src/task/ultragoal-redteam-activation.ts +75 -0
- package/src/tools/bash-allowed-prefixes.ts +45 -1
- package/src/tools/index.ts +3 -1
- package/src/tools/resource-gc.ts +496 -120
- package/src/tools/subagent-render.ts +74 -16
- package/src/tools/subagent.ts +2 -0
- package/src/workflow/workflow-intent-diff.ts +4 -1
- package/vendor/insane-search/MANIFEST.json +4 -1
- package/vendor/insane-search/engine/bias_check.py +5 -0
- package/vendor/insane-search/engine/fetch_chain.py +67 -1
- package/vendor/insane-search/engine/transport.py +13 -2
- package/vendor/insane-search/engine/validators.py +4 -1
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
releaseDaemonTransitionLock,
|
|
32
32
|
sanitizeDiagnostic,
|
|
33
33
|
} from "./notification-service";
|
|
34
|
-
import { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
|
|
34
|
+
import { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION, SERVING_EPOCH } from "./telegram-daemon-contract";
|
|
35
35
|
import {
|
|
36
36
|
type DaemonProcessReference,
|
|
37
37
|
type TelegramDaemonControlDeps,
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
} from "./telegram-daemon-control";
|
|
40
40
|
import { type TelegramSetupPreflight, withTelegramSetupLease } from "./telegram-setup";
|
|
41
41
|
|
|
42
|
-
export { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
|
|
42
|
+
export { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION, SERVING_EPOCH } from "./telegram-daemon-contract";
|
|
43
43
|
|
|
44
44
|
import {
|
|
45
45
|
buildButtonGrid,
|
|
@@ -132,6 +132,8 @@ export interface DaemonState {
|
|
|
132
132
|
* notification wire protocol version. Absent on pre-generation state.
|
|
133
133
|
*/
|
|
134
134
|
generation?: number;
|
|
135
|
+
/** Lifecycle-serving compatibility epoch; absent pre-epoch records are epoch 1. */
|
|
136
|
+
servingEpoch?: number;
|
|
135
137
|
stoppedAt?: number;
|
|
136
138
|
}
|
|
137
139
|
export interface TelegramDaemonFs {
|
|
@@ -191,8 +193,20 @@ export const CLIENT_PING_PONG_CAPABILITY = "client_ping_pong";
|
|
|
191
193
|
/** Capability required for typed controls and semantic Selected acknowledgement frames. */
|
|
192
194
|
export const ASK_SELECTED_ACK_CAPABILITY = "ask_selected_ack_v1";
|
|
193
195
|
export const ASK_CONTROLS_CAPABILITY = "ask_controls_v1";
|
|
194
|
-
/** Capability
|
|
195
|
-
export const TOOL_ACTIVITY_CAPABILITY = "
|
|
196
|
+
/** Capability for the closed tool phase set: started, completed, failed, and cancelled. */
|
|
197
|
+
export const TOOL_ACTIVITY_CAPABILITY = "tool_activity_v2";
|
|
198
|
+
/** Receive-only compatibility capability for pre-v2 hosts. */
|
|
199
|
+
export const LEGACY_TOOL_ACTIVITY_CAPABILITY = "tool_activity_v1";
|
|
200
|
+
type ToolActivityCapability = "v1" | "v2";
|
|
201
|
+
|
|
202
|
+
function negotiateToolActivityCapability(
|
|
203
|
+
current: ToolActivityCapability | undefined,
|
|
204
|
+
capabilities: unknown[],
|
|
205
|
+
): ToolActivityCapability | undefined {
|
|
206
|
+
if (current === "v2" || capabilities.includes(TOOL_ACTIVITY_CAPABILITY)) return "v2";
|
|
207
|
+
if (current === "v1" || capabilities.includes(LEGACY_TOOL_ACTIVITY_CAPABILITY)) return "v1";
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
196
210
|
|
|
197
211
|
const nodeFs: TelegramDaemonFs = {
|
|
198
212
|
...(fs.promises as unknown as TelegramDaemonFs),
|
|
@@ -241,6 +255,18 @@ const RELOAD_FRESHNESS_WAIT_MS = 15_000;
|
|
|
241
255
|
const RELOAD_CONTROLLER_GRACEFUL_MS = 8_000;
|
|
242
256
|
const RELOAD_CONTROLLER_KILL_MS = 3_000;
|
|
243
257
|
const RELOAD_RESERVATION_HEADROOM_MS = 10_000;
|
|
258
|
+
const ROOTS_REGISTRATION_LOCK_RETRY_DELAY_MS = 100;
|
|
259
|
+
const ROOTS_REGISTRATION_LOCK_HEADROOM_MS = 5_000;
|
|
260
|
+
const ROOTS_REGISTRATION_LOCK_RETRIES =
|
|
261
|
+
Math.ceil(
|
|
262
|
+
(RELOAD_CONTROLLER_GRACEFUL_MS + RELOAD_CONTROLLER_KILL_MS + ROOTS_REGISTRATION_LOCK_HEADROOM_MS) /
|
|
263
|
+
ROOTS_REGISTRATION_LOCK_RETRY_DELAY_MS,
|
|
264
|
+
) + 1;
|
|
265
|
+
const ROOTS_REGISTRATION_LOCK_OPTIONS = {
|
|
266
|
+
staleMs: 10_000,
|
|
267
|
+
retries: ROOTS_REGISTRATION_LOCK_RETRIES,
|
|
268
|
+
retryDelayMs: ROOTS_REGISTRATION_LOCK_RETRY_DELAY_MS,
|
|
269
|
+
};
|
|
244
270
|
|
|
245
271
|
/**
|
|
246
272
|
* File-lock options whose acquisition budget covers the full reload-reservation
|
|
@@ -656,6 +682,162 @@ function ownershipLockMatchesState(lock: OwnershipLockRead, state: DaemonState |
|
|
|
656
682
|
);
|
|
657
683
|
}
|
|
658
684
|
|
|
685
|
+
type OwnerHeartbeatSidecar = {
|
|
686
|
+
pid: number;
|
|
687
|
+
incarnation: string;
|
|
688
|
+
ownerId: string;
|
|
689
|
+
acquisitionId: string;
|
|
690
|
+
heartbeatAt: number;
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
function ownerTagFromState(
|
|
694
|
+
state: DaemonState | undefined,
|
|
695
|
+
): Pick<OwnerHeartbeatSidecar, "pid" | "incarnation" | "ownerId" | "acquisitionId"> | undefined {
|
|
696
|
+
if (
|
|
697
|
+
!state ||
|
|
698
|
+
!hasSafeDaemonStateShape(state) ||
|
|
699
|
+
state.stoppedAt !== undefined ||
|
|
700
|
+
!state.ownerId ||
|
|
701
|
+
!state.acquisitionId
|
|
702
|
+
)
|
|
703
|
+
return undefined;
|
|
704
|
+
return {
|
|
705
|
+
pid: state.pid,
|
|
706
|
+
incarnation: state.incarnation,
|
|
707
|
+
ownerId: state.ownerId,
|
|
708
|
+
acquisitionId: state.acquisitionId,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function sidecarMatchesOwnerTag(
|
|
713
|
+
sidecar: OwnerHeartbeatSidecar | undefined,
|
|
714
|
+
tag: Pick<OwnerHeartbeatSidecar, "pid" | "incarnation" | "ownerId" | "acquisitionId"> | undefined,
|
|
715
|
+
): sidecar is OwnerHeartbeatSidecar {
|
|
716
|
+
return Boolean(
|
|
717
|
+
sidecar &&
|
|
718
|
+
tag &&
|
|
719
|
+
validDaemonPid(sidecar.pid) &&
|
|
720
|
+
isProcessIncarnation(sidecar.incarnation) &&
|
|
721
|
+
typeof sidecar.ownerId === "string" &&
|
|
722
|
+
typeof sidecar.acquisitionId === "string" &&
|
|
723
|
+
Number.isSafeInteger(sidecar.heartbeatAt) &&
|
|
724
|
+
sidecar.pid === tag.pid &&
|
|
725
|
+
sidecar.incarnation === tag.incarnation &&
|
|
726
|
+
sidecar.ownerId === tag.ownerId &&
|
|
727
|
+
sidecar.acquisitionId === tag.acquisitionId,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* A corrupt, truncated, or otherwise unreadable sidecar must never break a
|
|
733
|
+
* freshness read: the sidecar is advisory-only, so any read/parse failure
|
|
734
|
+
* degrades to "no sidecar" (state-floor freshness), never a throw.
|
|
735
|
+
*/
|
|
736
|
+
async function readSidecarLenient(fsImpl: TelegramDaemonFs, file: string): Promise<OwnerHeartbeatSidecar | undefined> {
|
|
737
|
+
try {
|
|
738
|
+
const parsed: unknown = JSON.parse(await fsImpl.readFile(file, "utf8"));
|
|
739
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
740
|
+
? (parsed as OwnerHeartbeatSidecar)
|
|
741
|
+
: undefined;
|
|
742
|
+
} catch {
|
|
743
|
+
return undefined;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
export interface OwnerFreshnessSnapshot {
|
|
748
|
+
ownerTag: Pick<OwnerHeartbeatSidecar, "pid" | "incarnation" | "ownerId" | "acquisitionId"> | null;
|
|
749
|
+
effectiveHeartbeatAt: number | undefined;
|
|
750
|
+
legacyEmbedded: boolean;
|
|
751
|
+
state: DaemonState | undefined;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
export async function readOwnerFreshnessSnapshot(input: {
|
|
755
|
+
settings: Settings;
|
|
756
|
+
fs?: TelegramDaemonFs;
|
|
757
|
+
}): Promise<OwnerFreshnessSnapshot> {
|
|
758
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
759
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
760
|
+
const state = await readJson<DaemonState>(fsImpl, paths.state);
|
|
761
|
+
const legacyEmbedded = Boolean(
|
|
762
|
+
state && (isGenerationAbsentParentDaemonState(state) || isGeneration3ReleaseDaemonState(state)),
|
|
763
|
+
);
|
|
764
|
+
const tag = ownerTagFromState(state);
|
|
765
|
+
const lock = await readOwnershipLock(fsImpl, paths.lock);
|
|
766
|
+
const ownerTag = tag && ownershipLockMatchesState(lock, state) ? tag : null;
|
|
767
|
+
if (legacyEmbedded) return { ownerTag, effectiveHeartbeatAt: state?.heartbeatAt, legacyEmbedded, state };
|
|
768
|
+
const sidecar = await readSidecarLenient(fsImpl, paths.heartbeat);
|
|
769
|
+
const rereadState = await readJson<DaemonState>(fsImpl, paths.state);
|
|
770
|
+
const rereadLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
771
|
+
const rereadTag = ownerTagFromState(rereadState);
|
|
772
|
+
const stableTag = rereadTag && ownershipLockMatchesState(rereadLock, rereadState) ? rereadTag : null;
|
|
773
|
+
const stable =
|
|
774
|
+
ownerTag && stableTag && sidecarMatchesOwnerTag({ ...ownerTag, heartbeatAt: 0 }, stableTag) ? stableTag : null;
|
|
775
|
+
const effectiveHeartbeatAt = stable
|
|
776
|
+
? sidecarMatchesOwnerTag(sidecar, stable)
|
|
777
|
+
? Math.max(rereadState?.heartbeatAt ?? 0, sidecar.heartbeatAt)
|
|
778
|
+
: rereadState?.heartbeatAt
|
|
779
|
+
: undefined;
|
|
780
|
+
return {
|
|
781
|
+
ownerTag: stable,
|
|
782
|
+
effectiveHeartbeatAt,
|
|
783
|
+
legacyEmbedded: false,
|
|
784
|
+
state: rereadState,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Marker-free steady heartbeat renewal. The final lock reread fences a stale writer. */
|
|
789
|
+
export async function renewOwnerHeartbeatSidecar(input: {
|
|
790
|
+
settings: Settings;
|
|
791
|
+
ownerId: string;
|
|
792
|
+
acquisitionId?: string;
|
|
793
|
+
fs?: TelegramDaemonFs;
|
|
794
|
+
now?: () => number;
|
|
795
|
+
pid?: number;
|
|
796
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
797
|
+
}): Promise<boolean> {
|
|
798
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
799
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
800
|
+
const state = await readJson<DaemonState>(fsImpl, paths.state);
|
|
801
|
+
const pid = input.pid ?? state?.pid;
|
|
802
|
+
const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(pid ?? 0);
|
|
803
|
+
const acquisitionId = input.acquisitionId ?? input.ownerId;
|
|
804
|
+
if (
|
|
805
|
+
!state ||
|
|
806
|
+
!validDaemonPid(pid) ||
|
|
807
|
+
!isProcessIncarnation(incarnation) ||
|
|
808
|
+
state.pid !== pid ||
|
|
809
|
+
state.incarnation !== incarnation ||
|
|
810
|
+
state.ownerId !== input.ownerId ||
|
|
811
|
+
state.acquisitionId !== acquisitionId ||
|
|
812
|
+
state.stoppedAt !== undefined
|
|
813
|
+
)
|
|
814
|
+
return false;
|
|
815
|
+
const sidecar: OwnerHeartbeatSidecar = {
|
|
816
|
+
pid,
|
|
817
|
+
incarnation,
|
|
818
|
+
ownerId: input.ownerId,
|
|
819
|
+
acquisitionId,
|
|
820
|
+
heartbeatAt: (input.now ?? Date.now)(),
|
|
821
|
+
};
|
|
822
|
+
const tmp = `${paths.heartbeat}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
823
|
+
await fsImpl.writeFile(tmp, `${JSON.stringify(sidecar, null, 2)}\n`, { mode: 0o600 });
|
|
824
|
+
await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
|
|
825
|
+
const lock = await readOwnershipLock(fsImpl, paths.lock);
|
|
826
|
+
if (!ownershipLockMatchesState(lock, state)) {
|
|
827
|
+
await fsImpl.unlink(tmp).catch(() => undefined);
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
// Revalidate the exact lock after the temporary sidecar exists so a stale
|
|
831
|
+
// writer cannot publish after ownership moved during its write.
|
|
832
|
+
const finalLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
833
|
+
if (!ownershipLockMatchesState(finalLock, state)) {
|
|
834
|
+
await fsImpl.unlink(tmp).catch(() => undefined);
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
await fsImpl.rename(tmp, paths.heartbeat);
|
|
838
|
+
return true;
|
|
839
|
+
}
|
|
840
|
+
|
|
659
841
|
function ownershipLockMatchesStoppedState(
|
|
660
842
|
lock: OwnershipLockRead,
|
|
661
843
|
state: unknown,
|
|
@@ -800,23 +982,29 @@ async function ownershipLockIsReclaimable(input: {
|
|
|
800
982
|
);
|
|
801
983
|
}
|
|
802
984
|
|
|
803
|
-
interface NotificationRootRegistration {
|
|
985
|
+
export interface NotificationRootRegistration {
|
|
804
986
|
root?: string;
|
|
805
987
|
managed?: boolean;
|
|
988
|
+
token?: string;
|
|
806
989
|
}
|
|
807
990
|
|
|
808
|
-
async function readNotificationRootRegistration(input: {
|
|
991
|
+
export async function readNotificationRootRegistration(input: {
|
|
809
992
|
settings: Settings;
|
|
810
993
|
sessionId: string;
|
|
811
994
|
fs?: TelegramDaemonFs;
|
|
812
995
|
}): Promise<NotificationRootRegistration> {
|
|
813
996
|
const fsImpl = input.fs ?? nodeFs;
|
|
814
|
-
const current = await readJson<{
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
997
|
+
const current = await readJson<{
|
|
998
|
+
sessions?: Record<string, string>;
|
|
999
|
+
managedRoots?: string[];
|
|
1000
|
+
registrationTokens?: Record<string, string>;
|
|
1001
|
+
}>(fsImpl, daemonPaths(input.settings.getAgentDir()).roots);
|
|
818
1002
|
const root = current?.sessions?.[input.sessionId];
|
|
819
|
-
return {
|
|
1003
|
+
return {
|
|
1004
|
+
root,
|
|
1005
|
+
managed: root !== undefined && current?.managedRoots?.includes(root) === true,
|
|
1006
|
+
token: current?.registrationTokens?.[input.sessionId],
|
|
1007
|
+
};
|
|
820
1008
|
}
|
|
821
1009
|
|
|
822
1010
|
/** Restore a session root only if this ensure operation still owns its registration. */
|
|
@@ -824,6 +1012,8 @@ async function restoreNotificationRootRegistration(input: {
|
|
|
824
1012
|
settings: Settings;
|
|
825
1013
|
sessionId: string;
|
|
826
1014
|
registeredRoot: string;
|
|
1015
|
+
/** Token minted by this operation's own registration; a newer replacement registration's token fences the rollback. */
|
|
1016
|
+
registeredToken?: string;
|
|
827
1017
|
previous: NotificationRootRegistration;
|
|
828
1018
|
fs?: TelegramDaemonFs;
|
|
829
1019
|
}): Promise<void> {
|
|
@@ -834,14 +1024,24 @@ async function restoreNotificationRootRegistration(input: {
|
|
|
834
1024
|
paths.roots,
|
|
835
1025
|
async () => {
|
|
836
1026
|
const current =
|
|
837
|
-
(await readJson<{
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1027
|
+
(await readJson<{
|
|
1028
|
+
roots?: string[];
|
|
1029
|
+
managedRoots?: string[];
|
|
1030
|
+
sessions?: Record<string, string>;
|
|
1031
|
+
registrationTokens?: Record<string, string>;
|
|
1032
|
+
}>(fsImpl, paths.roots)) ?? {};
|
|
841
1033
|
const sessions = { ...(current.sessions ?? {}) };
|
|
842
1034
|
if (sessions[input.sessionId] !== input.registeredRoot) return;
|
|
1035
|
+
if (
|
|
1036
|
+
current.registrationTokens?.[input.sessionId] !== undefined &&
|
|
1037
|
+
current.registrationTokens[input.sessionId] !== input.registeredToken
|
|
1038
|
+
)
|
|
1039
|
+
return;
|
|
1040
|
+
const registrationTokens = { ...(current.registrationTokens ?? {}) };
|
|
843
1041
|
if (input.previous.root) sessions[input.sessionId] = input.previous.root;
|
|
844
1042
|
else delete sessions[input.sessionId];
|
|
1043
|
+
if (input.previous.token !== undefined) registrationTokens[input.sessionId] = input.previous.token;
|
|
1044
|
+
else delete registrationTokens[input.sessionId];
|
|
845
1045
|
const referencedRoots = new Set(Object.values(sessions));
|
|
846
1046
|
const roots = new Set(current.roots ?? []);
|
|
847
1047
|
const managedRoots = new Set(current.managedRoots ?? []);
|
|
@@ -859,33 +1059,44 @@ async function restoreNotificationRootRegistration(input: {
|
|
|
859
1059
|
roots: Array.from(roots).sort(),
|
|
860
1060
|
managedRoots: Array.from(managedRoots).sort(),
|
|
861
1061
|
sessions,
|
|
1062
|
+
registrationTokens,
|
|
862
1063
|
});
|
|
863
1064
|
},
|
|
864
1065
|
{ staleMs: 10_000 },
|
|
865
1066
|
);
|
|
866
1067
|
}
|
|
867
1068
|
|
|
1069
|
+
export interface RegisterNotificationRootResult {
|
|
1070
|
+
root: string;
|
|
1071
|
+
/** Per-registration ownership token fencing a stale same-session cleanup from deleting a live replacement registration. */
|
|
1072
|
+
token: string;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
868
1075
|
export async function registerNotificationRoot(input: {
|
|
869
1076
|
settings: Settings;
|
|
870
1077
|
cwd: string;
|
|
871
1078
|
sessionId: string;
|
|
872
1079
|
fs?: TelegramDaemonFs;
|
|
873
|
-
}): Promise<
|
|
1080
|
+
}): Promise<RegisterNotificationRootResult> {
|
|
874
1081
|
const fsImpl = input.fs ?? nodeFs;
|
|
875
1082
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
876
1083
|
await ensureDir(fsImpl, paths.dir);
|
|
877
1084
|
const root = notificationRootForCwd(input.cwd);
|
|
1085
|
+
const token = crypto.randomUUID();
|
|
878
1086
|
await withFileLock(
|
|
879
1087
|
paths.roots,
|
|
880
1088
|
async () => {
|
|
881
1089
|
const current =
|
|
882
|
-
(await readJson<{
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1090
|
+
(await readJson<{
|
|
1091
|
+
roots?: string[];
|
|
1092
|
+
managedRoots?: string[];
|
|
1093
|
+
sessions?: Record<string, string>;
|
|
1094
|
+
registrationTokens?: Record<string, string>;
|
|
1095
|
+
}>(fsImpl, paths.roots)) ?? {};
|
|
886
1096
|
const roots = new Set(current.roots ?? []);
|
|
887
1097
|
const managedRoots = new Set(current.managedRoots ?? []);
|
|
888
1098
|
const sessions = { ...(current.sessions ?? {}) };
|
|
1099
|
+
const registrationTokens = { ...(current.registrationTokens ?? {}) };
|
|
889
1100
|
const previousRoot = sessions[input.sessionId];
|
|
890
1101
|
const rootAlreadyPresent = roots.has(root);
|
|
891
1102
|
roots.add(root);
|
|
@@ -893,6 +1104,7 @@ export async function registerNotificationRoot(input: {
|
|
|
893
1104
|
// survive a later session rollback or unregister.
|
|
894
1105
|
if (!rootAlreadyPresent) managedRoots.add(root);
|
|
895
1106
|
sessions[input.sessionId] = root;
|
|
1107
|
+
registrationTokens[input.sessionId] = token;
|
|
896
1108
|
if (previousRoot && previousRoot !== root && managedRoots.has(previousRoot)) {
|
|
897
1109
|
const previousStillReferenced = Object.values(sessions).includes(previousRoot);
|
|
898
1110
|
if (!previousStillReferenced) {
|
|
@@ -905,16 +1117,19 @@ export async function registerNotificationRoot(input: {
|
|
|
905
1117
|
roots: Array.from(roots).sort(),
|
|
906
1118
|
managedRoots: Array.from(managedRoots).sort(),
|
|
907
1119
|
sessions,
|
|
1120
|
+
registrationTokens,
|
|
908
1121
|
});
|
|
909
1122
|
},
|
|
910
|
-
|
|
1123
|
+
ROOTS_REGISTRATION_LOCK_OPTIONS,
|
|
911
1124
|
);
|
|
912
|
-
return root;
|
|
1125
|
+
return { root, token };
|
|
913
1126
|
}
|
|
914
1127
|
|
|
915
1128
|
export interface UnregisterNotificationRootResult {
|
|
916
1129
|
root: string;
|
|
917
1130
|
remainingRoots: number;
|
|
1131
|
+
/** Opaque fingerprint of the exact registry written by this successful unregister. */
|
|
1132
|
+
registryFingerprint?: string;
|
|
918
1133
|
}
|
|
919
1134
|
|
|
920
1135
|
/** Remove one session's Telegram scan root without disturbing other live session roots. */
|
|
@@ -923,20 +1138,19 @@ export async function unregisterNotificationRoot(input: {
|
|
|
923
1138
|
cwd: string;
|
|
924
1139
|
sessionId: string;
|
|
925
1140
|
fs?: TelegramDaemonFs;
|
|
1141
|
+
/** Ownership token minted by this runtime's own registration; a stale cleanup presenting an older token never deletes a replacement registration. */
|
|
1142
|
+
registrationToken?: string;
|
|
926
1143
|
}): Promise<UnregisterNotificationRootResult> {
|
|
927
1144
|
const fsImpl = input.fs ?? nodeFs;
|
|
928
1145
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
929
1146
|
const root = notificationRootForCwd(input.cwd);
|
|
930
1147
|
await ensureDir(fsImpl, paths.dir);
|
|
931
1148
|
let remainingRoots = 0;
|
|
1149
|
+
let registryFingerprint: string | undefined;
|
|
932
1150
|
await withFileLock(
|
|
933
1151
|
paths.roots,
|
|
934
1152
|
async () => {
|
|
935
|
-
const current = await readJson<
|
|
936
|
-
roots?: string[];
|
|
937
|
-
managedRoots?: string[];
|
|
938
|
-
sessions?: Record<string, string>;
|
|
939
|
-
}>(fsImpl, paths.roots);
|
|
1153
|
+
const current = await readJson<NotificationRootsRegistry>(fsImpl, paths.roots);
|
|
940
1154
|
if (!current) return;
|
|
941
1155
|
const sessions = { ...(current.sessions ?? {}) };
|
|
942
1156
|
// A stale cleanup from a previous registration must not remove a newer
|
|
@@ -945,7 +1159,19 @@ export async function unregisterNotificationRoot(input: {
|
|
|
945
1159
|
remainingRoots = (current.roots ?? []).length;
|
|
946
1160
|
return;
|
|
947
1161
|
}
|
|
1162
|
+
// Same sessionId + same cwd re-registration records an identical root, so
|
|
1163
|
+
// the root match alone cannot distinguish a replaced registration. Any
|
|
1164
|
+
// token-bearing registration requires the caller to present that exact token;
|
|
1165
|
+
// only genuinely legacy token-less rows retain root-match cleanup behavior.
|
|
1166
|
+
const recordedToken = current.registrationTokens?.[input.sessionId];
|
|
1167
|
+
if (recordedToken !== undefined && recordedToken !== input.registrationToken) {
|
|
1168
|
+
logger.warn("notifications: fenced stale Telegram root unregister against a live replacement registration");
|
|
1169
|
+
remainingRoots = (current.roots ?? []).length;
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
948
1172
|
delete sessions[input.sessionId];
|
|
1173
|
+
const registrationTokens = { ...(current.registrationTokens ?? {}) };
|
|
1174
|
+
delete registrationTokens[input.sessionId];
|
|
949
1175
|
const rootStillReferenced = Object.values(sessions).includes(root);
|
|
950
1176
|
const managedRoots = new Set(current.managedRoots ?? []);
|
|
951
1177
|
const roots = (current.roots ?? []).filter(
|
|
@@ -953,16 +1179,46 @@ export async function unregisterNotificationRoot(input: {
|
|
|
953
1179
|
);
|
|
954
1180
|
if (!rootStillReferenced) managedRoots.delete(root);
|
|
955
1181
|
remainingRoots = roots.length;
|
|
956
|
-
|
|
1182
|
+
const nextRegistry: NotificationRootsRegistry = {
|
|
957
1183
|
version: 1,
|
|
958
1184
|
roots: Array.from(new Set(roots)).sort(),
|
|
959
1185
|
managedRoots: Array.from(managedRoots).sort(),
|
|
960
1186
|
sessions,
|
|
961
|
-
|
|
1187
|
+
registrationTokens,
|
|
1188
|
+
};
|
|
1189
|
+
await writeJsonAtomic(fsImpl, paths.roots, nextRegistry);
|
|
1190
|
+
registryFingerprint = notificationRootRegistryFingerprint(nextRegistry);
|
|
1191
|
+
},
|
|
1192
|
+
{ staleMs: 10_000 },
|
|
1193
|
+
);
|
|
1194
|
+
return registryFingerprint === undefined ? { root, remainingRoots } : { root, remainingRoots, registryFingerprint };
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** Run an action while the roots registry lock excludes any registration or unregister. */
|
|
1198
|
+
export async function withNotificationRootRegistryFence(input: {
|
|
1199
|
+
settings: Settings;
|
|
1200
|
+
registryFingerprint: string;
|
|
1201
|
+
action: () => Promise<void>;
|
|
1202
|
+
fs?: TelegramDaemonFs;
|
|
1203
|
+
}): Promise<boolean> {
|
|
1204
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
1205
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
1206
|
+
await ensureDir(fsImpl, paths.dir);
|
|
1207
|
+
return await withFileLock(
|
|
1208
|
+
paths.roots,
|
|
1209
|
+
async () => {
|
|
1210
|
+
const current = await readJson<NotificationRootsRegistry>(fsImpl, paths.roots);
|
|
1211
|
+
if (
|
|
1212
|
+
!current ||
|
|
1213
|
+
(current.roots ?? []).length !== 0 ||
|
|
1214
|
+
notificationRootRegistryFingerprint(current) !== input.registryFingerprint
|
|
1215
|
+
)
|
|
1216
|
+
return false;
|
|
1217
|
+
await input.action();
|
|
1218
|
+
return true;
|
|
962
1219
|
},
|
|
963
1220
|
{ staleMs: 10_000 },
|
|
964
1221
|
);
|
|
965
|
-
return { root, remainingRoots };
|
|
966
1222
|
}
|
|
967
1223
|
|
|
968
1224
|
function notificationRootForCwd(cwd: string): string {
|
|
@@ -998,8 +1254,32 @@ type NotificationRootsRegistry = {
|
|
|
998
1254
|
roots?: string[];
|
|
999
1255
|
managedRoots?: string[];
|
|
1000
1256
|
sessions?: Record<string, string>;
|
|
1257
|
+
registrationTokens?: Record<string, string>;
|
|
1001
1258
|
};
|
|
1002
1259
|
|
|
1260
|
+
function notificationRootRegistryFingerprint(registry: NotificationRootsRegistry): string {
|
|
1261
|
+
return crypto
|
|
1262
|
+
.createHash("sha256")
|
|
1263
|
+
.update(
|
|
1264
|
+
JSON.stringify({
|
|
1265
|
+
version: registry.version,
|
|
1266
|
+
roots: Array.from(new Set(registry.roots ?? [])).sort(),
|
|
1267
|
+
managedRoots: Array.from(new Set(registry.managedRoots ?? [])).sort(),
|
|
1268
|
+
sessions: Object.fromEntries(
|
|
1269
|
+
Object.entries(registry.sessions ?? {}).sort(([left], [right]) =>
|
|
1270
|
+
left < right ? -1 : left > right ? 1 : 0,
|
|
1271
|
+
),
|
|
1272
|
+
),
|
|
1273
|
+
registrationTokens: Object.fromEntries(
|
|
1274
|
+
Object.entries(registry.registrationTokens ?? {}).sort(([left], [right]) =>
|
|
1275
|
+
left < right ? -1 : left > right ? 1 : 0,
|
|
1276
|
+
),
|
|
1277
|
+
),
|
|
1278
|
+
}),
|
|
1279
|
+
)
|
|
1280
|
+
.digest("hex");
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1003
1283
|
/**
|
|
1004
1284
|
* Drop permanently missing scan roots from the durable registry under the roots
|
|
1005
1285
|
* lock. Session map entries that pointed only at those roots are removed too.
|
|
@@ -1022,6 +1302,7 @@ export async function pruneMissingNotificationRoots(input: {
|
|
|
1022
1302
|
const roots = [...(current.roots ?? [])];
|
|
1023
1303
|
const managedRoots = new Set(current.managedRoots ?? []);
|
|
1024
1304
|
const sessions = { ...(current.sessions ?? {}) };
|
|
1305
|
+
const registrationTokens = { ...(current.registrationTokens ?? {}) };
|
|
1025
1306
|
const candidateSet = input.candidates ? new Set(input.candidates) : undefined;
|
|
1026
1307
|
const survivors: string[] = [];
|
|
1027
1308
|
for (const root of roots) {
|
|
@@ -1052,7 +1333,10 @@ export async function pruneMissingNotificationRoots(input: {
|
|
|
1052
1333
|
pruned.push(root);
|
|
1053
1334
|
managedRoots.delete(root);
|
|
1054
1335
|
for (const [sessionId, mapped] of Object.entries(sessions)) {
|
|
1055
|
-
if (mapped === root)
|
|
1336
|
+
if (mapped === root) {
|
|
1337
|
+
delete sessions[sessionId];
|
|
1338
|
+
delete registrationTokens[sessionId];
|
|
1339
|
+
}
|
|
1056
1340
|
}
|
|
1057
1341
|
}
|
|
1058
1342
|
}
|
|
@@ -1063,6 +1347,7 @@ export async function pruneMissingNotificationRoots(input: {
|
|
|
1063
1347
|
roots: Array.from(new Set(survivors)).sort(),
|
|
1064
1348
|
managedRoots: Array.from(managedRoots).sort(),
|
|
1065
1349
|
sessions,
|
|
1350
|
+
registrationTokens,
|
|
1066
1351
|
});
|
|
1067
1352
|
},
|
|
1068
1353
|
{ staleMs: 10_000 },
|
|
@@ -1220,7 +1505,9 @@ export function hasSafeDaemonStateShape(state: unknown): state is DaemonState {
|
|
|
1220
1505
|
candidate.version === DAEMON_VERSION &&
|
|
1221
1506
|
(candidate.generation === undefined ||
|
|
1222
1507
|
(Number.isSafeInteger(candidate.generation) && (candidate.generation as number) > 0)) &&
|
|
1223
|
-
(candidate.stoppedAt === undefined || Number.isSafeInteger(candidate.stoppedAt))
|
|
1508
|
+
(candidate.stoppedAt === undefined || Number.isSafeInteger(candidate.stoppedAt)) &&
|
|
1509
|
+
(candidate.servingEpoch === undefined ||
|
|
1510
|
+
(Number.isSafeInteger(candidate.servingEpoch) && (candidate.servingEpoch as number) > 0)),
|
|
1224
1511
|
);
|
|
1225
1512
|
}
|
|
1226
1513
|
|
|
@@ -1421,6 +1708,7 @@ async function legacyParentHandoffDecision(input: {
|
|
|
1421
1708
|
> {
|
|
1422
1709
|
const { state } = input;
|
|
1423
1710
|
if (!input.pidAlive(state.pid)) return undefined;
|
|
1711
|
+
if (state.generation !== 3) return { acquired: false, attached: false, blocked: true };
|
|
1424
1712
|
if (!ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
|
|
1425
1713
|
return { acquired: false, attached: false, blocked: true };
|
|
1426
1714
|
const incarnation = input.pidIncarnation(state.pid);
|
|
@@ -1522,12 +1810,7 @@ function isRecognizedLegacyGeneration(generation: number | undefined): boolean {
|
|
|
1522
1810
|
return generation === undefined || generation === 3;
|
|
1523
1811
|
}
|
|
1524
1812
|
|
|
1525
|
-
/**
|
|
1526
|
-
* A predecessor is reloadable only when it has the complete modern ownership
|
|
1527
|
-
* proof. Generation is absent from the first fully-provenanced modern records,
|
|
1528
|
-
* so it is an incompatible predecessor too; parent-format records remain
|
|
1529
|
-
* excluded because they lack this ownership proof.
|
|
1530
|
-
*/
|
|
1813
|
+
/** True when a modern owner carries complete ready-state provenance. */
|
|
1531
1814
|
function hasFullModernOwnerProvenance(
|
|
1532
1815
|
state: DaemonState | undefined,
|
|
1533
1816
|
pidIncarnation?: (pid: number) => string | undefined,
|
|
@@ -1542,19 +1825,6 @@ function hasFullModernOwnerProvenance(
|
|
|
1542
1825
|
);
|
|
1543
1826
|
}
|
|
1544
1827
|
|
|
1545
|
-
function isFullModernPredecessor(
|
|
1546
|
-
state: DaemonState | undefined,
|
|
1547
|
-
pidIncarnation?: (pid: number) => string | undefined,
|
|
1548
|
-
): boolean {
|
|
1549
|
-
return Boolean(
|
|
1550
|
-
hasFullModernOwnerProvenance(state, pidIncarnation) &&
|
|
1551
|
-
(state?.generation === undefined ||
|
|
1552
|
-
(Number.isSafeInteger(state?.generation) &&
|
|
1553
|
-
(state?.generation as number) > 0 &&
|
|
1554
|
-
(state?.generation as number) < DAEMON_GENERATION)),
|
|
1555
|
-
);
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
1828
|
/**
|
|
1559
1829
|
* Classifies a physically live daemon record before any replacement action.
|
|
1560
1830
|
*
|
|
@@ -1628,14 +1898,17 @@ export function isFreshLiveOwner(input: {
|
|
|
1628
1898
|
chatId: string;
|
|
1629
1899
|
pidAlive: (pid: number) => boolean;
|
|
1630
1900
|
pidIncarnation?: (pid: number) => string | undefined;
|
|
1901
|
+
effectiveHeartbeatAt?: number;
|
|
1631
1902
|
}): boolean {
|
|
1632
1903
|
const { state } = input;
|
|
1904
|
+
const heartbeatAt = Object.hasOwn(input, "effectiveHeartbeatAt") ? input.effectiveHeartbeatAt : state?.heartbeatAt;
|
|
1633
1905
|
return Boolean(
|
|
1634
1906
|
state &&
|
|
1907
|
+
heartbeatAt !== undefined &&
|
|
1635
1908
|
hasSafeDaemonStateShape(state) &&
|
|
1636
1909
|
state.stoppedAt === undefined &&
|
|
1637
1910
|
ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
|
|
1638
|
-
input.now -
|
|
1911
|
+
input.now - heartbeatAt <= HEARTBEAT_TTL_MS &&
|
|
1639
1912
|
input.pidAlive(state.pid) &&
|
|
1640
1913
|
ownerProvenanceMatches(state, input.pidIncarnation),
|
|
1641
1914
|
);
|
|
@@ -1649,6 +1922,7 @@ export function isCurrentCompatibleOwner(input: {
|
|
|
1649
1922
|
chatId: string;
|
|
1650
1923
|
pidAlive: (pid: number) => boolean;
|
|
1651
1924
|
pidIncarnation?: (pid: number) => string | undefined;
|
|
1925
|
+
effectiveHeartbeatAt?: number;
|
|
1652
1926
|
}): boolean {
|
|
1653
1927
|
const state = input.state;
|
|
1654
1928
|
return Boolean(
|
|
@@ -1656,8 +1930,7 @@ export function isCurrentCompatibleOwner(input: {
|
|
|
1656
1930
|
state?.ownershipPhase === "ready" &&
|
|
1657
1931
|
typeof state.acquisitionId === "string" &&
|
|
1658
1932
|
state.acquisitionId.length > 0 &&
|
|
1659
|
-
|
|
1660
|
-
(state.generation as number) >= DAEMON_GENERATION,
|
|
1933
|
+
(state.servingEpoch === undefined ? 1 : state.servingEpoch) >= SERVING_EPOCH,
|
|
1661
1934
|
);
|
|
1662
1935
|
}
|
|
1663
1936
|
|
|
@@ -1701,14 +1974,14 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1701
1974
|
input.ownerId ?? input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
1702
1975
|
const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
|
|
1703
1976
|
|
|
1704
|
-
//
|
|
1705
|
-
//
|
|
1706
|
-
// silent attach. Newer/equal generations attach as before (no downgrade).
|
|
1977
|
+
// Generation is inventory-only: a lower servingEpoch triggers convergence,
|
|
1978
|
+
// while same-epoch cross-generation owners attach.
|
|
1707
1979
|
const attachDecision = (
|
|
1708
|
-
|
|
1980
|
+
snapshot: OwnerFreshnessSnapshot,
|
|
1709
1981
|
):
|
|
1710
1982
|
| { acquired: false; attached: boolean; blocked?: boolean; provisional?: boolean; reloadRequired?: boolean }
|
|
1711
1983
|
| undefined => {
|
|
1984
|
+
const state = snapshot.state;
|
|
1712
1985
|
if (state && !hasSafeDaemonStateShape(state)) {
|
|
1713
1986
|
const malformed = state as Partial<DaemonState>;
|
|
1714
1987
|
if (
|
|
@@ -1741,6 +2014,7 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1741
2014
|
chatId: input.chatId,
|
|
1742
2015
|
pidAlive,
|
|
1743
2016
|
pidIncarnation,
|
|
2017
|
+
effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
|
|
1744
2018
|
})
|
|
1745
2019
|
)
|
|
1746
2020
|
return { acquired: false, attached: true };
|
|
@@ -1755,8 +2029,17 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1755
2029
|
chatId: input.chatId,
|
|
1756
2030
|
pidAlive,
|
|
1757
2031
|
pidIncarnation,
|
|
2032
|
+
effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
|
|
1758
2033
|
}) &&
|
|
1759
|
-
(state.version !== DAEMON_VERSION ||
|
|
2034
|
+
(state.version !== DAEMON_VERSION ||
|
|
2035
|
+
((state.servingEpoch === undefined ? 1 : state.servingEpoch) < SERVING_EPOCH &&
|
|
2036
|
+
isSignalableMatchingOwner({
|
|
2037
|
+
state,
|
|
2038
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
2039
|
+
chatId: input.chatId,
|
|
2040
|
+
pidAlive,
|
|
2041
|
+
pidIncarnation,
|
|
2042
|
+
})))
|
|
1760
2043
|
)
|
|
1761
2044
|
return { acquired: false, attached: false, reloadRequired: true };
|
|
1762
2045
|
return { acquired: false, attached: false, provisional: true };
|
|
@@ -1800,7 +2083,8 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1800
2083
|
const transition = await acquireTransitionLock({ fs: fsImpl, path: paths.steal, pidAlive, pidIncarnation });
|
|
1801
2084
|
if (!transition) return { acquired: false, attached: false, provisional: true };
|
|
1802
2085
|
try {
|
|
1803
|
-
const
|
|
2086
|
+
const recheckedSnapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: fsImpl });
|
|
2087
|
+
const rechecked = recheckedSnapshot.state;
|
|
1804
2088
|
const recheckedLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
1805
2089
|
const recheckedForeignOwner = classifyForeignLiveOwner({
|
|
1806
2090
|
state: rechecked,
|
|
@@ -1826,7 +2110,7 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1826
2110
|
})
|
|
1827
2111
|
)
|
|
1828
2112
|
return { acquired: false, attached: false, blocked: true };
|
|
1829
|
-
const recheckedDecision = isLegacyParentDaemonState(rechecked) ? undefined : attachDecision(
|
|
2113
|
+
const recheckedDecision = isLegacyParentDaemonState(rechecked) ? undefined : attachDecision(recheckedSnapshot);
|
|
1830
2114
|
if (
|
|
1831
2115
|
recheckedDecision &&
|
|
1832
2116
|
(recheckedDecision.attached ||
|
|
@@ -1923,6 +2207,7 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1923
2207
|
roots,
|
|
1924
2208
|
version: DAEMON_VERSION,
|
|
1925
2209
|
generation: DAEMON_GENERATION,
|
|
2210
|
+
servingEpoch: SERVING_EPOCH,
|
|
1926
2211
|
} satisfies DaemonState);
|
|
1927
2212
|
if (
|
|
1928
2213
|
!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })) ||
|
|
@@ -2022,17 +2307,20 @@ export async function renewDaemonHeartbeat(input: {
|
|
|
2022
2307
|
})))
|
|
2023
2308
|
)
|
|
2024
2309
|
return false;
|
|
2310
|
+
const heartbeatAt = (input.now ?? Date.now)();
|
|
2311
|
+
const boundState: DaemonState = {
|
|
2312
|
+
...state,
|
|
2313
|
+
// Preserve the source launcher PID so concurrent ensures can recognize
|
|
2314
|
+
// this bound child as its successor while it is still provisional.
|
|
2315
|
+
launcherPid: state.pid,
|
|
2316
|
+
pid,
|
|
2317
|
+
incarnation,
|
|
2318
|
+
ownershipPhase: "provisional",
|
|
2319
|
+
heartbeatAt,
|
|
2320
|
+
servingEpoch: SERVING_EPOCH,
|
|
2321
|
+
};
|
|
2025
2322
|
try {
|
|
2026
|
-
await writeJsonAtomic(fsImpl, paths.state,
|
|
2027
|
-
...state,
|
|
2028
|
-
// Preserve the source launcher PID so concurrent ensures can recognize
|
|
2029
|
-
// this ready PID as its child rather than excluding it as the launcher.
|
|
2030
|
-
launcherPid: state.pid,
|
|
2031
|
-
pid,
|
|
2032
|
-
incarnation,
|
|
2033
|
-
ownershipPhase: "ready",
|
|
2034
|
-
heartbeatAt: (input.now ?? Date.now)(),
|
|
2035
|
-
});
|
|
2323
|
+
await writeJsonAtomic(fsImpl, paths.state, boundState);
|
|
2036
2324
|
} catch {
|
|
2037
2325
|
if (expectedLock && reboundLock)
|
|
2038
2326
|
await rollbackOwnershipLockRebind({
|
|
@@ -2046,7 +2334,49 @@ export async function renewDaemonHeartbeat(input: {
|
|
|
2046
2334
|
});
|
|
2047
2335
|
return false;
|
|
2048
2336
|
}
|
|
2049
|
-
|
|
2337
|
+
const retireBoundOwnership = async (): Promise<void> => {
|
|
2338
|
+
const lock = await readOwnershipLock(fsImpl, paths.lock);
|
|
2339
|
+
if (!ownershipLockMatchesState(lock, boundState)) return;
|
|
2340
|
+
await writeJsonAtomic(fsImpl, paths.state, {
|
|
2341
|
+
...boundState,
|
|
2342
|
+
ownershipPhase: "retired",
|
|
2343
|
+
stoppedAt: (input.now ?? Date.now)(),
|
|
2344
|
+
}).catch(() => undefined);
|
|
2345
|
+
if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
|
|
2346
|
+
await unlinkOwnershipLockExactly(fsImpl, paths.lock, lock);
|
|
2347
|
+
};
|
|
2348
|
+
// The durable sidecar must exist and match the rebound ownership lock before
|
|
2349
|
+
// any observer can see ownershipPhase:"ready". A provisional state may be
|
|
2350
|
+
// fresh, but readiness waiters categorically refuse to attach to it.
|
|
2351
|
+
let sidecarRenewed = false;
|
|
2352
|
+
try {
|
|
2353
|
+
sidecarRenewed = await renewOwnerHeartbeatSidecar({
|
|
2354
|
+
settings: input.settings,
|
|
2355
|
+
ownerId: input.ownerId,
|
|
2356
|
+
acquisitionId,
|
|
2357
|
+
fs: fsImpl,
|
|
2358
|
+
now: () => heartbeatAt,
|
|
2359
|
+
pid,
|
|
2360
|
+
pidIncarnation: input.pidIncarnation,
|
|
2361
|
+
});
|
|
2362
|
+
} catch {
|
|
2363
|
+
sidecarRenewed = false;
|
|
2364
|
+
}
|
|
2365
|
+
if (!sidecarRenewed) {
|
|
2366
|
+
logger.warn(
|
|
2367
|
+
"notifications: Telegram daemon initial heartbeat sidecar proof failed; retiring provisional ownership",
|
|
2368
|
+
);
|
|
2369
|
+
await retireBoundOwnership();
|
|
2370
|
+
return false;
|
|
2371
|
+
}
|
|
2372
|
+
try {
|
|
2373
|
+
await writeJsonAtomic(fsImpl, paths.state, { ...boundState, ownershipPhase: "ready" });
|
|
2374
|
+
return true;
|
|
2375
|
+
} catch {
|
|
2376
|
+
logger.warn("notifications: Telegram daemon ready publication failed after sidecar proof; retiring ownership");
|
|
2377
|
+
await retireBoundOwnership();
|
|
2378
|
+
return false;
|
|
2379
|
+
}
|
|
2050
2380
|
} finally {
|
|
2051
2381
|
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
2052
2382
|
}
|
|
@@ -2225,7 +2555,7 @@ async function bindProvisionalDaemonPid(input: {
|
|
|
2225
2555
|
}
|
|
2226
2556
|
}
|
|
2227
2557
|
|
|
2228
|
-
/** Wait for a matching
|
|
2558
|
+
/** Wait for a matching compatible owner to publish a ready state. Readiness is serving-epoch gated (same-epoch cross-generation owners attach, per isCurrentCompatibleOwner); generation is inventory-only here. Mutation paths still require exact current-generation equality. */
|
|
2229
2559
|
export async function waitForTelegramDaemonReady(input: {
|
|
2230
2560
|
settings: Settings;
|
|
2231
2561
|
ownerId?: string;
|
|
@@ -2251,12 +2581,13 @@ export async function waitForTelegramDaemonReady(input: {
|
|
|
2251
2581
|
const deadline = now() + timeoutMs;
|
|
2252
2582
|
const maxPolls = Math.ceil(timeoutMs / waitStepMs);
|
|
2253
2583
|
for (let poll = 0; poll <= maxPolls; poll++) {
|
|
2254
|
-
const
|
|
2584
|
+
const snapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: input.fs });
|
|
2585
|
+
const state = snapshot.state;
|
|
2255
2586
|
if (
|
|
2256
2587
|
(!input.ownerId || state?.ownerId === input.ownerId) &&
|
|
2257
2588
|
(!input.acquisitionId || state?.acquisitionId === input.acquisitionId) &&
|
|
2258
2589
|
state?.ownershipPhase === "ready" &&
|
|
2259
|
-
state.generation
|
|
2590
|
+
Number.isSafeInteger(state.generation) &&
|
|
2260
2591
|
ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
|
|
2261
2592
|
ownerProvenanceMatches(state, pidIncarnation) &&
|
|
2262
2593
|
pidAlive(state.pid) &&
|
|
@@ -2270,6 +2601,7 @@ export async function waitForTelegramDaemonReady(input: {
|
|
|
2270
2601
|
chatId: input.chatId,
|
|
2271
2602
|
pidAlive,
|
|
2272
2603
|
pidIncarnation,
|
|
2604
|
+
effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
|
|
2273
2605
|
})
|
|
2274
2606
|
)
|
|
2275
2607
|
return true;
|
|
@@ -2339,7 +2671,8 @@ export async function confirmTelegramDaemonSpawn(input: {
|
|
|
2339
2671
|
sleep: input.sleep,
|
|
2340
2672
|
});
|
|
2341
2673
|
if (retired) return false;
|
|
2342
|
-
const
|
|
2674
|
+
const snapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: input.fs });
|
|
2675
|
+
const state = snapshot.state;
|
|
2343
2676
|
if (
|
|
2344
2677
|
hasExactChildPid &&
|
|
2345
2678
|
isCurrentCompatibleOwner({
|
|
@@ -2349,6 +2682,7 @@ export async function confirmTelegramDaemonSpawn(input: {
|
|
|
2349
2682
|
chatId: input.chatId,
|
|
2350
2683
|
pidAlive: input.pidAlive ?? defaultPidAlive,
|
|
2351
2684
|
pidIncarnation: input.pidIncarnation ?? defaultPidIncarnation,
|
|
2685
|
+
effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
|
|
2352
2686
|
}) &&
|
|
2353
2687
|
(state?.ownerId !== input.spawned.acquisition.ownerId ||
|
|
2354
2688
|
state.acquisitionId !== input.spawned.acquisition.acquisitionId ||
|
|
@@ -2812,8 +3146,15 @@ export async function reclaimDeadDaemonOwner(input: {
|
|
|
2812
3146
|
* Ensure a configured daemon owns this session root, preserving ownership safety
|
|
2813
3147
|
* while exposing whether a #2028 generation handoff was required.
|
|
2814
3148
|
*/
|
|
2815
|
-
|
|
2816
|
-
input: {
|
|
3149
|
+
async function ensureTelegramDaemonRunningDetailedOnce(
|
|
3150
|
+
input: {
|
|
3151
|
+
settings: Settings;
|
|
3152
|
+
cwd: string;
|
|
3153
|
+
sessionId: string;
|
|
3154
|
+
registerRoot?: boolean;
|
|
3155
|
+
/** Receives each registration this ensure operation minted so the caller can fence its own later cleanup. */
|
|
3156
|
+
onRegistered?: (registration: RegisterNotificationRootResult) => void;
|
|
3157
|
+
},
|
|
2817
3158
|
deps: TelegramDaemonDeps = {},
|
|
2818
3159
|
): Promise<EnsureTelegramDaemonDetailedResult> {
|
|
2819
3160
|
const cfg = getNotificationConfig(input.settings);
|
|
@@ -2919,7 +3260,12 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
2919
3260
|
}
|
|
2920
3261
|
if (spawned.result === "attached" && spawned.reloadRequired) {
|
|
2921
3262
|
const previous = await readNotificationRootRegistration({ ...input, fs: deps.fs });
|
|
2922
|
-
|
|
3263
|
+
let registeredToken: string | undefined;
|
|
3264
|
+
if (input.registerRoot !== false) {
|
|
3265
|
+
const registered = await registerNotificationRoot({ ...input, fs: deps.fs });
|
|
3266
|
+
registeredToken = registered.token;
|
|
3267
|
+
input.onRegistered?.(registered);
|
|
3268
|
+
}
|
|
2923
3269
|
const fsImpl = deps.fs ?? nodeFs;
|
|
2924
3270
|
const now = deps.now ?? Date.now;
|
|
2925
3271
|
const pidAlive = deps.pidAlive ?? defaultPidAlive;
|
|
@@ -2958,22 +3304,28 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
2958
3304
|
// Iteration-capped so a frozen `now` cannot spin forever.
|
|
2959
3305
|
const maxWaits = Math.ceil(waitBudgetMs / waitStepMs);
|
|
2960
3306
|
for (let waits = 0; waits <= maxWaits; waits++) {
|
|
2961
|
-
const
|
|
3307
|
+
const snapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: fsImpl });
|
|
3308
|
+
const state = snapshot.state;
|
|
2962
3309
|
const t = now();
|
|
2963
3310
|
if (
|
|
2964
|
-
|
|
3311
|
+
isCurrentCompatibleOwner({
|
|
2965
3312
|
state,
|
|
2966
3313
|
now: t,
|
|
2967
3314
|
tokenFingerprint: fp,
|
|
2968
3315
|
chatId: cfg.chatId,
|
|
2969
3316
|
pidAlive,
|
|
2970
3317
|
pidIncarnation,
|
|
3318
|
+
effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
|
|
2971
3319
|
})
|
|
2972
3320
|
)
|
|
2973
3321
|
return "attached" as const;
|
|
2974
3322
|
if (t >= deadline) break;
|
|
2975
3323
|
await sleep(waitStepMs);
|
|
2976
3324
|
}
|
|
3325
|
+
// Keep the reservation after a failed reload so concurrent ensures do not
|
|
3326
|
+
// repeatedly signal the same incompatible predecessor. Surface the failed
|
|
3327
|
+
// convergence; the next ensure after cooldown expiry retries the reload.
|
|
3328
|
+
return "blocked_identity" as const;
|
|
2977
3329
|
}
|
|
2978
3330
|
await writeJsonAtomic(fsImpl, reloadAttemptPath, {
|
|
2979
3331
|
lastReloadAt: reloadNow,
|
|
@@ -2991,11 +3343,23 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
2991
3343
|
}),
|
|
2992
3344
|
);
|
|
2993
3345
|
if (reloadResult === "attached") return "attached";
|
|
3346
|
+
if (reloadResult === "blocked_identity") {
|
|
3347
|
+
await restoreNotificationRootRegistration({
|
|
3348
|
+
settings: input.settings,
|
|
3349
|
+
sessionId: input.sessionId,
|
|
3350
|
+
registeredRoot: root,
|
|
3351
|
+
registeredToken,
|
|
3352
|
+
previous,
|
|
3353
|
+
fs: deps.fs,
|
|
3354
|
+
});
|
|
3355
|
+
return "blocked_identity";
|
|
3356
|
+
}
|
|
2994
3357
|
if (reloadResult !== "reloaded") {
|
|
2995
3358
|
await restoreNotificationRootRegistration({
|
|
2996
3359
|
settings: input.settings,
|
|
2997
3360
|
sessionId: input.sessionId,
|
|
2998
3361
|
registeredRoot: root,
|
|
3362
|
+
registeredToken,
|
|
2999
3363
|
previous,
|
|
3000
3364
|
fs: deps.fs,
|
|
3001
3365
|
});
|
|
@@ -3004,7 +3368,10 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
3004
3368
|
return "reloaded";
|
|
3005
3369
|
}
|
|
3006
3370
|
if (spawned.result !== "owner_spawned") {
|
|
3007
|
-
|
|
3371
|
+
if (input.registerRoot !== false) {
|
|
3372
|
+
const registered = await registerNotificationRoot({ ...input, fs: deps.fs });
|
|
3373
|
+
input.onRegistered?.(registered);
|
|
3374
|
+
}
|
|
3008
3375
|
return "attached";
|
|
3009
3376
|
}
|
|
3010
3377
|
if (
|
|
@@ -3023,12 +3390,54 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
3023
3390
|
timeoutMs: deps.readinessTimeoutMs,
|
|
3024
3391
|
})
|
|
3025
3392
|
) {
|
|
3026
|
-
|
|
3393
|
+
if (input.registerRoot !== false) {
|
|
3394
|
+
const registered = await registerNotificationRoot({ ...input, fs: deps.fs });
|
|
3395
|
+
input.onRegistered?.(registered);
|
|
3396
|
+
}
|
|
3027
3397
|
return "spawned";
|
|
3028
3398
|
}
|
|
3029
3399
|
throw new Error("Telegram daemon did not become ready after spawning");
|
|
3030
3400
|
}
|
|
3031
3401
|
|
|
3402
|
+
/**
|
|
3403
|
+
* Ensure a configured daemon owns this session root, preserving ownership safety
|
|
3404
|
+
* while exposing whether a #2028 generation handoff was required.
|
|
3405
|
+
*/
|
|
3406
|
+
export async function ensureTelegramDaemonRunningDetailed(
|
|
3407
|
+
input: {
|
|
3408
|
+
settings: Settings;
|
|
3409
|
+
cwd: string;
|
|
3410
|
+
sessionId: string;
|
|
3411
|
+
registerRoot?: boolean;
|
|
3412
|
+
/** Receives each registration this ensure operation minted so the caller can fence its own later cleanup. */
|
|
3413
|
+
onRegistered?: (registration: RegisterNotificationRootResult) => void;
|
|
3414
|
+
},
|
|
3415
|
+
deps: TelegramDaemonDeps = {},
|
|
3416
|
+
): Promise<EnsureTelegramDaemonDetailedResult> {
|
|
3417
|
+
const result = await ensureTelegramDaemonRunningDetailedOnce(input, deps);
|
|
3418
|
+
if (input.registerRoot === false || (result !== "attached" && result !== "spawned")) return result;
|
|
3419
|
+
|
|
3420
|
+
const cfg = getNotificationConfig(input.settings);
|
|
3421
|
+
if (!isTelegramConfigured(cfg)) return "disabled";
|
|
3422
|
+
const ownerIsCurrent = async (): Promise<boolean> => {
|
|
3423
|
+
const snapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: deps.fs });
|
|
3424
|
+
return isCurrentCompatibleOwner({
|
|
3425
|
+
state: snapshot.state,
|
|
3426
|
+
now: (deps.now ?? Date.now)(),
|
|
3427
|
+
tokenFingerprint: tokenFingerprint(cfg.botToken),
|
|
3428
|
+
chatId: cfg.chatId,
|
|
3429
|
+
pidAlive: deps.pidAlive ?? defaultPidAlive,
|
|
3430
|
+
pidIncarnation: deps.pidIncarnation ?? defaultPidIncarnation,
|
|
3431
|
+
effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
|
|
3432
|
+
});
|
|
3433
|
+
};
|
|
3434
|
+
if (await ownerIsCurrent()) return result;
|
|
3435
|
+
|
|
3436
|
+
const converged = await ensureTelegramDaemonRunningDetailedOnce({ ...input, registerRoot: false }, deps);
|
|
3437
|
+
if (converged === "disabled" || converged === "blocked_identity") return converged;
|
|
3438
|
+
return (await ownerIsCurrent()) ? converged : "blocked_identity";
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3032
3441
|
/**
|
|
3033
3442
|
* Legacy compatibility mapping for callers that only distinguish ownership from
|
|
3034
3443
|
* attachment. New orchestration should use {@link ensureTelegramDaemonRunningDetailed}.
|
|
@@ -3196,7 +3605,7 @@ export type TelegramPollResult =
|
|
|
3196
3605
|
export interface TelegramUpdatePollerOptions {
|
|
3197
3606
|
botApi: BotApi;
|
|
3198
3607
|
runtime: NotificationOperatorRuntime;
|
|
3199
|
-
backoff:
|
|
3608
|
+
backoff: { next(): number; reset(): void };
|
|
3200
3609
|
processUpdate: (update: unknown) => Promise<TelegramUpdateOutcome>;
|
|
3201
3610
|
health?: TelegramPollHealth;
|
|
3202
3611
|
}
|
|
@@ -3258,6 +3667,20 @@ export class TelegramPollHealth {
|
|
|
3258
3667
|
}
|
|
3259
3668
|
}
|
|
3260
3669
|
|
|
3670
|
+
class JitteredPollConflictBackoff {
|
|
3671
|
+
#currentMs = 0;
|
|
3672
|
+
|
|
3673
|
+
next(): number {
|
|
3674
|
+
if (this.#currentMs === 0) this.#currentMs = POLL_BACKOFF_MS;
|
|
3675
|
+
else this.#currentMs = Math.min(this.#currentMs * (1.5 + Math.random() * 0.5), 10_000);
|
|
3676
|
+
return this.#currentMs;
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
reset(): void {
|
|
3680
|
+
this.#currentMs = 0;
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3261
3684
|
/** Owns getUpdates offset, conflict backoff, and per-update error isolation. */
|
|
3262
3685
|
export class TelegramUpdatePoller {
|
|
3263
3686
|
#offset = 0;
|
|
@@ -3423,7 +3846,7 @@ export interface TelegramDaemonOptions {
|
|
|
3423
3846
|
rich?: { enabled: boolean };
|
|
3424
3847
|
/** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */
|
|
3425
3848
|
richDraft?: { enabled: boolean };
|
|
3426
|
-
/** Tool start/completion messages (
|
|
3849
|
+
/** Tool start/completion messages (off by default; explicit opt-in only). */
|
|
3427
3850
|
toolActivity?: { enabled: boolean };
|
|
3428
3851
|
/**
|
|
3429
3852
|
* Per-session Telegram forum-topic naming. `nameTemplate` supports the
|
|
@@ -3452,6 +3875,8 @@ interface SessionSocket {
|
|
|
3452
3875
|
/** True once the server advertised the `client_ping_pong` capability. */
|
|
3453
3876
|
capable: boolean;
|
|
3454
3877
|
ephemeralCapable: boolean;
|
|
3878
|
+
/** Tool activity contract advertised by the connected host. */
|
|
3879
|
+
toolActivityCapability?: "v1" | "v2";
|
|
3455
3880
|
/** Timestamp (via opts.now) of the last received pong; seeds the TTL window. */
|
|
3456
3881
|
lastPongAt: number;
|
|
3457
3882
|
/** Nonce of the most recent in-flight ping, if any. */
|
|
@@ -3502,6 +3927,19 @@ interface ToolActivityOwner {
|
|
|
3502
3927
|
session: SessionSocket;
|
|
3503
3928
|
phase: "started" | "terminal";
|
|
3504
3929
|
policyEpoch?: number;
|
|
3930
|
+
summaryFreeSend?: ThreadedSend;
|
|
3931
|
+
}
|
|
3932
|
+
type LegacyToolStartOutcome = "visible" | "failed" | "cancelled" | "terminal";
|
|
3933
|
+
interface LegacyToolStartSettlement {
|
|
3934
|
+
key: string;
|
|
3935
|
+
owner: ToolActivityOwner;
|
|
3936
|
+
policyEpoch: number;
|
|
3937
|
+
phase: "admitted" | "pending_identity" | "queued" | "dispatching" | LegacyToolStartOutcome;
|
|
3938
|
+
itemId?: string;
|
|
3939
|
+
settled: Promise<LegacyToolStartOutcome>;
|
|
3940
|
+
resolve: (outcome: LegacyToolStartOutcome) => void;
|
|
3941
|
+
settledOutcome?: LegacyToolStartOutcome;
|
|
3942
|
+
terminalClaimed?: boolean;
|
|
3505
3943
|
}
|
|
3506
3944
|
|
|
3507
3945
|
interface PendingThreadedFrame {
|
|
@@ -3518,6 +3956,35 @@ type SelectedAckOutcome =
|
|
|
3518
3956
|
| { status: "unknown"; reason: "transport_ambiguous" | "shutdown" };
|
|
3519
3957
|
type BtwQueuedDeliveryOutcome = "accepted" | "not_delivered" | "uncertain" | "stale" | "partial_accepted";
|
|
3520
3958
|
|
|
3959
|
+
type BotApiCallOutcome =
|
|
3960
|
+
| { kind: "accepted" }
|
|
3961
|
+
| { kind: "rejected" }
|
|
3962
|
+
| { kind: "retryable"; retryAfterMs: number }
|
|
3963
|
+
| { kind: "unknown" };
|
|
3964
|
+
|
|
3965
|
+
interface BotApiCallResult {
|
|
3966
|
+
response: unknown;
|
|
3967
|
+
outcome: BotApiCallOutcome;
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3970
|
+
function classifyBotApiCallOutcome(response: unknown, cooldownSuppressed = false): BotApiCallOutcome {
|
|
3971
|
+
if (cooldownSuppressed) return { kind: "retryable", retryAfterMs: 0 };
|
|
3972
|
+
if (!response || typeof response !== "object") return { kind: "unknown" };
|
|
3973
|
+
const body = response as {
|
|
3974
|
+
ok?: unknown;
|
|
3975
|
+
error_code?: unknown;
|
|
3976
|
+
parameters?: { retry_after?: unknown };
|
|
3977
|
+
};
|
|
3978
|
+
if (body.ok === false && body.error_code === 429) {
|
|
3979
|
+
const retryAfter = body.parameters?.retry_after;
|
|
3980
|
+
const seconds = typeof retryAfter === "number" && Number.isFinite(retryAfter) ? retryAfter : 30;
|
|
3981
|
+
return { kind: "retryable", retryAfterMs: Math.min(3600, Math.max(1, seconds)) * 1_000 };
|
|
3982
|
+
}
|
|
3983
|
+
if (body.ok === true) return { kind: "accepted" };
|
|
3984
|
+
if (body.ok === false) return { kind: "rejected" };
|
|
3985
|
+
return { kind: "unknown" };
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3521
3988
|
interface BtwQueuedDelivery {
|
|
3522
3989
|
pending: PendingBtwTurn;
|
|
3523
3990
|
body: Record<string, unknown>;
|
|
@@ -3548,6 +4015,8 @@ interface TelegramQueuePayload {
|
|
|
3548
4015
|
selectedAck?: SelectedAckQueueItem;
|
|
3549
4016
|
btwDelivery?: BtwQueuedDelivery;
|
|
3550
4017
|
toolActivity?: ToolActivityOwner;
|
|
4018
|
+
/** Exact admitted legacy-v1 start authorized for this terminal delivery. */
|
|
4019
|
+
legacyToolStart?: LegacyToolStartSettlement;
|
|
3551
4020
|
}
|
|
3552
4021
|
|
|
3553
4022
|
interface PendingBtwTurn {
|
|
@@ -3645,6 +4114,9 @@ export class TelegramNotificationDaemon {
|
|
|
3645
4114
|
/** Endpoint-bound ownership for visible or dispatching tool bubbles. */
|
|
3646
4115
|
private readonly toolActivityOwners = new Map<string, ToolActivityOwner>();
|
|
3647
4116
|
private readonly revokedToolEndpoints = new Set<string>();
|
|
4117
|
+
/** Exact settlement of each admitted legacy-v1 start; retained only while visible. */
|
|
4118
|
+
private readonly legacyToolStarts = new Map<string, LegacyToolStartSettlement>();
|
|
4119
|
+
private nextLegacyToolStartId = 1;
|
|
3648
4120
|
private readonly unresolvedToolTerminalizations = new Map<string, { messageId: number; owner: ToolActivityOwner }>();
|
|
3649
4121
|
private toolTerminalizationChain: Promise<void> = Promise.resolve();
|
|
3650
4122
|
private toolActivityPolicyEpoch = 0;
|
|
@@ -3658,7 +4130,7 @@ export class TelegramNotificationDaemon {
|
|
|
3658
4130
|
|
|
3659
4131
|
private readonly runtime: NotificationOperatorRuntime;
|
|
3660
4132
|
private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
|
|
3661
|
-
private readonly pollConflictBackoff = new
|
|
4133
|
+
private readonly pollConflictBackoff = new JitteredPollConflictBackoff();
|
|
3662
4134
|
private readonly loopBackoff = new OperatorBackoffPolicy({ initialMs: 250, maxMs: 4_000 });
|
|
3663
4135
|
private running = false;
|
|
3664
4136
|
/** Once set, a concurrent startup await can never restore a running daemon. */
|
|
@@ -3698,6 +4170,9 @@ export class TelegramNotificationDaemon {
|
|
|
3698
4170
|
private readonly pool: RateLimitPool<TelegramQueuePayload>;
|
|
3699
4171
|
private readonly poller: TelegramUpdatePoller;
|
|
3700
4172
|
private readonly dispatchState = new TelegramEventDispatchState();
|
|
4173
|
+
/** Bot-wide flood-control window; inbound polling remains eligible during it. */
|
|
4174
|
+
private botCooldownUntil = 0;
|
|
4175
|
+
private warnedBotCooldownUntil = 0;
|
|
3701
4176
|
/** Original markdown of rich messages we sent (chat+message_id), for restoring reply context on inbound replies. */
|
|
3702
4177
|
private readonly replyStore: ReplySentStore;
|
|
3703
4178
|
/** Per-session debounce + monotonic draft-id state for opt-in draft streaming. */
|
|
@@ -4116,8 +4591,8 @@ export class TelegramNotificationDaemon {
|
|
|
4116
4591
|
|
|
4117
4592
|
private async refreshBotIdentity(): Promise<void> {
|
|
4118
4593
|
try {
|
|
4119
|
-
const response = (await this.botApi.call("getMe", {})) as { result?: { username?: unknown } };
|
|
4120
|
-
const username = response
|
|
4594
|
+
const response = (await this.botApi.call("getMe", {})) as { result?: { username?: unknown } } | undefined;
|
|
4595
|
+
const username = response?.result?.username;
|
|
4121
4596
|
this.botUsername =
|
|
4122
4597
|
typeof username === "string" && username.trim() ? username.trim().replace(/^@/, "") : undefined;
|
|
4123
4598
|
} catch {
|
|
@@ -4130,6 +4605,47 @@ export class TelegramNotificationDaemon {
|
|
|
4130
4605
|
return formatLifecycleOutcome(r, verb);
|
|
4131
4606
|
}
|
|
4132
4607
|
|
|
4608
|
+
private async callBotApi(
|
|
4609
|
+
rawBotApi: BotApi,
|
|
4610
|
+
method: string,
|
|
4611
|
+
body: unknown,
|
|
4612
|
+
callOpts?: { signal?: AbortSignal; noRetry?: boolean },
|
|
4613
|
+
): Promise<BotApiCallResult> {
|
|
4614
|
+
const now = this.opts.now?.() ?? Date.now();
|
|
4615
|
+
if (method !== "getUpdates" && now < this.botCooldownUntil) {
|
|
4616
|
+
if (this.warnedBotCooldownUntil !== this.botCooldownUntil) {
|
|
4617
|
+
this.warnedBotCooldownUntil = this.botCooldownUntil;
|
|
4618
|
+
logger.warn("notifications: Telegram Bot API flood-control cooldown suppressing outbound calls");
|
|
4619
|
+
}
|
|
4620
|
+
const outcome = classifyBotApiCallOutcome(undefined, true);
|
|
4621
|
+
return { response: undefined, outcome };
|
|
4622
|
+
}
|
|
4623
|
+
const response = await this.effects.call(rawBotApi, method, body, callOpts);
|
|
4624
|
+
const outcome = classifyBotApiCallOutcome(response);
|
|
4625
|
+
if (outcome.kind === "retryable")
|
|
4626
|
+
this.botCooldownUntil = Math.max(
|
|
4627
|
+
this.botCooldownUntil,
|
|
4628
|
+
(this.opts.now?.() ?? Date.now()) + outcome.retryAfterMs,
|
|
4629
|
+
);
|
|
4630
|
+
return { response, outcome };
|
|
4631
|
+
}
|
|
4632
|
+
|
|
4633
|
+
private readonly callBotApiClassified: (
|
|
4634
|
+
method: string,
|
|
4635
|
+
body: unknown,
|
|
4636
|
+
callOpts?: { signal?: AbortSignal; noRetry?: boolean },
|
|
4637
|
+
) => Promise<BotApiCallResult>;
|
|
4638
|
+
|
|
4639
|
+
private botApiWithOutcomeCollector(outcomes: BotApiCallOutcome[]): BotApi {
|
|
4640
|
+
return {
|
|
4641
|
+
call: async (method, body, callOpts) => {
|
|
4642
|
+
const result = await this.callBotApiClassified(method, body, callOpts);
|
|
4643
|
+
outcomes.push(result.outcome);
|
|
4644
|
+
return result.response;
|
|
4645
|
+
},
|
|
4646
|
+
};
|
|
4647
|
+
}
|
|
4648
|
+
|
|
4133
4649
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
4134
4650
|
this.fsImpl = opts.fs ?? nodeFs;
|
|
4135
4651
|
this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
|
|
@@ -4142,8 +4658,9 @@ export class TelegramNotificationDaemon {
|
|
|
4142
4658
|
fetchImpl: opts.fetchImpl,
|
|
4143
4659
|
setTimeoutImpl: opts.setTimeoutImpl,
|
|
4144
4660
|
});
|
|
4661
|
+
this.callBotApiClassified = (method, body, callOpts) => this.callBotApi(rawBotApi, method, body, callOpts);
|
|
4145
4662
|
this.botApi = {
|
|
4146
|
-
call: (method, body, callOpts) => this.
|
|
4663
|
+
call: async (method, body, callOpts) => (await this.callBotApiClassified(method, body, callOpts)).response,
|
|
4147
4664
|
};
|
|
4148
4665
|
this.runtime = new NotificationOperatorRuntime({
|
|
4149
4666
|
now: opts.now,
|
|
@@ -4169,6 +4686,10 @@ export class TelegramNotificationDaemon {
|
|
|
4169
4686
|
matches: msg => msg.type === "hello",
|
|
4170
4687
|
handle: (session, msg) => {
|
|
4171
4688
|
const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
4689
|
+
const previousToolActivityCapability = session.toolActivityCapability;
|
|
4690
|
+
session.toolActivityCapability = negotiateToolActivityCapability(previousToolActivityCapability, caps);
|
|
4691
|
+
if (previousToolActivityCapability === "v1" && session.toolActivityCapability === "v2")
|
|
4692
|
+
this.cleanLegacyToolStartsForCapabilityUpgrade(session);
|
|
4172
4693
|
if (caps.includes("ephemeral_turn_v1")) session.ephemeralCapable = true;
|
|
4173
4694
|
if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
|
|
4174
4695
|
session.capable = true;
|
|
@@ -4701,6 +5222,7 @@ export class TelegramNotificationDaemon {
|
|
|
4701
5222
|
ASK_CONTROLS_CAPABILITY,
|
|
4702
5223
|
ASK_SELECTED_ACK_CAPABILITY,
|
|
4703
5224
|
TOOL_ACTIVITY_CAPABILITY,
|
|
5225
|
+
LEGACY_TOOL_ACTIVITY_CAPABILITY,
|
|
4704
5226
|
"ephemeral_turn_v1",
|
|
4705
5227
|
],
|
|
4706
5228
|
}),
|
|
@@ -4794,12 +5316,12 @@ export class TelegramNotificationDaemon {
|
|
|
4794
5316
|
for (const item of claimedItems) {
|
|
4795
5317
|
const { messageId, owner } = item;
|
|
4796
5318
|
const backlogKey = `${owner.endpointDigest}\0${owner.sessionId}\0${owner.toolCallId}\0${messageId}`;
|
|
4797
|
-
const send = renderThreadedFrame({
|
|
5319
|
+
const send = this.renderThreadedFrame({
|
|
4798
5320
|
type: "tool_activity",
|
|
4799
5321
|
sessionId: owner.sessionId,
|
|
4800
5322
|
toolCallId: owner.toolCallId,
|
|
4801
5323
|
toolName: owner.toolName,
|
|
4802
|
-
phase: "
|
|
5324
|
+
phase: "cancelled",
|
|
4803
5325
|
});
|
|
4804
5326
|
if (!send?.text) continue;
|
|
4805
5327
|
let failure: unknown;
|
|
@@ -4857,6 +5379,9 @@ export class TelegramNotificationDaemon {
|
|
|
4857
5379
|
this.toolActivityOwners.delete(key);
|
|
4858
5380
|
const messageId = this.liveMessages.get(key);
|
|
4859
5381
|
this.liveMessages.delete(key);
|
|
5382
|
+
const legacyStart = this.legacyToolStarts.get(key);
|
|
5383
|
+
if (legacyStart !== undefined && legacyStart.owner === owner)
|
|
5384
|
+
this.settleLegacyToolStart(legacyStart, "terminal");
|
|
4860
5385
|
if (messageId !== undefined) {
|
|
4861
5386
|
const backlogKey = `${owner.endpointDigest}\0${owner.sessionId}\0${owner.toolCallId}\0${messageId}`;
|
|
4862
5387
|
claimedByKey.set(backlogKey, { messageId, owner });
|
|
@@ -4884,6 +5409,7 @@ export class TelegramNotificationDaemon {
|
|
|
4884
5409
|
this.toolActivityStopping = true;
|
|
4885
5410
|
this.toolActivityPolicyEpoch++;
|
|
4886
5411
|
this.toolShutdownBarrier = (async () => {
|
|
5412
|
+
await this.cancelLegacyToolStartsForPolicyTransition();
|
|
4887
5413
|
await this.flushChain;
|
|
4888
5414
|
const failures: Error[] = [];
|
|
4889
5415
|
if (this.toolActivityAmbiguous) {
|
|
@@ -4908,6 +5434,7 @@ export class TelegramNotificationDaemon {
|
|
|
4908
5434
|
if (session.recoveryLease) session.recoveryLease = { ...session.recoveryLease, state: "rejected" };
|
|
4909
5435
|
const isCurrentSession = this.sessions.get(session.sessionId) === session;
|
|
4910
5436
|
if (isCurrentSession) this.droppedSessions.add(session);
|
|
5437
|
+
if (isCurrentSession) this.cancelLegacyToolStartsForSession(session);
|
|
4911
5438
|
if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
|
|
4912
5439
|
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
4913
5440
|
if (session.pingTimer) {
|
|
@@ -5438,7 +5965,7 @@ export class TelegramNotificationDaemon {
|
|
|
5438
5965
|
typeof msg.sessionId !== "string" ||
|
|
5439
5966
|
!msg.sessionId.trim() ||
|
|
5440
5967
|
msg.sessionId === this.#logicalSessionId(session) ||
|
|
5441
|
-
(msg.type !== "config_update" && !renderThreadedFrame(msg))
|
|
5968
|
+
(msg.type !== "config_update" && !this.renderThreadedFrame(msg))
|
|
5442
5969
|
)
|
|
5443
5970
|
return;
|
|
5444
5971
|
if (!session.logicalSessionIdTrusted) return;
|
|
@@ -5700,12 +6227,151 @@ export class TelegramNotificationDaemon {
|
|
|
5700
6227
|
return undefined;
|
|
5701
6228
|
}
|
|
5702
6229
|
|
|
6230
|
+
private admitLegacyToolStart(owner: ToolActivityOwner): LegacyToolStartSettlement | undefined {
|
|
6231
|
+
if (owner.session.toolActivityCapability !== "v1") return undefined;
|
|
6232
|
+
const key = `${owner.sessionId}:tool:${owner.toolCallId}`;
|
|
6233
|
+
if (this.legacyToolStarts.has(key)) return undefined;
|
|
6234
|
+
const deferred = Promise.withResolvers<LegacyToolStartOutcome>();
|
|
6235
|
+
const state: LegacyToolStartSettlement = {
|
|
6236
|
+
key,
|
|
6237
|
+
owner,
|
|
6238
|
+
policyEpoch: owner.policyEpoch ?? this.toolActivityPolicyEpoch,
|
|
6239
|
+
phase: "admitted",
|
|
6240
|
+
settled: deferred.promise,
|
|
6241
|
+
resolve: deferred.resolve,
|
|
6242
|
+
};
|
|
6243
|
+
this.legacyToolStarts.set(key, state);
|
|
6244
|
+
return state;
|
|
6245
|
+
}
|
|
6246
|
+
private legacyToolStartForTerminal(owner: ToolActivityOwner): LegacyToolStartSettlement | undefined {
|
|
6247
|
+
if (owner.phase !== "terminal") return undefined;
|
|
6248
|
+
const key = `${owner.sessionId}:tool:${owner.toolCallId}`;
|
|
6249
|
+
const state = this.legacyToolStarts.get(key);
|
|
6250
|
+
if (
|
|
6251
|
+
state === undefined ||
|
|
6252
|
+
state.key !== key ||
|
|
6253
|
+
state.owner.session !== owner.session ||
|
|
6254
|
+
state.owner.endpointDigest !== owner.endpointDigest ||
|
|
6255
|
+
state.owner.toolName !== owner.toolName ||
|
|
6256
|
+
state.policyEpoch !== owner.policyEpoch
|
|
6257
|
+
)
|
|
6258
|
+
return undefined;
|
|
6259
|
+
return state;
|
|
6260
|
+
}
|
|
6261
|
+
|
|
6262
|
+
private settleLegacyToolStart(state: LegacyToolStartSettlement, outcome: LegacyToolStartOutcome): void {
|
|
6263
|
+
if (state.settledOutcome === undefined) {
|
|
6264
|
+
state.settledOutcome = outcome;
|
|
6265
|
+
state.resolve(outcome);
|
|
6266
|
+
}
|
|
6267
|
+
state.phase = outcome;
|
|
6268
|
+
if (outcome !== "visible" && this.legacyToolStarts.get(state.key) === state)
|
|
6269
|
+
this.legacyToolStarts.delete(state.key);
|
|
6270
|
+
if (outcome !== "visible" && !this.liveMessages.has(state.key)) {
|
|
6271
|
+
const owner = this.toolActivityOwners.get(state.key);
|
|
6272
|
+
if (owner === state.owner) this.toolActivityOwners.delete(state.key);
|
|
6273
|
+
}
|
|
6274
|
+
}
|
|
6275
|
+
|
|
6276
|
+
private cancelUnsentLegacyToolStart(state: LegacyToolStartSettlement): boolean {
|
|
6277
|
+
if (state.phase !== "admitted" && state.phase !== "pending_identity" && state.phase !== "queued") return false;
|
|
6278
|
+
if (state.itemId) this.pool.removeById(state.itemId);
|
|
6279
|
+
for (const [sessionId, frames] of this.pendingThreadedFrames) {
|
|
6280
|
+
const retained = frames.filter(frame => frame.toolActivity !== state.owner);
|
|
6281
|
+
if (retained.length === 0) this.pendingThreadedFrames.delete(sessionId);
|
|
6282
|
+
else if (retained.length !== frames.length) this.pendingThreadedFrames.set(sessionId, retained);
|
|
6283
|
+
}
|
|
6284
|
+
this.settleLegacyToolStart(state, "cancelled");
|
|
6285
|
+
return true;
|
|
6286
|
+
}
|
|
6287
|
+
|
|
6288
|
+
private failLegacyToolStart(toolActivity: ToolActivityOwner | undefined): void {
|
|
6289
|
+
if (toolActivity?.phase !== "started") return;
|
|
6290
|
+
const state = this.legacyToolStarts.get(`${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`);
|
|
6291
|
+
if (state !== undefined && state.owner === toolActivity && state.phase !== "visible")
|
|
6292
|
+
this.settleLegacyToolStart(state, "failed");
|
|
6293
|
+
}
|
|
6294
|
+
|
|
6295
|
+
private settleRejectedLegacyToolSubmission(
|
|
6296
|
+
toolActivity: ToolActivityOwner | undefined,
|
|
6297
|
+
legacyToolStart: LegacyToolStartSettlement | undefined,
|
|
6298
|
+
): void {
|
|
6299
|
+
if (toolActivity?.phase === "started") {
|
|
6300
|
+
this.failLegacyToolStart(toolActivity);
|
|
6301
|
+
return;
|
|
6302
|
+
}
|
|
6303
|
+
if (
|
|
6304
|
+
toolActivity?.phase !== "terminal" ||
|
|
6305
|
+
legacyToolStart === undefined ||
|
|
6306
|
+
this.legacyToolStarts.get(legacyToolStart.key) !== legacyToolStart
|
|
6307
|
+
)
|
|
6308
|
+
return;
|
|
6309
|
+
this.settleLegacyToolStart(legacyToolStart, "terminal");
|
|
6310
|
+
this.liveMessages.delete(legacyToolStart.key);
|
|
6311
|
+
if (this.toolActivityOwners.get(legacyToolStart.key) === legacyToolStart.owner)
|
|
6312
|
+
this.toolActivityOwners.delete(legacyToolStart.key);
|
|
6313
|
+
}
|
|
6314
|
+
|
|
6315
|
+
private cancelLegacyToolStartsForSession(session: SessionSocket): void {
|
|
6316
|
+
for (const state of [...this.legacyToolStarts.values()]) {
|
|
6317
|
+
if (state.owner.session !== session) continue;
|
|
6318
|
+
if (!this.cancelUnsentLegacyToolStart(state)) this.settleLegacyToolStart(state, "cancelled");
|
|
6319
|
+
}
|
|
6320
|
+
}
|
|
6321
|
+
|
|
6322
|
+
private cleanLegacyToolStartsForCapabilityUpgrade(session: SessionSocket): void {
|
|
6323
|
+
for (const state of [...this.legacyToolStarts.values()]) {
|
|
6324
|
+
if (state.owner.session !== session || state.phase === "dispatching") continue;
|
|
6325
|
+
if (state.phase === "visible") this.settleLegacyToolStart(state, "terminal");
|
|
6326
|
+
else this.cancelUnsentLegacyToolStart(state);
|
|
6327
|
+
}
|
|
6328
|
+
}
|
|
6329
|
+
private cancelLegacyToolStartsForPolicyTransition(): Promise<LegacyToolStartOutcome[]> {
|
|
6330
|
+
const dispatching: Promise<LegacyToolStartOutcome>[] = [];
|
|
6331
|
+
const visibleTerminalizations: Array<{ messageId: number; owner: ToolActivityOwner }> = [];
|
|
6332
|
+
for (const state of [...this.legacyToolStarts.values()]) {
|
|
6333
|
+
if (state.phase === "dispatching")
|
|
6334
|
+
dispatching.push(
|
|
6335
|
+
state.settled.then(outcome => {
|
|
6336
|
+
if (outcome === "visible" && this.legacyToolStarts.get(state.key) === state) {
|
|
6337
|
+
const messageId = this.liveMessages.get(state.key);
|
|
6338
|
+
this.liveMessages.delete(state.key);
|
|
6339
|
+
if (this.toolActivityOwners.get(state.key) === state.owner)
|
|
6340
|
+
this.toolActivityOwners.delete(state.key);
|
|
6341
|
+
this.settleLegacyToolStart(state, "terminal");
|
|
6342
|
+
if (messageId !== undefined) {
|
|
6343
|
+
void this.enqueueToolTerminalization([{ messageId, owner: state.owner }], false);
|
|
6344
|
+
}
|
|
6345
|
+
}
|
|
6346
|
+
return outcome;
|
|
6347
|
+
}),
|
|
6348
|
+
);
|
|
6349
|
+
else if (state.phase === "visible") {
|
|
6350
|
+
const messageId = this.liveMessages.get(state.key);
|
|
6351
|
+
this.liveMessages.delete(state.key);
|
|
6352
|
+
if (this.toolActivityOwners.get(state.key) === state.owner) this.toolActivityOwners.delete(state.key);
|
|
6353
|
+
this.settleLegacyToolStart(state, "terminal");
|
|
6354
|
+
if (messageId !== undefined) {
|
|
6355
|
+
visibleTerminalizations.push({ messageId, owner: state.owner });
|
|
6356
|
+
}
|
|
6357
|
+
} else this.cancelUnsentLegacyToolStart(state);
|
|
6358
|
+
}
|
|
6359
|
+
if (visibleTerminalizations.length > 0) {
|
|
6360
|
+
void this.enqueueToolTerminalization(visibleTerminalizations, false);
|
|
6361
|
+
}
|
|
6362
|
+
return Promise.all(dispatching);
|
|
6363
|
+
}
|
|
5703
6364
|
private toolActivityOwner(session: SessionSocket, msg: Record<string, unknown>): ToolActivityOwner | undefined {
|
|
5704
6365
|
if (msg.type !== "tool_activity") return undefined;
|
|
5705
6366
|
const toolCallId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
|
|
5706
6367
|
const toolName = typeof msg.toolName === "string" ? msg.toolName : undefined;
|
|
5707
6368
|
const phase = typeof msg.phase === "string" ? msg.phase : undefined;
|
|
5708
|
-
if (
|
|
6369
|
+
if (
|
|
6370
|
+
!toolCallId ||
|
|
6371
|
+
!toolName ||
|
|
6372
|
+
(phase !== "started" && phase !== "completed" && phase !== "failed" && phase !== "cancelled")
|
|
6373
|
+
)
|
|
6374
|
+
return undefined;
|
|
5709
6375
|
return {
|
|
5710
6376
|
sessionId: this.#logicalSessionId(session),
|
|
5711
6377
|
toolCallId,
|
|
@@ -5715,6 +6381,30 @@ export class TelegramNotificationDaemon {
|
|
|
5715
6381
|
phase: phase === "started" ? "started" : "terminal",
|
|
5716
6382
|
};
|
|
5717
6383
|
}
|
|
6384
|
+
private renderThreadedFrame(frame: Record<string, unknown>): ThreadedSend | undefined {
|
|
6385
|
+
if (
|
|
6386
|
+
frame.type === "tool_activity" &&
|
|
6387
|
+
frame.phase !== "started" &&
|
|
6388
|
+
frame.phase !== "completed" &&
|
|
6389
|
+
frame.phase !== "failed" &&
|
|
6390
|
+
frame.phase !== "cancelled"
|
|
6391
|
+
)
|
|
6392
|
+
return undefined;
|
|
6393
|
+
return renderThreadedFrame(frame);
|
|
6394
|
+
}
|
|
6395
|
+
private toolActivityFrameWithoutSummaries(frame: Record<string, unknown>): Record<string, unknown> {
|
|
6396
|
+
const summaryFree = { ...frame };
|
|
6397
|
+
delete summaryFree.argsSummary;
|
|
6398
|
+
delete summaryFree.resultSummary;
|
|
6399
|
+
return summaryFree;
|
|
6400
|
+
}
|
|
6401
|
+
private toolActivitySummariesAreCurrent(toolActivity: ToolActivityOwner): boolean {
|
|
6402
|
+
return (
|
|
6403
|
+
this.opts.toolActivity?.enabled === true &&
|
|
6404
|
+
toolActivity.policyEpoch === this.toolActivityPolicyEpoch &&
|
|
6405
|
+
this.toolActivityAuthorityIsCurrent(toolActivity)
|
|
6406
|
+
);
|
|
6407
|
+
}
|
|
5718
6408
|
|
|
5719
6409
|
private toolActivityAuthorityIsCurrent(toolActivity: ToolActivityOwner): boolean {
|
|
5720
6410
|
if (this.revokedToolEndpoints.has(toolActivity.endpointDigest)) return false;
|
|
@@ -5723,6 +6413,34 @@ export class TelegramNotificationDaemon {
|
|
|
5723
6413
|
if (toolActivity.endpointDigest === undefined) return session === undefined;
|
|
5724
6414
|
return session === toolActivity.session && session.endpointDigest === toolActivity.endpointDigest;
|
|
5725
6415
|
}
|
|
6416
|
+
private toolActivityDeliveryIsCurrent(toolActivity: ToolActivityOwner): boolean {
|
|
6417
|
+
const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
6418
|
+
const owner = this.toolActivityOwners.get(key);
|
|
6419
|
+
if (owner && owner.session !== toolActivity.session) return false;
|
|
6420
|
+
if (toolActivity.phase === "started") {
|
|
6421
|
+
const legacyStart =
|
|
6422
|
+
toolActivity.session.toolActivityCapability === "v1" ? this.legacyToolStarts.get(key) : undefined;
|
|
6423
|
+
return (
|
|
6424
|
+
(toolActivity.session.toolActivityCapability !== "v1" ||
|
|
6425
|
+
(legacyStart !== undefined &&
|
|
6426
|
+
legacyStart.owner === toolActivity &&
|
|
6427
|
+
legacyStart.phase !== "cancelled" &&
|
|
6428
|
+
legacyStart.phase !== "failed" &&
|
|
6429
|
+
legacyStart.phase !== "terminal")) &&
|
|
6430
|
+
this.toolActivityAuthorityIsCurrent(toolActivity) &&
|
|
6431
|
+
!this.toolActivityStopping &&
|
|
6432
|
+
this.opts.toolActivity?.enabled === true &&
|
|
6433
|
+
toolActivity.policyEpoch === this.toolActivityPolicyEpoch
|
|
6434
|
+
);
|
|
6435
|
+
}
|
|
6436
|
+
if (
|
|
6437
|
+
this.toolActivityAuthorityIsCurrent(toolActivity) &&
|
|
6438
|
+
this.opts.toolActivity?.enabled === true &&
|
|
6439
|
+
toolActivity.policyEpoch === this.toolActivityPolicyEpoch
|
|
6440
|
+
)
|
|
6441
|
+
return true;
|
|
6442
|
+
return this.liveMessages.has(key) && owner?.session === toolActivity.session;
|
|
6443
|
+
}
|
|
5726
6444
|
private async submitThreadedFrame(
|
|
5727
6445
|
sessionId: string,
|
|
5728
6446
|
send: ThreadedSend,
|
|
@@ -5730,17 +6448,45 @@ export class TelegramNotificationDaemon {
|
|
|
5730
6448
|
toolActivity?: ToolActivityOwner,
|
|
5731
6449
|
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
5732
6450
|
): Promise<void> {
|
|
5733
|
-
|
|
6451
|
+
const legacyStart =
|
|
6452
|
+
toolActivity?.phase === "started"
|
|
6453
|
+
? this.legacyToolStarts.get(`${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`)
|
|
6454
|
+
: toolActivity?.phase === "terminal"
|
|
6455
|
+
? this.legacyToolStartForTerminal(toolActivity)
|
|
6456
|
+
: undefined;
|
|
6457
|
+
if (legacyStart !== undefined && legacyStart.owner === toolActivity) {
|
|
6458
|
+
if (toolActivity!.phase === "started") {
|
|
6459
|
+
legacyStart.phase = "queued";
|
|
6460
|
+
legacyStart.itemId ??= `legacy-tool-start:${this.nextLegacyToolStartId++}`;
|
|
6461
|
+
}
|
|
6462
|
+
}
|
|
6463
|
+
// Terminal frames get a distinct pool item identity so they cannot collide
|
|
6464
|
+
// with an in-flight start for the same legacy settlement.
|
|
6465
|
+
const poolItemId =
|
|
6466
|
+
legacyStart !== undefined && legacyStart.owner === toolActivity
|
|
6467
|
+
? toolActivity!.phase === "terminal"
|
|
6468
|
+
? `legacy-tool-terminal:${this.nextLegacyToolStartId++}`
|
|
6469
|
+
: legacyStart.itemId
|
|
6470
|
+
: undefined;
|
|
6471
|
+
const submitted = this.submitPool({
|
|
5734
6472
|
sessionId,
|
|
5735
6473
|
lane: send.lane,
|
|
5736
6474
|
coalesceKey: send.coalesceKey,
|
|
6475
|
+
...(poolItemId !== undefined ? { itemId: poolItemId } : {}),
|
|
5737
6476
|
payload: {
|
|
5738
6477
|
send,
|
|
5739
6478
|
topicLease,
|
|
5740
6479
|
...(socketLease ? { socketLease } : {}),
|
|
5741
6480
|
...(toolActivity ? { toolActivity } : {}),
|
|
6481
|
+
...(legacyStart !== undefined && toolActivity?.phase === "terminal"
|
|
6482
|
+
? { legacyToolStart: legacyStart }
|
|
6483
|
+
: {}),
|
|
5742
6484
|
},
|
|
5743
6485
|
});
|
|
6486
|
+
if (!submitted) {
|
|
6487
|
+
this.settleRejectedLegacyToolSubmission(toolActivity, legacyStart);
|
|
6488
|
+
return;
|
|
6489
|
+
}
|
|
5744
6490
|
await this.flushPool();
|
|
5745
6491
|
}
|
|
5746
6492
|
|
|
@@ -5810,8 +6556,16 @@ export class TelegramNotificationDaemon {
|
|
|
5810
6556
|
): void {
|
|
5811
6557
|
const logicalSessionId = this.#logicalSessionId(session);
|
|
5812
6558
|
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
5813
|
-
if (!socketLease)
|
|
6559
|
+
if (!socketLease) {
|
|
6560
|
+
this.failLegacyToolStart(toolActivity);
|
|
6561
|
+
return;
|
|
6562
|
+
}
|
|
5814
6563
|
const frames = this.pendingThreadedFrames.get(logicalSessionId) ?? [];
|
|
6564
|
+
const legacyStart =
|
|
6565
|
+
toolActivity?.phase === "started"
|
|
6566
|
+
? this.legacyToolStarts.get(`${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`)
|
|
6567
|
+
: undefined;
|
|
6568
|
+
if (legacyStart !== undefined && legacyStart.owner === toolActivity) legacyStart.phase = "pending_identity";
|
|
5815
6569
|
frames.push({
|
|
5816
6570
|
send,
|
|
5817
6571
|
msg,
|
|
@@ -5819,7 +6573,10 @@ export class TelegramNotificationDaemon {
|
|
|
5819
6573
|
socketLease,
|
|
5820
6574
|
...(toolActivity ? { toolActivity } : {}),
|
|
5821
6575
|
});
|
|
5822
|
-
if (frames.length > PENDING_TOPIC_FRAME_LIMIT)
|
|
6576
|
+
if (frames.length > PENDING_TOPIC_FRAME_LIMIT) {
|
|
6577
|
+
const evicted = frames.shift();
|
|
6578
|
+
this.failLegacyToolStart(evicted?.toolActivity);
|
|
6579
|
+
}
|
|
5823
6580
|
this.pendingThreadedFrames.set(logicalSessionId, frames);
|
|
5824
6581
|
}
|
|
5825
6582
|
|
|
@@ -5828,8 +6585,14 @@ export class TelegramNotificationDaemon {
|
|
|
5828
6585
|
if (!frames || frames.length === 0) return;
|
|
5829
6586
|
this.pendingThreadedFrames.delete(sessionId);
|
|
5830
6587
|
for (const frame of frames) {
|
|
5831
|
-
if (
|
|
5832
|
-
|
|
6588
|
+
if (
|
|
6589
|
+
frame.logicalSessionId !== sessionId ||
|
|
6590
|
+
!this.#leaseTokenAllows(frame.socketLease) ||
|
|
6591
|
+
(frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled !== true)
|
|
6592
|
+
) {
|
|
6593
|
+
this.failLegacyToolStart(frame.toolActivity);
|
|
6594
|
+
continue;
|
|
6595
|
+
}
|
|
5833
6596
|
await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, frame.socketLease);
|
|
5834
6597
|
}
|
|
5835
6598
|
}
|
|
@@ -5868,13 +6631,20 @@ export class TelegramNotificationDaemon {
|
|
|
5868
6631
|
let acceptedTopicId: string | undefined;
|
|
5869
6632
|
let acceptedTopicCompensated = false;
|
|
5870
6633
|
let acceptedTopicDeleteAttempted = false;
|
|
6634
|
+
let creationSuppressed = false;
|
|
5871
6635
|
try {
|
|
5872
6636
|
const rec = await this.topics.getOrCreateTopic(
|
|
5873
6637
|
sessionId,
|
|
5874
6638
|
async () => {
|
|
5875
6639
|
if (capturedCreationLease && !this.#leaseTokenAllows(capturedCreationLease))
|
|
5876
6640
|
throw new Error("topic authority was revoked during creation");
|
|
5877
|
-
const res = await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name })
|
|
6641
|
+
const res = (await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name })) as
|
|
6642
|
+
| { result?: { message_thread_id?: unknown } }
|
|
6643
|
+
| undefined;
|
|
6644
|
+
if (res === undefined) {
|
|
6645
|
+
creationSuppressed = true;
|
|
6646
|
+
return undefined;
|
|
6647
|
+
}
|
|
5878
6648
|
if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
|
|
5879
6649
|
const response = res as {
|
|
5880
6650
|
ok?: unknown;
|
|
@@ -5958,7 +6728,7 @@ export class TelegramNotificationDaemon {
|
|
|
5958
6728
|
}
|
|
5959
6729
|
return rec.topicId;
|
|
5960
6730
|
} catch (err) {
|
|
5961
|
-
if (err instanceof ThreadedModeCapabilityRefusal) return undefined;
|
|
6731
|
+
if (creationSuppressed || err instanceof ThreadedModeCapabilityRefusal) return undefined;
|
|
5962
6732
|
if (
|
|
5963
6733
|
acceptedTopicId &&
|
|
5964
6734
|
!acceptedTopicCompensated &&
|
|
@@ -6445,6 +7215,7 @@ export class TelegramNotificationDaemon {
|
|
|
6445
7215
|
this.finishSelectedAck(expiredItem.payload.selectedAck, { status: "failed", reason: "expired" });
|
|
6446
7216
|
}
|
|
6447
7217
|
expiredItem.payload.btwDelivery?.finish("not_delivered");
|
|
7218
|
+
this.failLegacyToolStart(expiredItem.payload.toolActivity);
|
|
6448
7219
|
}
|
|
6449
7220
|
// Within a batch a finalized frame supersedes any still-queued live frame for
|
|
6450
7221
|
// the same streamed message (finalized outranks live), so drop the stale live
|
|
@@ -6469,17 +7240,18 @@ export class TelegramNotificationDaemon {
|
|
|
6469
7240
|
}
|
|
6470
7241
|
for (const item of batch) {
|
|
6471
7242
|
const toolActivity = item.payload.toolActivity;
|
|
6472
|
-
|
|
6473
|
-
toolActivity?.phase === "started"
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
) {
|
|
7243
|
+
const legacyStart =
|
|
7244
|
+
toolActivity?.phase === "started"
|
|
7245
|
+
? this.legacyToolStarts.get(`${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`)
|
|
7246
|
+
: undefined;
|
|
7247
|
+
if (legacyStart !== undefined && legacyStart.owner === toolActivity && legacyStart.phase === "queued")
|
|
7248
|
+
legacyStart.phase = "dispatching";
|
|
7249
|
+
if (toolActivity && !this.toolActivityDeliveryIsCurrent(toolActivity)) {
|
|
6479
7250
|
const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
6480
7251
|
const owner = this.toolActivityOwners.get(key);
|
|
6481
7252
|
if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
|
|
6482
7253
|
this.toolActivityOwners.delete(key);
|
|
7254
|
+
this.failLegacyToolStart(toolActivity);
|
|
6483
7255
|
this.pool.settle(item.itemId!, "removed");
|
|
6484
7256
|
continue;
|
|
6485
7257
|
}
|
|
@@ -6496,14 +7268,32 @@ export class TelegramNotificationDaemon {
|
|
|
6496
7268
|
continue;
|
|
6497
7269
|
}
|
|
6498
7270
|
try {
|
|
6499
|
-
const
|
|
7271
|
+
const { outcome } = await this.callBotApiClassified("sendMessage", btwDelivery.body, {
|
|
6500
7272
|
noRetry: true,
|
|
6501
7273
|
signal: btwDelivery.signal,
|
|
6502
7274
|
});
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
7275
|
+
if (outcome.kind === "retryable") {
|
|
7276
|
+
this.submitPool({
|
|
7277
|
+
sessionId: item.sessionId,
|
|
7278
|
+
lane: item.lane,
|
|
7279
|
+
coalesceKey: item.coalesceKey,
|
|
7280
|
+
deadlineAt: item.deadlineAt,
|
|
7281
|
+
payload: item.payload,
|
|
7282
|
+
});
|
|
7283
|
+
this.pool.settle(item.itemId!, "ambiguous");
|
|
7284
|
+
continue;
|
|
7285
|
+
}
|
|
7286
|
+
btwDelivery.finish(
|
|
7287
|
+
outcome.kind === "accepted"
|
|
7288
|
+
? "accepted"
|
|
7289
|
+
: outcome.kind === "rejected"
|
|
7290
|
+
? "not_delivered"
|
|
7291
|
+
: "uncertain",
|
|
7292
|
+
);
|
|
7293
|
+
this.pool.settle(
|
|
7294
|
+
item.itemId!,
|
|
7295
|
+
outcome.kind === "accepted" ? "accepted" : outcome.kind === "rejected" ? "rejected" : "ambiguous",
|
|
7296
|
+
);
|
|
6507
7297
|
} catch {
|
|
6508
7298
|
btwDelivery.finish("uncertain");
|
|
6509
7299
|
this.pool.settle(item.itemId!, "ambiguous");
|
|
@@ -6549,7 +7339,7 @@ export class TelegramNotificationDaemon {
|
|
|
6549
7339
|
this.pool.settle(item.itemId!, "rejected");
|
|
6550
7340
|
continue;
|
|
6551
7341
|
}
|
|
6552
|
-
const response =
|
|
7342
|
+
const { response, outcome } = await this.callBotApiClassified(
|
|
6553
7343
|
"sendMessage",
|
|
6554
7344
|
{
|
|
6555
7345
|
chat_id: this.opts.chatId,
|
|
@@ -6557,9 +7347,28 @@ export class TelegramNotificationDaemon {
|
|
|
6557
7347
|
text: "Selected!",
|
|
6558
7348
|
},
|
|
6559
7349
|
{ signal: controller.signal, noRetry: true },
|
|
6560
|
-
)
|
|
6561
|
-
|
|
6562
|
-
|
|
7350
|
+
);
|
|
7351
|
+
if (outcome.kind === "retryable") {
|
|
7352
|
+
this.pool.settle(item.itemId!, "ambiguous");
|
|
7353
|
+
if (this.effects.stopping) {
|
|
7354
|
+
this.finishSelectedAck(selectedAck, { status: "unknown", reason: "shutdown" });
|
|
7355
|
+
} else {
|
|
7356
|
+
const retry = this.pool.submit({
|
|
7357
|
+
sessionId: item.sessionId,
|
|
7358
|
+
lane: item.lane,
|
|
7359
|
+
coalesceKey: item.coalesceKey,
|
|
7360
|
+
deadlineAt: item.deadlineAt,
|
|
7361
|
+
payload: item.payload,
|
|
7362
|
+
});
|
|
7363
|
+
selectedAck.itemId = retry.itemId;
|
|
7364
|
+
selectedAck.state = "queued";
|
|
7365
|
+
selectedAck.controller = undefined;
|
|
7366
|
+
}
|
|
7367
|
+
continue;
|
|
7368
|
+
}
|
|
7369
|
+
const typedResponse = response as { ok?: unknown; result?: { message_id?: unknown } } | undefined;
|
|
7370
|
+
const messageId = typedResponse?.result?.message_id;
|
|
7371
|
+
const delivered = typedResponse?.ok === true && typeof messageId === "number";
|
|
6563
7372
|
this.finishSelectedAck(
|
|
6564
7373
|
selectedAck,
|
|
6565
7374
|
delivered ? { status: "delivered", messageId } : { status: "failed", reason: "telegram_rejected" },
|
|
@@ -6573,17 +7382,20 @@ export class TelegramNotificationDaemon {
|
|
|
6573
7382
|
}
|
|
6574
7383
|
continue;
|
|
6575
7384
|
}
|
|
6576
|
-
|
|
7385
|
+
let { send } = item.payload;
|
|
7386
|
+
const { topicLease, socketLease } = item.payload;
|
|
6577
7387
|
if (
|
|
6578
7388
|
(socketLease && !this.#leaseTokenAllows(socketLease)) ||
|
|
6579
7389
|
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6580
7390
|
) {
|
|
6581
7391
|
this.pool.settle(item.itemId!, "rejected");
|
|
7392
|
+
this.failLegacyToolStart(toolActivity);
|
|
6582
7393
|
continue;
|
|
6583
7394
|
}
|
|
6584
7395
|
const topicId = topicLease?.topicId;
|
|
6585
7396
|
if (topicId && !(await this.pairedChatIsPrivate())) {
|
|
6586
7397
|
this.pool.settle(item.itemId!, "rejected");
|
|
7398
|
+
this.failLegacyToolStart(toolActivity);
|
|
6587
7399
|
continue;
|
|
6588
7400
|
}
|
|
6589
7401
|
if (
|
|
@@ -6591,12 +7403,20 @@ export class TelegramNotificationDaemon {
|
|
|
6591
7403
|
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6592
7404
|
) {
|
|
6593
7405
|
this.pool.settle(item.itemId!, "rejected");
|
|
7406
|
+
this.failLegacyToolStart(toolActivity);
|
|
6594
7407
|
continue;
|
|
6595
7408
|
}
|
|
6596
7409
|
if (item.payload.toolActivity && !this.toolActivityAuthorityIsCurrent(item.payload.toolActivity)) {
|
|
6597
7410
|
this.pool.settle(item.itemId!, "removed");
|
|
7411
|
+
this.failLegacyToolStart(toolActivity);
|
|
6598
7412
|
continue;
|
|
6599
7413
|
}
|
|
7414
|
+
if (
|
|
7415
|
+
toolActivity?.phase === "terminal" &&
|
|
7416
|
+
!this.toolActivitySummariesAreCurrent(toolActivity) &&
|
|
7417
|
+
toolActivity.summaryFreeSend
|
|
7418
|
+
)
|
|
7419
|
+
send = toolActivity.summaryFreeSend;
|
|
6600
7420
|
// Threaded topic when available; otherwise deliver flat to the paired chat.
|
|
6601
7421
|
const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
|
|
6602
7422
|
const ckey = send.editable ? item.coalesceKey : undefined;
|
|
@@ -6605,6 +7425,8 @@ export class TelegramNotificationDaemon {
|
|
|
6605
7425
|
this.pool.settle(item.itemId!, "removed");
|
|
6606
7426
|
continue;
|
|
6607
7427
|
}
|
|
7428
|
+
const itemOutcomes: BotApiCallOutcome[] = [];
|
|
7429
|
+
const itemBotApi = this.botApiWithOutcomeCollector(itemOutcomes);
|
|
6608
7430
|
let disposition: "accepted" | "ambiguous" | "rejected" = "accepted";
|
|
6609
7431
|
try {
|
|
6610
7432
|
// Draft streaming (opt-in, off by default): stream a live turn frame as a
|
|
@@ -6643,7 +7465,7 @@ export class TelegramNotificationDaemon {
|
|
|
6643
7465
|
}
|
|
6644
7466
|
if (send.method === "sendPhoto" && send.photoBase64) {
|
|
6645
7467
|
// Real photo upload (the default botApi multiparts base64 -> file).
|
|
6646
|
-
await
|
|
7468
|
+
await itemBotApi.call("sendPhoto", {
|
|
6647
7469
|
chat_id: this.opts.chatId,
|
|
6648
7470
|
...threadField,
|
|
6649
7471
|
photo: send.photoBase64,
|
|
@@ -6652,7 +7474,7 @@ export class TelegramNotificationDaemon {
|
|
|
6652
7474
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
6653
7475
|
});
|
|
6654
7476
|
} else if (send.method === "sendDocument" && send.documentBase64) {
|
|
6655
|
-
await
|
|
7477
|
+
await itemBotApi.call("sendDocument", {
|
|
6656
7478
|
chat_id: this.opts.chatId,
|
|
6657
7479
|
...threadField,
|
|
6658
7480
|
document: send.documentBase64,
|
|
@@ -6682,7 +7504,7 @@ export class TelegramNotificationDaemon {
|
|
|
6682
7504
|
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6683
7505
|
)
|
|
6684
7506
|
return;
|
|
6685
|
-
await
|
|
7507
|
+
await itemBotApi.call("sendMessage", {
|
|
6686
7508
|
chat_id: this.opts.chatId,
|
|
6687
7509
|
...threadField,
|
|
6688
7510
|
text: chunks[0]!,
|
|
@@ -6712,7 +7534,7 @@ export class TelegramNotificationDaemon {
|
|
|
6712
7534
|
}
|
|
6713
7535
|
};
|
|
6714
7536
|
const richMessageId = await deliverRichWithFallback(
|
|
6715
|
-
|
|
7537
|
+
itemBotApi,
|
|
6716
7538
|
{ chat_id: this.opts.chatId, ...threadField },
|
|
6717
7539
|
send,
|
|
6718
7540
|
AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
|
|
@@ -6746,13 +7568,13 @@ export class TelegramNotificationDaemon {
|
|
|
6746
7568
|
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6747
7569
|
)
|
|
6748
7570
|
return;
|
|
6749
|
-
const res = (await
|
|
7571
|
+
const res = (await itemBotApi.call("editMessageText", {
|
|
6750
7572
|
chat_id: this.opts.chatId,
|
|
6751
7573
|
message_id: existingId,
|
|
6752
7574
|
text: chunks[0],
|
|
6753
7575
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
6754
7576
|
})) as { ok?: boolean; description?: string } | null;
|
|
6755
|
-
edited = res?.ok
|
|
7577
|
+
edited = res?.ok === true || /not modified/i.test(String(res?.description ?? ""));
|
|
6756
7578
|
} catch {
|
|
6757
7579
|
edited = false;
|
|
6758
7580
|
}
|
|
@@ -6762,7 +7584,7 @@ export class TelegramNotificationDaemon {
|
|
|
6762
7584
|
(!socketLease || this.#leaseTokenAllows(socketLease)) &&
|
|
6763
7585
|
(!topicLease || this.topicLeaseIsCurrent(topicLease))
|
|
6764
7586
|
) {
|
|
6765
|
-
const res = (await
|
|
7587
|
+
const res = (await itemBotApi.call("sendMessage", {
|
|
6766
7588
|
chat_id: this.opts.chatId,
|
|
6767
7589
|
...threadField,
|
|
6768
7590
|
text: chunks[0]!,
|
|
@@ -6778,7 +7600,7 @@ export class TelegramNotificationDaemon {
|
|
|
6778
7600
|
(topicLease && !this.topicLeaseIsCurrent(topicLease))
|
|
6779
7601
|
)
|
|
6780
7602
|
return;
|
|
6781
|
-
const res = (await
|
|
7603
|
+
const res = (await itemBotApi.call("sendMessage", {
|
|
6782
7604
|
chat_id: this.opts.chatId,
|
|
6783
7605
|
...threadField,
|
|
6784
7606
|
text: chunks[0]!,
|
|
@@ -6826,11 +7648,79 @@ export class TelegramNotificationDaemon {
|
|
|
6826
7648
|
disposition = "ambiguous";
|
|
6827
7649
|
if (item.payload.toolActivity?.phase === "started") this.toolActivityAmbiguous = true;
|
|
6828
7650
|
} finally {
|
|
7651
|
+
const retryable = itemOutcomes.some(outcome => outcome.kind === "retryable");
|
|
7652
|
+
const accepted = itemOutcomes.some(outcome => outcome.kind === "accepted");
|
|
7653
|
+
const rejected = itemOutcomes.some(outcome => outcome.kind === "rejected");
|
|
7654
|
+
if (retryable) {
|
|
7655
|
+
if (!accepted) {
|
|
7656
|
+
let retryItemId: string | undefined;
|
|
7657
|
+
let retryAllowed = true;
|
|
7658
|
+
if (toolActivity?.phase === "started") {
|
|
7659
|
+
retryAllowed =
|
|
7660
|
+
!this.toolActivityStopping &&
|
|
7661
|
+
!this.stopRequested &&
|
|
7662
|
+
this.opts.toolActivity?.enabled === true &&
|
|
7663
|
+
toolActivity.policyEpoch === this.toolActivityPolicyEpoch &&
|
|
7664
|
+
this.toolActivityAuthorityIsCurrent(toolActivity);
|
|
7665
|
+
if (toolActivity.session.toolActivityCapability === "v1") {
|
|
7666
|
+
const state = this.legacyToolStarts.get(
|
|
7667
|
+
`${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`,
|
|
7668
|
+
);
|
|
7669
|
+
retryAllowed =
|
|
7670
|
+
retryAllowed &&
|
|
7671
|
+
state !== undefined &&
|
|
7672
|
+
state.owner === toolActivity &&
|
|
7673
|
+
!state.terminalClaimed;
|
|
7674
|
+
if (state !== undefined && state.owner === toolActivity) {
|
|
7675
|
+
if (retryAllowed) {
|
|
7676
|
+
state.phase = "queued";
|
|
7677
|
+
state.itemId = `legacy-tool-start:${this.nextLegacyToolStartId++}`;
|
|
7678
|
+
retryItemId = state.itemId;
|
|
7679
|
+
} else {
|
|
7680
|
+
this.settleLegacyToolStart(state, "cancelled");
|
|
7681
|
+
}
|
|
7682
|
+
}
|
|
7683
|
+
} else if (retryAllowed) {
|
|
7684
|
+
retryItemId = `tool-activity-start:${this.nextLegacyToolStartId++}`;
|
|
7685
|
+
}
|
|
7686
|
+
} else if (toolActivity?.phase === "terminal" && item.payload.legacyToolStart !== undefined) {
|
|
7687
|
+
retryItemId = `legacy-tool-terminal:${this.nextLegacyToolStartId++}`;
|
|
7688
|
+
}
|
|
7689
|
+
const newerCoalescedItem =
|
|
7690
|
+
toolActivity === undefined &&
|
|
7691
|
+
item.coalesceKey !== undefined &&
|
|
7692
|
+
this.pool.someQueued(
|
|
7693
|
+
queued =>
|
|
7694
|
+
queued.sessionId === item.sessionId &&
|
|
7695
|
+
queued.lane === item.lane &&
|
|
7696
|
+
queued.coalesceKey === item.coalesceKey,
|
|
7697
|
+
);
|
|
7698
|
+
if (retryAllowed && !newerCoalescedItem) {
|
|
7699
|
+
this.submitPool({
|
|
7700
|
+
sessionId: item.sessionId,
|
|
7701
|
+
lane: item.lane,
|
|
7702
|
+
coalesceKey: item.coalesceKey,
|
|
7703
|
+
deadlineAt: item.deadlineAt,
|
|
7704
|
+
...(retryItemId !== undefined ? { itemId: retryItemId } : {}),
|
|
7705
|
+
payload: item.payload,
|
|
7706
|
+
});
|
|
7707
|
+
}
|
|
7708
|
+
}
|
|
7709
|
+
disposition = "ambiguous";
|
|
7710
|
+
} else if (disposition === "accepted" && !accepted && rejected) {
|
|
7711
|
+
disposition = "rejected";
|
|
7712
|
+
}
|
|
6829
7713
|
this.pool.settle(item.itemId!, disposition);
|
|
7714
|
+
if (toolActivity?.phase === "started" && !retryable) this.failLegacyToolStart(toolActivity);
|
|
7715
|
+
if (toolActivity?.phase === "terminal" && !retryable) {
|
|
7716
|
+
const state = item.payload.legacyToolStart;
|
|
7717
|
+
if (state !== undefined && this.legacyToolStarts.get(state.key) === state)
|
|
7718
|
+
this.settleLegacyToolStart(state, "terminal");
|
|
7719
|
+
}
|
|
6830
7720
|
// A terminal tool frame owns the end of this coalescing key even when both
|
|
6831
7721
|
// edit and fallback delivery fail. Retaining the old message id would leak
|
|
6832
7722
|
// one entry per failure and let a later reused key edit stale Telegram state.
|
|
6833
|
-
if (send.terminal && editKey) {
|
|
7723
|
+
if (send.terminal && editKey && !retryable) {
|
|
6834
7724
|
this.liveMessages.delete(editKey);
|
|
6835
7725
|
const owner = this.toolActivityOwners.get(editKey);
|
|
6836
7726
|
if (!item.payload.toolActivity || owner?.session === item.payload.toolActivity.session)
|
|
@@ -6866,6 +7756,18 @@ export class TelegramNotificationDaemon {
|
|
|
6866
7756
|
}
|
|
6867
7757
|
}
|
|
6868
7758
|
if (toolActivity) {
|
|
7759
|
+
const legacyStart = this.legacyToolStarts.get(mapKey);
|
|
7760
|
+
if (
|
|
7761
|
+
legacyStart !== undefined &&
|
|
7762
|
+
legacyStart.owner === toolActivity &&
|
|
7763
|
+
(toolActivity.session.toolActivityCapability !== "v1" ||
|
|
7764
|
+
legacyStart.policyEpoch !== this.toolActivityPolicyEpoch ||
|
|
7765
|
+
!this.toolActivityAuthorityIsCurrent(toolActivity))
|
|
7766
|
+
) {
|
|
7767
|
+
this.settleLegacyToolStart(legacyStart, "terminal");
|
|
7768
|
+
void this.enqueueToolTerminalization([{ messageId, owner: toolActivity }], false);
|
|
7769
|
+
return;
|
|
7770
|
+
}
|
|
6869
7771
|
const owner = this.toolActivityOwners.get(mapKey);
|
|
6870
7772
|
if (this.revokedToolEndpoints.has(toolActivity.endpointDigest) || owner?.session !== toolActivity.session) {
|
|
6871
7773
|
void this.enqueueToolTerminalization([{ messageId, owner: toolActivity }], false);
|
|
@@ -6874,6 +7776,10 @@ export class TelegramNotificationDaemon {
|
|
|
6874
7776
|
}
|
|
6875
7777
|
this.liveMessages.set(mapKey, messageId);
|
|
6876
7778
|
if (toolActivity) this.toolActivityOwners.set(mapKey, toolActivity);
|
|
7779
|
+
if (toolActivity) {
|
|
7780
|
+
const state = this.legacyToolStarts.get(mapKey);
|
|
7781
|
+
if (state !== undefined && state.owner === toolActivity) this.settleLegacyToolStart(state, "visible");
|
|
7782
|
+
}
|
|
6877
7783
|
}
|
|
6878
7784
|
|
|
6879
7785
|
/**
|
|
@@ -6892,16 +7798,50 @@ export class TelegramNotificationDaemon {
|
|
|
6892
7798
|
socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
|
|
6893
7799
|
): Promise<void> {
|
|
6894
7800
|
if ((socketLease && !this.#leaseTokenAllows(socketLease)) || !(await this.pairedChatIsPrivate())) return;
|
|
7801
|
+
if (toolActivity && !this.toolActivityDeliveryIsCurrent(toolActivity)) return;
|
|
6895
7802
|
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
6896
7803
|
await this.notifyThreadedFallback(socketLease);
|
|
7804
|
+
if (toolActivity && !this.toolActivityDeliveryIsCurrent(toolActivity)) return;
|
|
6897
7805
|
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
7806
|
+
if (toolActivity && !this.toolActivityDeliveryIsCurrent(toolActivity)) return;
|
|
6898
7807
|
if (send.identity && this.flatIdentitySent.has(sessionId)) return;
|
|
6899
|
-
this.
|
|
7808
|
+
if (toolActivity && !this.toolActivityDeliveryIsCurrent(toolActivity)) return;
|
|
7809
|
+
const legacyToolStart =
|
|
7810
|
+
toolActivity?.phase === "started"
|
|
7811
|
+
? this.legacyToolStarts.get(`${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`)
|
|
7812
|
+
: toolActivity?.phase === "terminal"
|
|
7813
|
+
? this.legacyToolStartForTerminal(toolActivity)
|
|
7814
|
+
: undefined;
|
|
7815
|
+
if (
|
|
7816
|
+
legacyToolStart !== undefined &&
|
|
7817
|
+
legacyToolStart.owner === toolActivity &&
|
|
7818
|
+
toolActivity?.phase === "started"
|
|
7819
|
+
) {
|
|
7820
|
+
legacyToolStart.phase = "queued";
|
|
7821
|
+
legacyToolStart.itemId ??= `legacy-tool-start:${this.nextLegacyToolStartId++}`;
|
|
7822
|
+
}
|
|
7823
|
+
const poolItemId =
|
|
7824
|
+
legacyToolStart !== undefined && legacyToolStart.owner === toolActivity
|
|
7825
|
+
? toolActivity!.phase === "terminal"
|
|
7826
|
+
? `legacy-tool-terminal:${this.nextLegacyToolStartId++}`
|
|
7827
|
+
: legacyToolStart.itemId
|
|
7828
|
+
: undefined;
|
|
7829
|
+
const submitted = this.submitPool({
|
|
6900
7830
|
sessionId,
|
|
6901
7831
|
lane: send.lane,
|
|
6902
7832
|
coalesceKey: send.coalesceKey,
|
|
6903
|
-
|
|
7833
|
+
...(poolItemId !== undefined ? { itemId: poolItemId } : {}),
|
|
7834
|
+
payload: {
|
|
7835
|
+
send,
|
|
7836
|
+
...(socketLease ? { socketLease } : {}),
|
|
7837
|
+
...(toolActivity ? { toolActivity } : {}),
|
|
7838
|
+
...(legacyToolStart !== undefined && toolActivity?.phase === "terminal" ? { legacyToolStart } : {}),
|
|
7839
|
+
},
|
|
6904
7840
|
});
|
|
7841
|
+
if (!submitted) {
|
|
7842
|
+
this.settleRejectedLegacyToolSubmission(toolActivity, legacyToolStart);
|
|
7843
|
+
return;
|
|
7844
|
+
}
|
|
6905
7845
|
await this.flushPool();
|
|
6906
7846
|
if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
|
|
6907
7847
|
if (send.identity) this.flatIdentitySent.add(sessionId);
|
|
@@ -6920,6 +7860,7 @@ export class TelegramNotificationDaemon {
|
|
|
6920
7860
|
ok?: unknown;
|
|
6921
7861
|
result?: { type?: unknown };
|
|
6922
7862
|
};
|
|
7863
|
+
if (res === undefined) return "indeterminate";
|
|
6923
7864
|
if (res?.ok !== true) {
|
|
6924
7865
|
logger.warn("notifications: getChat privacy check indeterminate (non-success response)");
|
|
6925
7866
|
return "indeterminate";
|
|
@@ -6982,12 +7923,10 @@ export class TelegramNotificationDaemon {
|
|
|
6982
7923
|
this.runtime.stopInterval("telegram-flush");
|
|
6983
7924
|
}
|
|
6984
7925
|
private async renewOwnershipHeartbeat(): Promise<boolean> {
|
|
6985
|
-
return
|
|
7926
|
+
return renewOwnerHeartbeatSidecar({
|
|
6986
7927
|
settings: this.opts.settings,
|
|
6987
7928
|
ownerId: this.opts.ownerId,
|
|
6988
7929
|
acquisitionId: this.opts.ownerId,
|
|
6989
|
-
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
6990
|
-
chatId: this.opts.chatId,
|
|
6991
7930
|
fs: this.fsImpl,
|
|
6992
7931
|
now: this.opts.now,
|
|
6993
7932
|
pid: this.opts.pid ?? process.pid,
|
|
@@ -7145,7 +8084,7 @@ export class TelegramNotificationDaemon {
|
|
|
7145
8084
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
7146
8085
|
reply_markup: { inline_keyboard },
|
|
7147
8086
|
});
|
|
7148
|
-
if (response
|
|
8087
|
+
if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
|
|
7149
8088
|
for (const alias of aliases) this.#modelChoiceAliases.delete(alias);
|
|
7150
8089
|
logger.warn("notifications: failed to send model selection keyboard");
|
|
7151
8090
|
return false;
|
|
@@ -7161,6 +8100,10 @@ export class TelegramNotificationDaemon {
|
|
|
7161
8100
|
async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
|
|
7162
8101
|
if (msg?.type === "hello") {
|
|
7163
8102
|
const capabilities = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
8103
|
+
const previousToolActivityCapability = session.toolActivityCapability;
|
|
8104
|
+
session.toolActivityCapability = negotiateToolActivityCapability(previousToolActivityCapability, capabilities);
|
|
8105
|
+
if (previousToolActivityCapability === "v1" && session.toolActivityCapability === "v2")
|
|
8106
|
+
this.cleanLegacyToolStartsForCapabilityUpgrade(session);
|
|
7164
8107
|
if (capabilities.includes("ephemeral_turn_v1")) {
|
|
7165
8108
|
session.ephemeralCapable = true;
|
|
7166
8109
|
this.#resumeBtwTurnsForSession(session);
|
|
@@ -7172,7 +8115,7 @@ export class TelegramNotificationDaemon {
|
|
|
7172
8115
|
return;
|
|
7173
8116
|
}
|
|
7174
8117
|
if (session.replayPending) {
|
|
7175
|
-
if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled
|
|
8118
|
+
if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled !== true || this.toolActivityStopping))
|
|
7176
8119
|
return;
|
|
7177
8120
|
const matchingReplay = msg?.type === "event_replay_result" && msg.id === session.replayId;
|
|
7178
8121
|
if (!matchingReplay) {
|
|
@@ -7526,34 +8469,76 @@ export class TelegramNotificationDaemon {
|
|
|
7526
8469
|
return;
|
|
7527
8470
|
}
|
|
7528
8471
|
if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
|
|
7529
|
-
|
|
8472
|
+
let threadedFrame = msg as Record<string, unknown>;
|
|
8473
|
+
let legacyUnknownStart: LegacyToolStartSettlement | undefined;
|
|
8474
|
+
if (threadedFrame.type === "tool_activity" && threadedFrame.phase === "unknown") {
|
|
8475
|
+
const toolCallId = typeof threadedFrame.toolCallId === "string" ? threadedFrame.toolCallId : undefined;
|
|
8476
|
+
const toolName = typeof threadedFrame.toolName === "string" ? threadedFrame.toolName : undefined;
|
|
8477
|
+
const liveKey = toolCallId ? `${this.#logicalSessionId(session)}:tool:${toolCallId}` : undefined;
|
|
8478
|
+
const state = liveKey ? this.legacyToolStarts.get(liveKey) : undefined;
|
|
8479
|
+
if (
|
|
8480
|
+
session.toolActivityCapability !== "v1" ||
|
|
8481
|
+
!state ||
|
|
8482
|
+
state.owner.session !== session ||
|
|
8483
|
+
state.owner.toolName !== toolName ||
|
|
8484
|
+
state.terminalClaimed
|
|
8485
|
+
)
|
|
8486
|
+
return;
|
|
8487
|
+
state.terminalClaimed = true;
|
|
8488
|
+
if (this.cancelUnsentLegacyToolStart(state)) return;
|
|
8489
|
+
if (state.phase === "dispatching") await state.settled;
|
|
8490
|
+
if (
|
|
8491
|
+
state.settledOutcome !== "visible" ||
|
|
8492
|
+
this.legacyToolStarts.get(state.key) !== state ||
|
|
8493
|
+
session.toolActivityCapability !== "v1" ||
|
|
8494
|
+
state.owner.session !== session ||
|
|
8495
|
+
state.policyEpoch !== this.toolActivityPolicyEpoch ||
|
|
8496
|
+
!this.toolActivityAuthorityIsCurrent(state.owner) ||
|
|
8497
|
+
this.toolActivityOwners.get(state.key) !== state.owner ||
|
|
8498
|
+
!this.liveMessages.has(state.key)
|
|
8499
|
+
)
|
|
8500
|
+
return;
|
|
8501
|
+
legacyUnknownStart = state;
|
|
8502
|
+
threadedFrame = { ...this.toolActivityFrameWithoutSummaries(threadedFrame), phase: "cancelled" };
|
|
8503
|
+
}
|
|
7530
8504
|
const toolActivity = this.toolActivityOwner(session, threadedFrame);
|
|
8505
|
+
if (threadedFrame.type === "tool_activity" && !toolActivity) return;
|
|
7531
8506
|
const toolAdmissionEpoch = toolActivity
|
|
7532
8507
|
? (this.replayToolActivityEpochs.get(threadedFrame) ?? this.toolActivityPolicyEpoch)
|
|
7533
8508
|
: undefined;
|
|
7534
8509
|
if (toolActivity) toolActivity.policyEpoch = toolAdmissionEpoch;
|
|
7535
|
-
|
|
7536
|
-
|
|
7537
|
-
(
|
|
7538
|
-
|
|
7539
|
-
|
|
7540
|
-
|
|
8510
|
+
if (toolActivity?.phase === "started" && toolActivity.session.toolActivityCapability === "v1") {
|
|
8511
|
+
const state = this.admitLegacyToolStart(toolActivity);
|
|
8512
|
+
if (!state) return;
|
|
8513
|
+
}
|
|
8514
|
+
if (legacyUnknownStart && toolActivity?.phase === "terminal") {
|
|
8515
|
+
if (
|
|
8516
|
+
legacyUnknownStart.owner.session !== toolActivity.session ||
|
|
8517
|
+
legacyUnknownStart.key !== `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`
|
|
8518
|
+
)
|
|
8519
|
+
return;
|
|
8520
|
+
}
|
|
8521
|
+
const toolFrameIsCurrent = (): boolean => !toolActivity || this.toolActivityDeliveryIsCurrent(toolActivity);
|
|
7541
8522
|
const abandonStaleToolStart = (): void => {
|
|
7542
8523
|
if (toolActivity?.phase !== "started") return;
|
|
7543
8524
|
const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
7544
8525
|
const owner = this.toolActivityOwners.get(key);
|
|
7545
8526
|
if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
|
|
7546
8527
|
this.toolActivityOwners.delete(key);
|
|
8528
|
+
this.failLegacyToolStart(toolActivity);
|
|
7547
8529
|
};
|
|
7548
8530
|
if (toolActivity) {
|
|
7549
8531
|
const liveKey = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
7550
8532
|
const currentOwner = this.toolActivityOwners.get(liveKey);
|
|
7551
8533
|
if (toolActivity.phase === "started") {
|
|
7552
|
-
if (!
|
|
8534
|
+
if (!toolFrameIsCurrent()) {
|
|
8535
|
+
this.failLegacyToolStart(toolActivity);
|
|
8536
|
+
return;
|
|
8537
|
+
}
|
|
7553
8538
|
this.toolActivityOwners.set(liveKey, toolActivity);
|
|
7554
8539
|
} else {
|
|
7555
8540
|
if (currentOwner && currentOwner.session !== session) return;
|
|
7556
|
-
if (this.opts.toolActivity?.enabled
|
|
8541
|
+
if (this.opts.toolActivity?.enabled !== true) {
|
|
7557
8542
|
if (!currentOwner) return;
|
|
7558
8543
|
if (!this.liveMessages.has(liveKey)) {
|
|
7559
8544
|
// A start may already be granted to the serialized dispatcher but not
|
|
@@ -7569,8 +8554,21 @@ export class TelegramNotificationDaemon {
|
|
|
7569
8554
|
}
|
|
7570
8555
|
}
|
|
7571
8556
|
}
|
|
7572
|
-
const
|
|
7573
|
-
|
|
8557
|
+
const summaryFreeToolFrame =
|
|
8558
|
+
toolActivity?.phase === "terminal" ? this.toolActivityFrameWithoutSummaries(threadedFrame) : threadedFrame;
|
|
8559
|
+
const renderedFrame =
|
|
8560
|
+
toolActivity?.phase === "terminal" && !this.toolActivitySummariesAreCurrent(toolActivity)
|
|
8561
|
+
? summaryFreeToolFrame
|
|
8562
|
+
: threadedFrame;
|
|
8563
|
+
const send = this.renderThreadedFrame(renderedFrame);
|
|
8564
|
+
if (toolActivity?.phase === "terminal") {
|
|
8565
|
+
const summaryFreeSend = this.renderThreadedFrame(summaryFreeToolFrame);
|
|
8566
|
+
if (summaryFreeSend) toolActivity.summaryFreeSend = summaryFreeSend;
|
|
8567
|
+
}
|
|
8568
|
+
if (!send) {
|
|
8569
|
+
this.failLegacyToolStart(toolActivity);
|
|
8570
|
+
return;
|
|
8571
|
+
}
|
|
7574
8572
|
const transportLogicalSessionId = this.#logicalSessionId(session);
|
|
7575
8573
|
// Preserve legacy identity routing for direct/non-replay session callers.
|
|
7576
8574
|
// Authenticated transports never infer topic ownership from display identity:
|
|
@@ -7579,19 +8577,27 @@ export class TelegramNotificationDaemon {
|
|
|
7579
8577
|
send.identity && !session.logicalSessionIdTrusted && this.sessions.get(session.sessionId) !== session
|
|
7580
8578
|
? (this.topicOwnerForIdentity(msg) ?? transportLogicalSessionId)
|
|
7581
8579
|
: transportLogicalSessionId;
|
|
7582
|
-
if (!this.#leaseAllows(session, logicalSessionId))
|
|
8580
|
+
if (!this.#leaseAllows(session, logicalSessionId)) {
|
|
8581
|
+
this.failLegacyToolStart(toolActivity);
|
|
8582
|
+
return;
|
|
8583
|
+
}
|
|
7583
8584
|
const socketLease = this.#socketLease(session, logicalSessionId);
|
|
7584
|
-
if (!socketLease)
|
|
8585
|
+
if (!socketLease) {
|
|
8586
|
+
this.failLegacyToolStart(toolActivity);
|
|
8587
|
+
return;
|
|
8588
|
+
}
|
|
7585
8589
|
const existingTopic = await this.existingTopicForPrivateChat(logicalSessionId);
|
|
7586
|
-
if (!
|
|
8590
|
+
if (!toolFrameIsCurrent()) {
|
|
7587
8591
|
abandonStaleToolStart();
|
|
7588
8592
|
return;
|
|
7589
8593
|
}
|
|
7590
8594
|
if (
|
|
7591
8595
|
this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
|
|
7592
8596
|
this.topics.get(logicalSessionId)?.bindingMalformed
|
|
7593
|
-
)
|
|
8597
|
+
) {
|
|
8598
|
+
this.failLegacyToolStart(toolActivity);
|
|
7594
8599
|
return;
|
|
8600
|
+
}
|
|
7595
8601
|
if (!send.identity && !existingTopic && !this.flatIdentitySent.has(logicalSessionId)) {
|
|
7596
8602
|
this.rememberPendingThreadedFrame(session, send, threadedFrame, toolActivity);
|
|
7597
8603
|
return;
|
|
@@ -7604,8 +8610,14 @@ export class TelegramNotificationDaemon {
|
|
|
7604
8610
|
if (
|
|
7605
8611
|
this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
|
|
7606
8612
|
this.topics.get(logicalSessionId)?.bindingMalformed
|
|
7607
|
-
)
|
|
8613
|
+
) {
|
|
8614
|
+
this.failLegacyToolStart(toolActivity);
|
|
7608
8615
|
return;
|
|
8616
|
+
}
|
|
8617
|
+
if (!toolFrameIsCurrent()) {
|
|
8618
|
+
abandonStaleToolStart();
|
|
8619
|
+
return;
|
|
8620
|
+
}
|
|
7609
8621
|
await this.deliverFlatFallback(logicalSessionId, send, toolActivity, socketLease);
|
|
7610
8622
|
return;
|
|
7611
8623
|
}
|
|
@@ -7647,7 +8659,7 @@ export class TelegramNotificationDaemon {
|
|
|
7647
8659
|
await this.reconcileUserTopicName(topicLease);
|
|
7648
8660
|
return;
|
|
7649
8661
|
}
|
|
7650
|
-
if (!
|
|
8662
|
+
if (!toolFrameIsCurrent()) {
|
|
7651
8663
|
abandonStaleToolStart();
|
|
7652
8664
|
return;
|
|
7653
8665
|
}
|
|
@@ -7726,7 +8738,7 @@ export class TelegramNotificationDaemon {
|
|
|
7726
8738
|
// returns the last chunk's message_id (the reply-routable message).
|
|
7727
8739
|
const sendHtmlChunks = async (): Promise<number | undefined> => {
|
|
7728
8740
|
const chunks = splitTelegramHtml(rendered.text);
|
|
7729
|
-
let result: { result?: { message_id?: number } }
|
|
8741
|
+
let result: { result?: { message_id?: number } } | undefined;
|
|
7730
8742
|
for (let i = 0; i < chunks.length; i++) {
|
|
7731
8743
|
if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease)))
|
|
7732
8744
|
return undefined;
|
|
@@ -7736,9 +8748,10 @@ export class TelegramNotificationDaemon {
|
|
|
7736
8748
|
text: chunks[i]!,
|
|
7737
8749
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
7738
8750
|
...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
|
|
7739
|
-
})) as { result?: { message_id?: number } };
|
|
8751
|
+
})) as { result?: { message_id?: number } } | undefined;
|
|
8752
|
+
if (result === undefined) return undefined;
|
|
7740
8753
|
}
|
|
7741
|
-
return result
|
|
8754
|
+
return result?.result?.message_id;
|
|
7742
8755
|
};
|
|
7743
8756
|
const kind = msg.kind === "idle" ? "idle" : "ask";
|
|
7744
8757
|
if (this.opts.rich?.enabled !== false) {
|
|
@@ -8042,6 +9055,7 @@ export class TelegramNotificationDaemon {
|
|
|
8042
9055
|
? "notifications.telegram.rich.enabled"
|
|
8043
9056
|
: "notifications.telegram.toolActivity.enabled";
|
|
8044
9057
|
const label = isRichCommand ? "Rich messages" : "Tool activity";
|
|
9058
|
+
const toolActivityChanged = isRichCommand || this.opts.toolActivity?.enabled !== desired;
|
|
8045
9059
|
try {
|
|
8046
9060
|
await this.opts.settings.set(settingPath, desired);
|
|
8047
9061
|
// Confirm only after the global config write is durable.
|
|
@@ -8055,13 +9069,26 @@ export class TelegramNotificationDaemon {
|
|
|
8055
9069
|
}
|
|
8056
9070
|
if (isRichCommand) {
|
|
8057
9071
|
this.opts.rich = { enabled: desired };
|
|
8058
|
-
} else {
|
|
9072
|
+
} else if (toolActivityChanged) {
|
|
8059
9073
|
this.toolActivityPolicyEpoch++;
|
|
8060
9074
|
this.opts.toolActivity = { enabled: desired };
|
|
9075
|
+
const legacySettlements = this.cancelLegacyToolStartsForPolicyTransition();
|
|
8061
9076
|
if (!desired) {
|
|
8062
|
-
|
|
8063
|
-
|
|
8064
|
-
|
|
9077
|
+
// Unsent legacy starts are cancelled by exact item/frame identity; a
|
|
9078
|
+
// dispatch already inside Bot API is joined through its own settlement.
|
|
9079
|
+
await legacySettlements;
|
|
9080
|
+
await this.flushChain;
|
|
9081
|
+
const removedTools = this.pool.removeWhere(item => {
|
|
9082
|
+
const toolActivity = item.payload.toolActivity;
|
|
9083
|
+
if (!toolActivity) return false;
|
|
9084
|
+
if (toolActivity.phase === "started") return true;
|
|
9085
|
+
// Keep a terminal only when its exact start is already visible. A
|
|
9086
|
+
// queued or pending start is cancelled above and cannot retain a
|
|
9087
|
+
// terminal merely because its owner record has not been swept yet.
|
|
9088
|
+
const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
|
|
9089
|
+
const owner = this.toolActivityOwners.get(key);
|
|
9090
|
+
return !this.liveMessages.has(key) || owner?.session !== toolActivity.session;
|
|
9091
|
+
});
|
|
8065
9092
|
for (const item of removedTools) {
|
|
8066
9093
|
const toolActivity = item.payload.toolActivity;
|
|
8067
9094
|
if (!toolActivity) continue;
|
|
@@ -8091,7 +9118,6 @@ export class TelegramNotificationDaemon {
|
|
|
8091
9118
|
// future starts are rejected immediately, queued starts are removed,
|
|
8092
9119
|
// and any already-granted Bot API effect settles before the off
|
|
8093
9120
|
// acknowledgement. Visible starts may still receive their terminal edit.
|
|
8094
|
-
await this.flushChain;
|
|
8095
9121
|
}
|
|
8096
9122
|
await reply(`${label}: ${desired ? "on" : "off"}`);
|
|
8097
9123
|
return;
|
|
@@ -8582,12 +9608,10 @@ export class TelegramNotificationDaemon {
|
|
|
8582
9608
|
while (this.running) {
|
|
8583
9609
|
if (await this.controlStopRequested()) break;
|
|
8584
9610
|
if (
|
|
8585
|
-
!(await
|
|
9611
|
+
!(await renewOwnerHeartbeatSidecar({
|
|
8586
9612
|
settings: this.opts.settings,
|
|
8587
9613
|
ownerId: this.opts.ownerId,
|
|
8588
9614
|
acquisitionId: this.opts.ownerId,
|
|
8589
|
-
tokenFingerprint: tokenFingerprint(this.opts.botToken),
|
|
8590
|
-
chatId: this.opts.chatId,
|
|
8591
9615
|
fs: this.fsImpl,
|
|
8592
9616
|
now: this.opts.now,
|
|
8593
9617
|
pid: this.opts.pid ?? process.pid,
|
|
@@ -8629,6 +9653,11 @@ export class TelegramNotificationDaemon {
|
|
|
8629
9653
|
await this.runtime.sleep(10);
|
|
8630
9654
|
}
|
|
8631
9655
|
} finally {
|
|
9656
|
+
this.running = false;
|
|
9657
|
+
this.runtime.stop();
|
|
9658
|
+
this.stopOwnershipHeartbeatTimer();
|
|
9659
|
+
const heartbeatJoined = await this.runtime.joinExclusive("telegram-owner-heartbeat", BTW_SHUTDOWN_JOIN_MS);
|
|
9660
|
+
if (!heartbeatJoined) logger.warn("heartbeat join timed out; retaining daemon ownership (release aborted)");
|
|
8632
9661
|
// A contender must not mutate durable owner state while unwinding startup.
|
|
8633
9662
|
if (ownershipProved) {
|
|
8634
9663
|
let toolShutdownError: unknown;
|
|
@@ -8644,8 +9673,6 @@ export class TelegramNotificationDaemon {
|
|
|
8644
9673
|
if (toolShutdownError) throw toolShutdownError;
|
|
8645
9674
|
await this.#drainBtwTurns();
|
|
8646
9675
|
await this.toolTerminalizationChain;
|
|
8647
|
-
this.runtime.stop();
|
|
8648
|
-
this.stopOwnershipHeartbeatTimer();
|
|
8649
9676
|
this.stopFlushTimer();
|
|
8650
9677
|
this.stopScanTimer();
|
|
8651
9678
|
this.stopTypingTimer();
|
|
@@ -8670,7 +9697,7 @@ export class TelegramNotificationDaemon {
|
|
|
8670
9697
|
deadline.promise,
|
|
8671
9698
|
]);
|
|
8672
9699
|
clearTimeout(deadlineTimer);
|
|
8673
|
-
const quiesced = completed && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
|
|
9700
|
+
const quiesced = completed && heartbeatJoined && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
|
|
8674
9701
|
if (!quiesced || !persisted) {
|
|
8675
9702
|
logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
|
|
8676
9703
|
} else {
|