@gajae-code/coding-agent 0.11.7 → 0.11.9
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 +73 -1
- package/dist/types/cli/args.d.ts +0 -2
- package/dist/types/cli.d.ts +1 -0
- package/dist/types/config/keybindings.d.ts +33 -3
- package/dist/types/config/model-profile-activation.d.ts +4 -3
- package/dist/types/config/model-profile-contract.d.ts +38 -0
- package/dist/types/config/model-profiles.d.ts +2 -3
- package/dist/types/config/settings-schema.d.ts +15 -2
- package/dist/types/edit/modes/patch.d.ts +13 -0
- package/dist/types/edit/path-mutation-lock.d.ts +15 -0
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/eval/py/tool-bridge.d.ts +1 -2
- package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
- package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
- package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
- package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
- package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +44 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +1 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
- package/dist/types/main.d.ts +8 -0
- package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
- package/dist/types/modes/components/tool-status-header.d.ts +3 -2
- package/dist/types/modes/components/welcome.d.ts +4 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +5 -2
- package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
- package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
- package/dist/types/modes/types.d.ts +3 -0
- package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
- package/dist/types/runtime-mcp/manager.d.ts +1 -1
- package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
- package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
- package/dist/types/runtime-mcp/redaction.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
- package/dist/types/sdk/bus/config-commands.d.ts +2 -2
- package/dist/types/sdk/bus/config.d.ts +2 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +25 -0
- package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +15 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
- package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +1 -1
- package/dist/types/sdk/host/query/handlers.d.ts +9 -0
- package/dist/types/sdk/index.d.ts +2 -0
- package/dist/types/sdk/prompt-status.d.ts +88 -0
- package/dist/types/sdk/session-directory.d.ts +1 -1
- package/dist/types/sdk/startup-capability.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +22 -3
- package/dist/types/session/internal/managed-session-storage.d.ts +24 -4
- package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
- package/dist/types/session/session-manager.d.ts +5 -1
- package/dist/types/session/session-storage.d.ts +31 -0
- package/dist/types/skill-state/active-state.d.ts +4 -0
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/task/receipt.d.ts +2 -0
- package/dist/types/task/types.d.ts +94 -0
- package/dist/types/tools/ask.d.ts +454 -174
- package/dist/types/tools/image-gen.d.ts +47 -1
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/utils/prompt-suggestion.d.ts +33 -0
- package/package.json +8 -8
- package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
- package/scripts/dogfood-repository-binding.ts +209 -0
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/scripts/run-test-manifest.ts +4 -4
- package/src/cli/args.ts +2 -92
- package/src/cli/mcp-cli.ts +4 -21
- package/src/cli/notify-cli.ts +5 -38
- package/src/cli.ts +50 -0
- package/src/commands/deep-interview.ts +9 -4
- package/src/commands/sdk.ts +2 -2
- package/src/commit/agentic/index.ts +3 -3
- package/src/commit/map-reduce/index.ts +2 -2
- package/src/config/keybindings.ts +157 -24
- package/src/config/model-profile-activation.ts +7 -18
- package/src/config/model-profile-contract.ts +161 -0
- package/src/config/model-profiles.ts +2 -3
- package/src/config/model-registry.ts +11 -1
- package/src/config/settings-schema.ts +17 -2
- package/src/coordinator-mcp/model-preset.ts +21 -74
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +80 -165
- package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
- package/src/edit/index.ts +3 -6
- package/src/edit/modes/patch.ts +38 -1
- package/src/edit/modes/replace.ts +42 -0
- package/src/edit/path-mutation-lock.ts +112 -0
- package/src/eval/py/executor.ts +30 -11
- package/src/eval/py/prelude.py +2 -2
- package/src/eval/py/tool-bridge.ts +34 -18
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
- package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
- package/src/gjc-runtime/deep-interview-draft.ts +842 -0
- package/src/gjc-runtime/deep-interview-payload.ts +262 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +294 -226
- package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
- package/src/gjc-runtime/deep-interview-state.ts +916 -4
- package/src/gjc-runtime/launch-tmux.ts +1 -1
- package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
- package/src/gjc-runtime/ralplan-runtime.ts +78 -4
- package/src/gjc-runtime/repository-binding.ts +267 -0
- package/src/gjc-runtime/session-state-sidecar.ts +25 -9
- package/src/gjc-runtime/state-writer.ts +166 -2
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
- package/src/gjc-runtime/tmux-sessions.ts +45 -6
- package/src/gjc-runtime/ultragoal-guard.ts +5 -5
- package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
- package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
- package/src/gjc-runtime/workflow-manifest.ts +191 -5
- package/src/internal-urls/docs-index.generated.ts +8 -6
- package/src/internal-urls/local-protocol.ts +33 -16
- package/src/internal-urls/mcp-protocol.ts +73 -11
- package/src/main.ts +25 -10
- package/src/modes/DESIGN.md +5 -0
- package/src/modes/acp/acp-agent.ts +50 -16
- package/src/modes/components/model-selector.ts +42 -8
- package/src/modes/components/plan-preview-overlay.ts +1 -1
- package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
- package/src/modes/components/tool-status-header.ts +20 -4
- package/src/modes/components/welcome.ts +32 -20
- package/src/modes/controllers/command-controller.ts +97 -54
- package/src/modes/controllers/event-controller.ts +10 -0
- package/src/modes/controllers/input-controller.ts +7 -2
- package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
- package/src/modes/controllers/selector-controller.ts +46 -54
- package/src/modes/interactive-mode.ts +70 -36
- package/src/modes/prompt-action-autocomplete.ts +32 -13
- package/src/modes/prompt-suggestion-controller.ts +96 -0
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
- package/src/modes/types.ts +3 -0
- package/src/modes/utils/hotkeys-markdown.ts +64 -36
- package/src/modes/utils/ui-helpers.ts +5 -3
- package/src/prompts/agents/architect.md +0 -1
- package/src/prompts/agents/critic.md +0 -1
- package/src/prompts/system/prompt-suggestion-system.md +32 -0
- package/src/prompts/system/system-prompt.md +7 -4
- package/src/prompts/tools/cron.md +3 -1
- package/src/runtime-mcp/json-rpc.ts +7 -4
- package/src/runtime-mcp/manager.ts +51 -53
- package/src/runtime-mcp/oauth-flow.ts +0 -72
- package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
- package/src/runtime-mcp/redaction.ts +30 -0
- package/src/runtime-mcp/transports/http.ts +13 -6
- package/src/sdk/broker/broker.ts +34 -20
- package/src/sdk/broker/lifecycle.ts +185 -36
- package/src/sdk/broker/session-index.ts +11 -1
- package/src/sdk/bus/chat-daemon-control.ts +20 -5
- package/src/sdk/bus/config-commands.ts +2 -2
- package/src/sdk/bus/config.ts +9 -1
- package/src/sdk/bus/conversation-store.ts +40 -6
- package/src/sdk/bus/index.ts +240 -65
- package/src/sdk/bus/lifecycle-commands.ts +47 -3
- package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
- package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
- package/src/sdk/bus/notification-orchestration.ts +12 -0
- package/src/sdk/bus/notification-service.ts +27 -2
- package/src/sdk/bus/prompt-reconciliation.ts +239 -0
- package/src/sdk/bus/recent-activity.ts +90 -26
- package/src/sdk/bus/telegram-daemon-contract.ts +15 -3
- package/src/sdk/bus/telegram-daemon-control.ts +145 -33
- package/src/sdk/bus/telegram-daemon.ts +853 -112
- package/src/sdk/bus/telegram-reference.ts +20 -2
- package/src/sdk/cli/session-cli.ts +15 -2
- package/src/sdk/host/control/dispatch.ts +1 -1
- package/src/sdk/host/control/operations.ts +1 -1
- package/src/sdk/host/host.ts +2 -6
- package/src/sdk/host/query/handlers.ts +70 -5
- package/src/sdk/index.ts +12 -0
- package/src/sdk/prompt-status.ts +97 -0
- package/src/sdk/protocol/operation-inventory.generated.json +47 -0
- package/src/sdk/protocol/operation-registry.ts +18 -4
- package/src/sdk/session-directory.ts +5 -1
- package/src/sdk/session.ts +2 -0
- package/src/sdk/startup-capability.ts +9 -1
- package/src/session/agent-session.ts +138 -5
- package/src/session/blob-store.ts +7 -2
- package/src/session/internal/managed-session-scope.ts +998 -133
- package/src/session/internal/managed-session-storage.ts +268 -203
- package/src/session/internal/native-publish-outcome.ts +266 -0
- package/src/session/session-manager.ts +406 -360
- package/src/session/session-storage.ts +151 -11
- package/src/skill-state/active-state.ts +10 -0
- package/src/task/executor.ts +13 -5
- package/src/task/gjc-command.ts +2 -2
- package/src/task/index.ts +121 -15
- package/src/task/receipt.ts +3 -0
- package/src/task/types.ts +34 -3
- package/src/tools/ask.ts +199 -88
- package/src/tools/ast-edit.ts +2 -2
- package/src/tools/bash-pty-selection.ts +2 -2
- package/src/tools/browser/screenshot-format.ts +6 -1
- package/src/tools/cron.ts +1 -1
- package/src/tools/image-gen.ts +178 -6
- package/src/tools/index.ts +4 -0
- package/src/tools/render-utils.ts +7 -1
- package/src/utils/edit-mode.ts +2 -2
- package/src/utils/prompt-suggestion.ts +265 -0
- package/src/utils/sixel.ts +3 -3
- package/src/web/insane/url-guard.ts +3 -1
- package/src/web/scrapers/docs-rs.ts +30 -5
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
type TelegramDaemonControlDeps,
|
|
38
38
|
TelegramDaemonController,
|
|
39
39
|
} from "./telegram-daemon-control";
|
|
40
|
-
import { withTelegramSetupLease } from "./telegram-setup";
|
|
40
|
+
import { type TelegramSetupPreflight, withTelegramSetupLease } from "./telegram-setup";
|
|
41
41
|
|
|
42
42
|
export { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
|
|
43
43
|
|
|
@@ -143,7 +143,7 @@ export interface TelegramDaemonFs {
|
|
|
143
143
|
open(path: string, flags: string, mode?: number): Promise<{ close(): Promise<void> }>;
|
|
144
144
|
readdir(path: string): Promise<string[]>;
|
|
145
145
|
chmod(path: string, mode: number): Promise<void>;
|
|
146
|
-
stat?(path: string): Promise<{ mtimeMs: number }>;
|
|
146
|
+
stat?(path: string): Promise<{ mtimeMs: number; size?: number; dev?: number; ino?: number; ctimeMs?: number }>;
|
|
147
147
|
readEndpointFile?(path: string): Promise<NotificationEndpointFile>;
|
|
148
148
|
exactUnlink?(path: string, identity: NotificationEndpointFileIdentity): Promise<NotificationExactUnlinkResult>;
|
|
149
149
|
}
|
|
@@ -231,6 +231,37 @@ const BOT_API_RETRY_ATTEMPTS = 3;
|
|
|
231
231
|
// Backoff after a failed getUpdates long-poll so a persistent outage does not
|
|
232
232
|
// busy-loop the daemon.
|
|
233
233
|
const POLL_BACKOFF_MS = 1_000;
|
|
234
|
+
const AUTOMATIC_RELOAD_COOLDOWN_MS = 10 * 60 * 1_000;
|
|
235
|
+
// Default freshness-poll window a cooldown contender waits for a sibling's
|
|
236
|
+
// replacement daemon to publish a fresh ready owner before reloading itself.
|
|
237
|
+
const RELOAD_FRESHNESS_WAIT_MS = 15_000;
|
|
238
|
+
// Cooperative-then-forced termination + replacement-readiness bounds the reload
|
|
239
|
+
// controller can spend while holding the reservation lock (mirrors the control
|
|
240
|
+
// plane's graceful/kill defaults plus a readiness wait).
|
|
241
|
+
const RELOAD_CONTROLLER_GRACEFUL_MS = 8_000;
|
|
242
|
+
const RELOAD_CONTROLLER_KILL_MS = 3_000;
|
|
243
|
+
const RELOAD_RESERVATION_HEADROOM_MS = 10_000;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* File-lock options whose acquisition budget covers the full reload-reservation
|
|
247
|
+
* critical section: the in-lock freshness poll plus the controller's
|
|
248
|
+
* graceful+kill+readiness sequence, with headroom. A contender must be able to
|
|
249
|
+
* wait out a legitimate slow reload and then attach, never fail startup.
|
|
250
|
+
*/
|
|
251
|
+
export function reloadReservationLockOptions(input: {
|
|
252
|
+
freshnessWaitMs: number;
|
|
253
|
+
readinessTimeoutMs: number;
|
|
254
|
+
retryDelayMs?: number;
|
|
255
|
+
}): { staleMs: number; retries: number; retryDelayMs: number } {
|
|
256
|
+
const retryDelayMs = Math.max(input.retryDelayMs ?? 100, 1);
|
|
257
|
+
const criticalMs =
|
|
258
|
+
Math.max(input.freshnessWaitMs, 0) +
|
|
259
|
+
RELOAD_CONTROLLER_GRACEFUL_MS +
|
|
260
|
+
RELOAD_CONTROLLER_KILL_MS +
|
|
261
|
+
Math.max(input.readinessTimeoutMs, 0) +
|
|
262
|
+
RELOAD_RESERVATION_HEADROOM_MS;
|
|
263
|
+
return { staleMs: 10_000, retries: Math.max(1, Math.ceil(criticalMs / retryDelayMs)), retryDelayMs };
|
|
264
|
+
}
|
|
234
265
|
// Telegram clears a chat action after ~5s; refresh slightly sooner to keep the
|
|
235
266
|
// typing indicator alive while the agent is busy.
|
|
236
267
|
const TYPING_REFRESH_INTERVAL_MS = 4_000;
|
|
@@ -507,9 +538,17 @@ type LegacyOwnershipLockMetadata = {
|
|
|
507
538
|
incarnation?: string;
|
|
508
539
|
startedAt: number;
|
|
509
540
|
};
|
|
541
|
+
type V010OwnershipLockMetadata = {
|
|
542
|
+
size: 0;
|
|
543
|
+
mtimeMs?: number;
|
|
544
|
+
dev?: number;
|
|
545
|
+
ino?: number;
|
|
546
|
+
ctimeMs?: number;
|
|
547
|
+
};
|
|
510
548
|
type OwnershipLockRead =
|
|
511
549
|
| { kind: "missing" }
|
|
512
550
|
| { kind: "malformed"; raw: string; mtimeMs?: number }
|
|
551
|
+
| { kind: "v010"; metadata: V010OwnershipLockMetadata }
|
|
513
552
|
| { kind: "legacy"; metadata: LegacyOwnershipLockMetadata }
|
|
514
553
|
| { kind: "valid"; metadata: OwnershipLockMetadata };
|
|
515
554
|
|
|
@@ -522,6 +561,20 @@ export async function readOwnershipLock(fsImpl: TelegramDaemonFs, file: string):
|
|
|
522
561
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" };
|
|
523
562
|
throw error;
|
|
524
563
|
}
|
|
564
|
+
if (raw.length === 0) {
|
|
565
|
+
const stat = await fsImpl.stat?.(file).catch(() => undefined);
|
|
566
|
+
if (stat?.size !== 0) return { kind: "malformed", raw, mtimeMs: stat?.mtimeMs };
|
|
567
|
+
return {
|
|
568
|
+
kind: "v010",
|
|
569
|
+
metadata: {
|
|
570
|
+
size: 0,
|
|
571
|
+
...(stat?.mtimeMs === undefined ? {} : { mtimeMs: stat.mtimeMs }),
|
|
572
|
+
...(stat?.dev === undefined ? {} : { dev: stat.dev }),
|
|
573
|
+
...(stat?.ino === undefined ? {} : { ino: stat.ino }),
|
|
574
|
+
...(stat?.ctimeMs === undefined ? {} : { ctimeMs: stat.ctimeMs }),
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
|
525
578
|
try {
|
|
526
579
|
const value = JSON.parse(raw) as Partial<OwnershipLockMetadata>;
|
|
527
580
|
const pid = value.pid;
|
|
@@ -564,6 +617,7 @@ export function liveOwnershipLockDecision(input: {
|
|
|
564
617
|
| { acquired: false; attached: false; blocked: true }
|
|
565
618
|
| { acquired: false; attached: false; provisional: true }
|
|
566
619
|
| undefined {
|
|
620
|
+
if (input.lock.kind === "v010") return { acquired: false, attached: false, blocked: true };
|
|
567
621
|
if (input.lock.kind !== "valid" && input.lock.kind !== "legacy") return undefined;
|
|
568
622
|
if (!input.pidAlive(input.lock.metadata.pid)) return undefined;
|
|
569
623
|
if (input.lock.kind === "legacy") {
|
|
@@ -585,8 +639,9 @@ function ownershipLockMatches(left: OwnershipLockRead, right: OwnershipLockRead)
|
|
|
585
639
|
if (left.kind === "missing") return true;
|
|
586
640
|
if (left.kind === "malformed" && right.kind === "malformed")
|
|
587
641
|
return left.raw === right.raw && left.mtimeMs !== undefined && left.mtimeMs === right.mtimeMs;
|
|
588
|
-
if ((left.kind
|
|
589
|
-
return
|
|
642
|
+
if ((left.kind === "legacy" && right.kind === "legacy") || (left.kind === "v010" && right.kind === "v010"))
|
|
643
|
+
return JSON.stringify(left.metadata) === JSON.stringify(right.metadata);
|
|
644
|
+
if (left.kind !== "valid" || right.kind !== "valid") return false;
|
|
590
645
|
return JSON.stringify(left.metadata) === JSON.stringify(right.metadata);
|
|
591
646
|
}
|
|
592
647
|
|
|
@@ -601,10 +656,22 @@ function ownershipLockMatchesState(lock: OwnershipLockRead, state: DaemonState |
|
|
|
601
656
|
);
|
|
602
657
|
}
|
|
603
658
|
|
|
604
|
-
function ownershipLockMatchesStoppedState(
|
|
659
|
+
function ownershipLockMatchesStoppedState(
|
|
660
|
+
lock: OwnershipLockRead,
|
|
661
|
+
state: unknown,
|
|
662
|
+
pidAlive: (pid: number) => boolean,
|
|
663
|
+
): boolean {
|
|
605
664
|
if (isExplicitlyStoppedDaemonState(state)) return ownershipLockMatchesState(lock, state);
|
|
606
|
-
if (!isLegacyStoppedDaemonState(state)
|
|
607
|
-
const legacyState = state as Pick<DaemonState, "pid" | "startedAt"> & { stoppedAt: number };
|
|
665
|
+
if (!isLegacyStoppedDaemonState(state)) return false;
|
|
666
|
+
const legacyState = state as Pick<DaemonState, "pid" | "startedAt" | "generation"> & { stoppedAt: number };
|
|
667
|
+
if (lock.kind === "v010")
|
|
668
|
+
return (
|
|
669
|
+
legacyState.generation === 3 &&
|
|
670
|
+
!pidAlive(legacyState.pid) &&
|
|
671
|
+
lock.metadata.mtimeMs !== undefined &&
|
|
672
|
+
lock.metadata.mtimeMs <= legacyState.stoppedAt
|
|
673
|
+
);
|
|
674
|
+
if (lock.kind !== "legacy") return false;
|
|
608
675
|
return (
|
|
609
676
|
lock.metadata.pid === legacyState.pid &&
|
|
610
677
|
lock.metadata.startedAt <= legacyState.startedAt &&
|
|
@@ -624,6 +691,37 @@ function ownershipLockMatchesMetadata(lock: OwnershipLockRead, metadata: Ownersh
|
|
|
624
691
|
return lock.kind === "valid" && JSON.stringify(lock.metadata) === JSON.stringify(metadata);
|
|
625
692
|
}
|
|
626
693
|
|
|
694
|
+
/**
|
|
695
|
+
* Accept an exact unlink only when it either completed or returned typed
|
|
696
|
+
* retained authority — a concrete detached quarantine plus a proven-absent
|
|
697
|
+
* canonical pathname. Anything else stays fail-closed.
|
|
698
|
+
*/
|
|
699
|
+
async function exactUnlinkAcceptedWithRetainedEvidence(
|
|
700
|
+
fsImpl: TelegramDaemonFs,
|
|
701
|
+
file: string,
|
|
702
|
+
identity: NotificationEndpointFileIdentity,
|
|
703
|
+
): Promise<boolean> {
|
|
704
|
+
const removed = await fsImpl.exactUnlink!(file, identity);
|
|
705
|
+
if (removed.ok) return true;
|
|
706
|
+
return (
|
|
707
|
+
removed.code === "cleanup_pending" &&
|
|
708
|
+
typeof removed.detachedPath === "string" &&
|
|
709
|
+
removed.detachedPath.length > 0 &&
|
|
710
|
+
(await fsImpl.readEndpointFile!(file).catch(() => undefined)) === undefined
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function unlinkOwnershipLockExactly(
|
|
715
|
+
fsImpl: TelegramDaemonFs,
|
|
716
|
+
file: string,
|
|
717
|
+
expected: OwnershipLockRead,
|
|
718
|
+
): Promise<boolean> {
|
|
719
|
+
if (expected.kind === "missing" || !fsImpl.readEndpointFile || !fsImpl.exactUnlink) return false;
|
|
720
|
+
const endpoint = await fsImpl.readEndpointFile(file).catch(() => undefined);
|
|
721
|
+
if (!endpoint || !ownershipLockMatches(expected, await readOwnershipLock(fsImpl, file))) return false;
|
|
722
|
+
return await exactUnlinkAcceptedWithRetainedEvidence(fsImpl, file, endpoint.identity);
|
|
723
|
+
}
|
|
724
|
+
|
|
627
725
|
/**
|
|
628
726
|
* Replace an acquisition's lease only after proving the old lease is unchanged.
|
|
629
727
|
* The transition fence serializes lifecycle writers; the exact read-back prevents
|
|
@@ -687,6 +785,7 @@ async function ownershipLockIsReclaimable(input: {
|
|
|
687
785
|
pidIncarnation: (pid: number) => string | undefined;
|
|
688
786
|
}): Promise<boolean> {
|
|
689
787
|
if (input.lock.kind === "missing") return true;
|
|
788
|
+
if (input.lock.kind === "v010") return false;
|
|
690
789
|
if (input.lock.kind === "malformed") {
|
|
691
790
|
if (!input.fs.stat) return false;
|
|
692
791
|
const stat = await input.fs.stat(input.path).catch(() => undefined);
|
|
@@ -870,6 +969,172 @@ function notificationRootForCwd(cwd: string): string {
|
|
|
870
969
|
return path.join(cwd, ".gjc", "state");
|
|
871
970
|
}
|
|
872
971
|
|
|
972
|
+
/**
|
|
973
|
+
* Leak artifact prefixes left when native exact-unlink cleanup is retained
|
|
974
|
+
* (#2956). Nothing previously reaped these; they accumulate in the notifications
|
|
975
|
+
* directory across ownership transitions.
|
|
976
|
+
*/
|
|
977
|
+
export const NOTIFICATION_LEAK_ARTIFACT_PREFIXES = [
|
|
978
|
+
".gjc-delete-daemon-transition-",
|
|
979
|
+
".gjc-exact-unlink-placeholder-",
|
|
980
|
+
".gjc-delete-notification-endpoint-",
|
|
981
|
+
] as const;
|
|
982
|
+
|
|
983
|
+
/** Grace window before a leak artifact is reaped (covers in-flight unlinks). */
|
|
984
|
+
export const NOTIFICATION_LEAK_ARTIFACT_GRACE_MS = 5 * 60_000;
|
|
985
|
+
|
|
986
|
+
/** True when a path is permanently gone (not a transient I/O blip). */
|
|
987
|
+
export function isPermanentMissingPathError(error: unknown): boolean {
|
|
988
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
989
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export function isNotificationLeakArtifactName(name: string): boolean {
|
|
993
|
+
return NOTIFICATION_LEAK_ARTIFACT_PREFIXES.some(prefix => name.startsWith(prefix));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
type NotificationRootsRegistry = {
|
|
997
|
+
version?: number;
|
|
998
|
+
roots?: string[];
|
|
999
|
+
managedRoots?: string[];
|
|
1000
|
+
sessions?: Record<string, string>;
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Drop permanently missing scan roots from the durable registry under the roots
|
|
1005
|
+
* lock. Session map entries that pointed only at those roots are removed too.
|
|
1006
|
+
*/
|
|
1007
|
+
export async function pruneMissingNotificationRoots(input: {
|
|
1008
|
+
settings: Settings;
|
|
1009
|
+
fs?: TelegramDaemonFs;
|
|
1010
|
+
/** When set, only these roots are considered; otherwise every registered root is probed. */
|
|
1011
|
+
candidates?: readonly string[];
|
|
1012
|
+
}): Promise<{ pruned: string[]; remaining: number }> {
|
|
1013
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
1014
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
1015
|
+
await ensureDir(fsImpl, paths.dir);
|
|
1016
|
+
const pruned: string[] = [];
|
|
1017
|
+
let remaining = 0;
|
|
1018
|
+
await withFileLock(
|
|
1019
|
+
paths.roots,
|
|
1020
|
+
async () => {
|
|
1021
|
+
const current = (await readJson<NotificationRootsRegistry>(fsImpl, paths.roots)) ?? {};
|
|
1022
|
+
const roots = [...(current.roots ?? [])];
|
|
1023
|
+
const managedRoots = new Set(current.managedRoots ?? []);
|
|
1024
|
+
const sessions = { ...(current.sessions ?? {}) };
|
|
1025
|
+
const candidateSet = input.candidates ? new Set(input.candidates) : undefined;
|
|
1026
|
+
const survivors: string[] = [];
|
|
1027
|
+
for (const root of roots) {
|
|
1028
|
+
if (candidateSet && !candidateSet.has(root)) {
|
|
1029
|
+
survivors.push(root);
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
const sdkDir = path.join(root, "sdk");
|
|
1033
|
+
try {
|
|
1034
|
+
await fsImpl.readdir(sdkDir);
|
|
1035
|
+
survivors.push(root);
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
if (!isPermanentMissingPathError(error)) {
|
|
1038
|
+
// Transient unreadable: keep for the next pass.
|
|
1039
|
+
survivors.push(root);
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
// Also accept a missing sdk dir when the root itself is still a directory
|
|
1043
|
+
// (empty registration) — still permanently useless for scans.
|
|
1044
|
+
try {
|
|
1045
|
+
await fsImpl.readdir(root);
|
|
1046
|
+
} catch (rootError) {
|
|
1047
|
+
if (!isPermanentMissingPathError(rootError)) {
|
|
1048
|
+
survivors.push(root);
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
pruned.push(root);
|
|
1053
|
+
managedRoots.delete(root);
|
|
1054
|
+
for (const [sessionId, mapped] of Object.entries(sessions)) {
|
|
1055
|
+
if (mapped === root) delete sessions[sessionId];
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
remaining = survivors.length;
|
|
1060
|
+
if (pruned.length === 0) return;
|
|
1061
|
+
await writeJsonAtomic(fsImpl, paths.roots, {
|
|
1062
|
+
version: 1,
|
|
1063
|
+
roots: Array.from(new Set(survivors)).sort(),
|
|
1064
|
+
managedRoots: Array.from(managedRoots).sort(),
|
|
1065
|
+
sessions,
|
|
1066
|
+
});
|
|
1067
|
+
},
|
|
1068
|
+
{ staleMs: 10_000 },
|
|
1069
|
+
);
|
|
1070
|
+
return { pruned, remaining };
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Reap retained exact-unlink / ownership-transition quarantine files older than
|
|
1075
|
+
* the grace window from the notifications directory.
|
|
1076
|
+
*/
|
|
1077
|
+
export async function reapStaleNotificationArtifacts(input: {
|
|
1078
|
+
settings: Settings;
|
|
1079
|
+
fs?: TelegramDaemonFs;
|
|
1080
|
+
now?: () => number;
|
|
1081
|
+
graceMs?: number;
|
|
1082
|
+
}): Promise<{ removed: string[]; skipped: number }> {
|
|
1083
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
1084
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
1085
|
+
await ensureDir(fsImpl, paths.dir);
|
|
1086
|
+
const now = input.now?.() ?? Date.now();
|
|
1087
|
+
const graceMs = input.graceMs ?? NOTIFICATION_LEAK_ARTIFACT_GRACE_MS;
|
|
1088
|
+
const removed: string[] = [];
|
|
1089
|
+
let skipped = 0;
|
|
1090
|
+
let names: string[];
|
|
1091
|
+
try {
|
|
1092
|
+
names = await fsImpl.readdir(paths.dir);
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
if (isPermanentMissingPathError(error)) return { removed, skipped };
|
|
1095
|
+
throw error;
|
|
1096
|
+
}
|
|
1097
|
+
for (const name of names) {
|
|
1098
|
+
if (!isNotificationLeakArtifactName(name)) continue;
|
|
1099
|
+
const file = path.join(paths.dir, name);
|
|
1100
|
+
try {
|
|
1101
|
+
const stat = fsImpl.stat ? await fsImpl.stat(file) : undefined;
|
|
1102
|
+
const age = stat ? now - stat.mtimeMs : Number.POSITIVE_INFINITY;
|
|
1103
|
+
if (Number.isFinite(age) && age < graceMs) {
|
|
1104
|
+
skipped += 1;
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
await fsImpl.unlink(file);
|
|
1108
|
+
removed.push(file);
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
if (isPermanentMissingPathError(error)) continue;
|
|
1111
|
+
// Best-effort: a busy file must not fail daemon ownership.
|
|
1112
|
+
skipped += 1;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return { removed, skipped };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Startup / ownership self-heal: prune dead roots and reap leak artifacts so
|
|
1120
|
+
* `gjc daemon reload` recovers a degraded install without manual surgery (#2956).
|
|
1121
|
+
*/
|
|
1122
|
+
export async function healTelegramDaemonNotificationState(input: {
|
|
1123
|
+
settings: Settings;
|
|
1124
|
+
fs?: TelegramDaemonFs;
|
|
1125
|
+
now?: () => number;
|
|
1126
|
+
graceMs?: number;
|
|
1127
|
+
}): Promise<{ prunedRoots: string[]; removedArtifacts: string[] }> {
|
|
1128
|
+
const prune = await pruneMissingNotificationRoots(input);
|
|
1129
|
+
const reap = await reapStaleNotificationArtifacts(input);
|
|
1130
|
+
if (prune.pruned.length > 0 || reap.removed.length > 0) {
|
|
1131
|
+
logger.warn(
|
|
1132
|
+
`notifications: self-heal pruned ${prune.pruned.length} dead root(s), reaped ${reap.removed.length} leak artifact(s)`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
return { prunedRoots: prune.pruned, removedArtifacts: reap.removed };
|
|
1136
|
+
}
|
|
1137
|
+
|
|
873
1138
|
function validBotToken(token: unknown): token is string {
|
|
874
1139
|
return typeof token === "string" && token.trim().length > 0;
|
|
875
1140
|
}
|
|
@@ -959,24 +1224,98 @@ export function hasSafeDaemonStateShape(state: unknown): state is DaemonState {
|
|
|
959
1224
|
);
|
|
960
1225
|
}
|
|
961
1226
|
|
|
962
|
-
|
|
1227
|
+
const V010_PARENT_STATE_KEYS = [
|
|
1228
|
+
"pid",
|
|
1229
|
+
"ownerId",
|
|
1230
|
+
"tokenFingerprint",
|
|
1231
|
+
"chatId",
|
|
1232
|
+
"startedAt",
|
|
1233
|
+
"heartbeatAt",
|
|
1234
|
+
"roots",
|
|
1235
|
+
"version",
|
|
1236
|
+
] as const;
|
|
1237
|
+
const V010_GENERATION_3_PARENT_STATE_KEYS = [...V010_PARENT_STATE_KEYS, "generation"] as const;
|
|
1238
|
+
|
|
1239
|
+
type ParentDaemonStateBase = Omit<
|
|
1240
|
+
DaemonState,
|
|
1241
|
+
"incarnation" | "acquisitionId" | "ownershipPhase" | "generation" | "launcherPid"
|
|
1242
|
+
> & {
|
|
963
1243
|
incarnation?: undefined;
|
|
964
1244
|
acquisitionId?: undefined;
|
|
965
1245
|
ownershipPhase?: undefined;
|
|
1246
|
+
generation?: unknown;
|
|
1247
|
+
launcherPid?: undefined;
|
|
1248
|
+
};
|
|
1249
|
+
type GenerationAbsentParentDaemonState = Omit<ParentDaemonStateBase, "generation"> & {
|
|
966
1250
|
generation?: undefined;
|
|
967
1251
|
};
|
|
1252
|
+
type Generation3ReleaseDaemonState = Omit<ParentDaemonStateBase, "generation"> & {
|
|
1253
|
+
generation: 3;
|
|
1254
|
+
};
|
|
1255
|
+
export type LegacyParentDaemonState = GenerationAbsentParentDaemonState | Generation3ReleaseDaemonState;
|
|
968
1256
|
|
|
969
1257
|
interface LegacyMigrationAttestation {
|
|
1258
|
+
stateDigest: string;
|
|
1259
|
+
confirmed?: true;
|
|
1260
|
+
lock?: V010OwnershipLockMetadata;
|
|
970
1261
|
pid: number;
|
|
971
1262
|
incarnation: string;
|
|
972
1263
|
heartbeatAt: number;
|
|
973
1264
|
observedAt: number;
|
|
1265
|
+
tokenFingerprint: string;
|
|
1266
|
+
chatId: string;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function historicalStateSerializer(state: LegacyParentDaemonState): string {
|
|
1270
|
+
const historicalState = {
|
|
1271
|
+
pid: state.pid,
|
|
1272
|
+
ownerId: state.ownerId,
|
|
1273
|
+
tokenFingerprint: state.tokenFingerprint,
|
|
1274
|
+
chatId: state.chatId,
|
|
1275
|
+
startedAt: state.startedAt,
|
|
1276
|
+
heartbeatAt: state.heartbeatAt,
|
|
1277
|
+
roots: state.roots,
|
|
1278
|
+
version: state.version,
|
|
1279
|
+
...(state.generation === undefined ? {} : { generation: 3 }),
|
|
1280
|
+
};
|
|
1281
|
+
return `${JSON.stringify(historicalState, null, 2)}\n`;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function legacyParentStateDigest(state: LegacyParentDaemonState): string {
|
|
1285
|
+
const { heartbeatAt: _heartbeatAt, ...immutableState } = state;
|
|
1286
|
+
return crypto
|
|
1287
|
+
.createHash("sha256")
|
|
1288
|
+
.update(historicalStateSerializer({ ...immutableState, heartbeatAt: 0 }))
|
|
1289
|
+
.digest("hex");
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function hasExactParentStateKeys(state: object, keys: readonly string[]): boolean {
|
|
1293
|
+
const actual = Object.keys(state);
|
|
1294
|
+
return actual.length === keys.length && actual.every((key, index) => key === keys[index]);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function isGenerationAbsentParentDaemonState(state: unknown): state is GenerationAbsentParentDaemonState {
|
|
1298
|
+
return hasParentDaemonStateShape(state) && hasExactParentStateKeys(state, V010_PARENT_STATE_KEYS);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function isGeneration3ReleaseDaemonState(state: unknown): state is Generation3ReleaseDaemonState {
|
|
1302
|
+
return (
|
|
1303
|
+
hasParentDaemonStateShape(state) &&
|
|
1304
|
+
state.generation === 3 &&
|
|
1305
|
+
hasExactParentStateKeys(state, V010_GENERATION_3_PARENT_STATE_KEYS)
|
|
1306
|
+
);
|
|
974
1307
|
}
|
|
975
1308
|
|
|
976
|
-
function
|
|
977
|
-
|
|
1309
|
+
function isParentDaemonState(state: unknown): state is LegacyParentDaemonState {
|
|
1310
|
+
return isGenerationAbsentParentDaemonState(state) || isGeneration3ReleaseDaemonState(state);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function hasParentDaemonStateShape(state: unknown): state is ParentDaemonStateBase {
|
|
1314
|
+
const candidate = state as Partial<ParentDaemonStateBase> | undefined;
|
|
978
1315
|
return Boolean(
|
|
979
1316
|
candidate &&
|
|
1317
|
+
typeof candidate === "object" &&
|
|
1318
|
+
!Array.isArray(candidate) &&
|
|
980
1319
|
Number.isSafeInteger(candidate.pid) &&
|
|
981
1320
|
(candidate.pid ?? 0) > 0 &&
|
|
982
1321
|
typeof candidate.ownerId === "string" &&
|
|
@@ -991,11 +1330,70 @@ function isLegacyParentDaemonState(state: unknown): state is LegacyParentDaemonS
|
|
|
991
1330
|
candidate.incarnation === undefined &&
|
|
992
1331
|
candidate.acquisitionId === undefined &&
|
|
993
1332
|
candidate.ownershipPhase === undefined &&
|
|
994
|
-
candidate.
|
|
995
|
-
candidate.stoppedAt === undefined
|
|
1333
|
+
candidate.launcherPid === undefined &&
|
|
1334
|
+
candidate.stoppedAt === undefined &&
|
|
1335
|
+
(candidate.generation === undefined || candidate.generation === 3),
|
|
996
1336
|
);
|
|
997
1337
|
}
|
|
998
1338
|
|
|
1339
|
+
const isLegacyParentDaemonState = isParentDaemonState;
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* A live v0.10-shaped record is authority only in its exact historical form.
|
|
1343
|
+
* Parsed JSON alone cannot establish that form: field order and serialization
|
|
1344
|
+
* bytes are part of the migration attestation.
|
|
1345
|
+
*/
|
|
1346
|
+
async function isLiveNoncanonicalParentState(input: {
|
|
1347
|
+
fs: TelegramDaemonFs;
|
|
1348
|
+
statePath: string;
|
|
1349
|
+
state: unknown;
|
|
1350
|
+
pidAlive: (pid: number) => boolean;
|
|
1351
|
+
pidIncarnation: (pid: number) => string | undefined;
|
|
1352
|
+
tokenFingerprint: string;
|
|
1353
|
+
chatId: string;
|
|
1354
|
+
}): Promise<boolean> {
|
|
1355
|
+
const candidate = input.state as Partial<ParentDaemonStateBase> | undefined;
|
|
1356
|
+
if (
|
|
1357
|
+
!candidate ||
|
|
1358
|
+
typeof candidate !== "object" ||
|
|
1359
|
+
Array.isArray(candidate) ||
|
|
1360
|
+
!validDaemonPid(candidate.pid) ||
|
|
1361
|
+
typeof candidate.ownerId !== "string" ||
|
|
1362
|
+
candidate.ownerId.length === 0 ||
|
|
1363
|
+
typeof candidate.tokenFingerprint !== "string" ||
|
|
1364
|
+
typeof candidate.chatId !== "string" ||
|
|
1365
|
+
!Number.isSafeInteger(candidate.startedAt) ||
|
|
1366
|
+
!Number.isSafeInteger(candidate.heartbeatAt) ||
|
|
1367
|
+
!Array.isArray(candidate.roots) ||
|
|
1368
|
+
!candidate.roots.every(root => typeof root === "string") ||
|
|
1369
|
+
candidate.version !== DAEMON_VERSION ||
|
|
1370
|
+
!input.pidAlive(candidate.pid)
|
|
1371
|
+
)
|
|
1372
|
+
return false;
|
|
1373
|
+
if (
|
|
1374
|
+
isStoppedDaemonState(candidate) ||
|
|
1375
|
+
(candidate.incarnation !== undefined &&
|
|
1376
|
+
(candidate.acquisitionId !== undefined || candidate.ownershipPhase !== undefined))
|
|
1377
|
+
)
|
|
1378
|
+
return false;
|
|
1379
|
+
if (
|
|
1380
|
+
candidate.incarnation !== undefined &&
|
|
1381
|
+
isProcessIncarnation(input.pidIncarnation(candidate.pid)) &&
|
|
1382
|
+
input.pidIncarnation(candidate.pid) !== candidate.incarnation &&
|
|
1383
|
+
!(candidate.tokenFingerprint === input.tokenFingerprint && candidate.chatId === input.chatId)
|
|
1384
|
+
)
|
|
1385
|
+
return false;
|
|
1386
|
+
if (!isParentDaemonState(candidate)) return true;
|
|
1387
|
+
try {
|
|
1388
|
+
return (await input.fs.readFile(input.statePath, "utf8")) !== historicalStateSerializer(candidate);
|
|
1389
|
+
} catch {
|
|
1390
|
+
return false;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
function legacyOwnershipLockMatchesHandoffState(lock: OwnershipLockRead, state: unknown): boolean {
|
|
1394
|
+
return lock.kind === "v010" && isGeneration3ReleaseDaemonState(state);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
999
1397
|
function legacyMigrationAttestationPath(statePath: string): string {
|
|
1000
1398
|
return `${statePath}.legacy-migration.json`;
|
|
1001
1399
|
}
|
|
@@ -1003,6 +1401,7 @@ function legacyMigrationAttestationPath(statePath: string): string {
|
|
|
1003
1401
|
async function legacyParentHandoffDecision(input: {
|
|
1004
1402
|
fs: TelegramDaemonFs;
|
|
1005
1403
|
statePath: string;
|
|
1404
|
+
lockPath: string;
|
|
1006
1405
|
state: LegacyParentDaemonState;
|
|
1007
1406
|
now: number;
|
|
1008
1407
|
pidAlive: (pid: number) => boolean;
|
|
@@ -1010,7 +1409,15 @@ async function legacyParentHandoffDecision(input: {
|
|
|
1010
1409
|
tokenFingerprint: string;
|
|
1011
1410
|
chatId: string;
|
|
1012
1411
|
}): Promise<
|
|
1013
|
-
|
|
1412
|
+
| {
|
|
1413
|
+
acquired: false;
|
|
1414
|
+
attached: false;
|
|
1415
|
+
provisional?: boolean;
|
|
1416
|
+
reloadRequired?: boolean;
|
|
1417
|
+
legacyReloadRequired?: boolean;
|
|
1418
|
+
blocked?: boolean;
|
|
1419
|
+
}
|
|
1420
|
+
| undefined
|
|
1014
1421
|
> {
|
|
1015
1422
|
const { state } = input;
|
|
1016
1423
|
if (!input.pidAlive(state.pid)) return undefined;
|
|
@@ -1018,33 +1425,133 @@ async function legacyParentHandoffDecision(input: {
|
|
|
1018
1425
|
return { acquired: false, attached: false, blocked: true };
|
|
1019
1426
|
const incarnation = input.pidIncarnation(state.pid);
|
|
1020
1427
|
if (!isProcessIncarnation(incarnation)) return { acquired: false, attached: false, blocked: true };
|
|
1021
|
-
|
|
1022
|
-
|
|
1428
|
+
let stateBytes: string;
|
|
1429
|
+
try {
|
|
1430
|
+
stateBytes = await input.fs.readFile(input.statePath, "utf8");
|
|
1431
|
+
} catch {
|
|
1432
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1433
|
+
}
|
|
1434
|
+
if (stateBytes !== historicalStateSerializer(state)) return { acquired: false, attached: false, blocked: true };
|
|
1435
|
+
const lock = await readOwnershipLock(input.fs, input.lockPath);
|
|
1436
|
+
const attestationLock =
|
|
1437
|
+
state.generation === undefined && lock.kind === "missing"
|
|
1438
|
+
? undefined
|
|
1439
|
+
: lock.kind === "v010" && legacyOwnershipLockMatchesHandoffState(lock, state)
|
|
1440
|
+
? lock.metadata
|
|
1441
|
+
: null;
|
|
1442
|
+
if (attestationLock === null) return { acquired: false, attached: false, blocked: true };
|
|
1443
|
+
const previous = await readJson<LegacyMigrationAttestation>(
|
|
1444
|
+
input.fs,
|
|
1445
|
+
legacyMigrationAttestationPath(input.statePath),
|
|
1446
|
+
);
|
|
1447
|
+
const stateDigest = legacyParentStateDigest(state);
|
|
1023
1448
|
const attested = Boolean(
|
|
1024
1449
|
previous &&
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
Number.isSafeInteger(previous.heartbeatAt) &&
|
|
1028
|
-
Number.isSafeInteger(previous.observedAt) &&
|
|
1450
|
+
previous.stateDigest === stateDigest &&
|
|
1451
|
+
JSON.stringify(previous.lock) === JSON.stringify(attestationLock) &&
|
|
1029
1452
|
previous.pid === state.pid &&
|
|
1030
1453
|
previous.incarnation === incarnation &&
|
|
1454
|
+
previous.tokenFingerprint === input.tokenFingerprint &&
|
|
1455
|
+
previous.chatId === input.chatId &&
|
|
1031
1456
|
state.heartbeatAt > previous.heartbeatAt &&
|
|
1032
1457
|
input.now - previous.observedAt <= HEARTBEAT_TTL_MS,
|
|
1033
1458
|
);
|
|
1034
|
-
await writeJsonAtomic(input.fs,
|
|
1459
|
+
await writeJsonAtomic(input.fs, legacyMigrationAttestationPath(input.statePath), {
|
|
1460
|
+
stateDigest,
|
|
1461
|
+
...(attested ? { confirmed: true as const } : {}),
|
|
1462
|
+
...(attestationLock === undefined ? {} : { lock: attestationLock }),
|
|
1035
1463
|
pid: state.pid,
|
|
1036
1464
|
incarnation,
|
|
1037
1465
|
heartbeatAt: state.heartbeatAt,
|
|
1038
1466
|
observedAt: input.now,
|
|
1467
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1468
|
+
chatId: input.chatId,
|
|
1039
1469
|
} satisfies LegacyMigrationAttestation);
|
|
1040
|
-
return
|
|
1041
|
-
|
|
1042
|
-
|
|
1470
|
+
if (!attested) return { acquired: false, attached: false, provisional: true };
|
|
1471
|
+
return { acquired: false, attached: false, reloadRequired: true, legacyReloadRequired: true };
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
export interface AttestedLegacyDaemonOwner {
|
|
1475
|
+
state: LegacyParentDaemonState;
|
|
1476
|
+
incarnation: string;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/** Revalidate the exact two-observation legacy proof immediately before signaling. */
|
|
1480
|
+
export async function readAttestedLegacyDaemonOwner(input: {
|
|
1481
|
+
settings: Settings;
|
|
1482
|
+
fs?: TelegramDaemonFs;
|
|
1483
|
+
now?: () => number;
|
|
1484
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
1485
|
+
tokenFingerprint: string;
|
|
1486
|
+
chatId: string;
|
|
1487
|
+
}): Promise<AttestedLegacyDaemonOwner | undefined> {
|
|
1488
|
+
const fsImpl = input.fs ?? nodeFs;
|
|
1489
|
+
const paths = daemonPaths(input.settings.getAgentDir());
|
|
1490
|
+
const state = await readJson<unknown>(fsImpl, paths.state);
|
|
1491
|
+
if (!isGeneration3ReleaseDaemonState(state)) return undefined;
|
|
1492
|
+
let stateBytes: string;
|
|
1493
|
+
try {
|
|
1494
|
+
stateBytes = await fsImpl.readFile(paths.state, "utf8");
|
|
1495
|
+
} catch {
|
|
1496
|
+
return undefined;
|
|
1497
|
+
}
|
|
1498
|
+
if (stateBytes !== historicalStateSerializer(state)) return undefined;
|
|
1499
|
+
const lock = await readOwnershipLock(fsImpl, paths.lock);
|
|
1500
|
+
if (lock.kind !== "v010") return undefined;
|
|
1501
|
+
const attestation = await readJson<LegacyMigrationAttestation>(fsImpl, legacyMigrationAttestationPath(paths.state));
|
|
1502
|
+
const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(state.pid);
|
|
1503
|
+
const now = (input.now ?? Date.now)();
|
|
1504
|
+
if (
|
|
1505
|
+
attestation?.confirmed !== true ||
|
|
1506
|
+
!isProcessIncarnation(incarnation) ||
|
|
1507
|
+
attestation.stateDigest !== legacyParentStateDigest(state) ||
|
|
1508
|
+
JSON.stringify(attestation.lock) !== JSON.stringify(lock.metadata) ||
|
|
1509
|
+
attestation.pid !== state.pid ||
|
|
1510
|
+
attestation.incarnation !== incarnation ||
|
|
1511
|
+
attestation.tokenFingerprint !== input.tokenFingerprint ||
|
|
1512
|
+
attestation.chatId !== input.chatId ||
|
|
1513
|
+
attestation.heartbeatAt > state.heartbeatAt ||
|
|
1514
|
+
now < attestation.observedAt ||
|
|
1515
|
+
now - attestation.observedAt > HEARTBEAT_TTL_MS
|
|
1516
|
+
)
|
|
1517
|
+
return undefined;
|
|
1518
|
+
return { state, incarnation };
|
|
1043
1519
|
}
|
|
1044
1520
|
|
|
1045
1521
|
function isRecognizedLegacyGeneration(generation: number | undefined): boolean {
|
|
1046
|
-
return
|
|
1047
|
-
|
|
1522
|
+
return generation === undefined || generation === 3;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
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
|
+
*/
|
|
1531
|
+
function hasFullModernOwnerProvenance(
|
|
1532
|
+
state: DaemonState | undefined,
|
|
1533
|
+
pidIncarnation?: (pid: number) => string | undefined,
|
|
1534
|
+
): boolean {
|
|
1535
|
+
return Boolean(
|
|
1536
|
+
hasSafeDaemonStateShape(state) &&
|
|
1537
|
+
state.generation !== 3 &&
|
|
1538
|
+
state.ownershipPhase === "ready" &&
|
|
1539
|
+
typeof state.acquisitionId === "string" &&
|
|
1540
|
+
state.acquisitionId.length > 0 &&
|
|
1541
|
+
ownerProvenanceMatches(state, pidIncarnation),
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
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)),
|
|
1048
1555
|
);
|
|
1049
1556
|
}
|
|
1050
1557
|
|
|
@@ -1086,18 +1593,23 @@ export function isPhysicalMatchingOwner(input: {
|
|
|
1086
1593
|
pidAlive: (pid: number) => boolean;
|
|
1087
1594
|
pidIncarnation?: (pid: number) => string | undefined;
|
|
1088
1595
|
}): boolean {
|
|
1089
|
-
const
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1596
|
+
const persistedState: unknown = input.state;
|
|
1597
|
+
const legacyParentState = isLegacyParentDaemonState(persistedState) ? persistedState : undefined;
|
|
1598
|
+
const modernState = hasSafeDaemonStateShape(persistedState) ? persistedState : undefined;
|
|
1599
|
+
const state = modernState ?? legacyParentState;
|
|
1600
|
+
if (
|
|
1601
|
+
!state ||
|
|
1602
|
+
state.stoppedAt !== undefined ||
|
|
1603
|
+
!ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) ||
|
|
1604
|
+
!input.pidAlive(state.pid)
|
|
1605
|
+
)
|
|
1606
|
+
return false;
|
|
1607
|
+
return legacyParentState
|
|
1608
|
+
? isProcessIncarnation((input.pidIncarnation ?? defaultPidIncarnation)(legacyParentState.pid))
|
|
1609
|
+
: modernState !== undefined && ownerProvenanceMatches(modernState, input.pidIncarnation);
|
|
1098
1610
|
}
|
|
1099
1611
|
|
|
1100
|
-
/** True only for a
|
|
1612
|
+
/** True only for a fully-provenanced modern owner outside the generation-3 parent schema. */
|
|
1101
1613
|
export function isSignalableMatchingOwner(input: {
|
|
1102
1614
|
state: DaemonState | undefined;
|
|
1103
1615
|
tokenFingerprint: string;
|
|
@@ -1106,14 +1618,7 @@ export function isSignalableMatchingOwner(input: {
|
|
|
1106
1618
|
pidIncarnation?: (pid: number) => string | undefined;
|
|
1107
1619
|
}): boolean {
|
|
1108
1620
|
const { state } = input;
|
|
1109
|
-
return Boolean(
|
|
1110
|
-
isPhysicalMatchingOwner(input) &&
|
|
1111
|
-
(isRecognizedLegacyGeneration(state?.generation) ||
|
|
1112
|
-
(state?.generation === DAEMON_GENERATION &&
|
|
1113
|
-
state.ownershipPhase === "ready" &&
|
|
1114
|
-
typeof state.acquisitionId === "string" &&
|
|
1115
|
-
state.acquisitionId.length > 0)),
|
|
1116
|
-
);
|
|
1621
|
+
return Boolean(isPhysicalMatchingOwner(input) && hasFullModernOwnerProvenance(state, input.pidIncarnation));
|
|
1117
1622
|
}
|
|
1118
1623
|
|
|
1119
1624
|
export function isFreshLiveOwner(input: {
|
|
@@ -1180,6 +1685,7 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1180
1685
|
provisional?: boolean;
|
|
1181
1686
|
reason?: "identity_mismatch";
|
|
1182
1687
|
reloadRequired?: boolean;
|
|
1688
|
+
legacyReloadRequired?: boolean;
|
|
1183
1689
|
}> {
|
|
1184
1690
|
const fsImpl = input.fs ?? nodeFs;
|
|
1185
1691
|
const now = input.now ?? Date.now;
|
|
@@ -1216,6 +1722,7 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1216
1722
|
}
|
|
1217
1723
|
if (!state || state.stoppedAt !== undefined || !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
|
|
1218
1724
|
return undefined;
|
|
1725
|
+
if (state.generation === 3) return { acquired: false, attached: false, blocked: true };
|
|
1219
1726
|
// Unavailable provenance is ambiguous and must remain fail-closed. A
|
|
1220
1727
|
// different authoritative incarnation proves PID reuse, so allow only the
|
|
1221
1728
|
// transition-locked path below to reclaim the stale owner artifacts.
|
|
@@ -1238,19 +1745,18 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1238
1745
|
)
|
|
1239
1746
|
return { acquired: false, attached: true };
|
|
1240
1747
|
// A physical owner that cannot prove current compatibility is never safe to
|
|
1241
|
-
// attach.
|
|
1242
|
-
//
|
|
1748
|
+
// attach. A reload handoff additionally requires a fresh heartbeat: a stale
|
|
1749
|
+
// record must remain blocked rather than authorizing a signal to its PID.
|
|
1243
1750
|
if (
|
|
1244
|
-
|
|
1245
|
-
!isFreshLiveOwner({
|
|
1751
|
+
isFreshLiveOwner({
|
|
1246
1752
|
state,
|
|
1247
1753
|
now: now(),
|
|
1248
1754
|
tokenFingerprint: input.tokenFingerprint,
|
|
1249
1755
|
chatId: input.chatId,
|
|
1250
1756
|
pidAlive,
|
|
1251
1757
|
pidIncarnation,
|
|
1252
|
-
})
|
|
1253
|
-
|
|
1758
|
+
}) &&
|
|
1759
|
+
(state.version !== DAEMON_VERSION || isFullModernPredecessor(state, pidIncarnation))
|
|
1254
1760
|
)
|
|
1255
1761
|
return { acquired: false, attached: false, reloadRequired: true };
|
|
1256
1762
|
return { acquired: false, attached: false, provisional: true };
|
|
@@ -1278,12 +1784,48 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1278
1784
|
? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
|
|
1279
1785
|
: { acquired: false, attached: false, blocked: true };
|
|
1280
1786
|
}
|
|
1787
|
+
if (
|
|
1788
|
+
await isLiveNoncanonicalParentState({
|
|
1789
|
+
fs: fsImpl,
|
|
1790
|
+
statePath: paths.state,
|
|
1791
|
+
state: existing,
|
|
1792
|
+
pidAlive,
|
|
1793
|
+
pidIncarnation,
|
|
1794
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1795
|
+
chatId: input.chatId,
|
|
1796
|
+
})
|
|
1797
|
+
)
|
|
1798
|
+
return { acquired: false, attached: false, blocked: true };
|
|
1281
1799
|
|
|
1282
1800
|
const transition = await acquireTransitionLock({ fs: fsImpl, path: paths.steal, pidAlive, pidIncarnation });
|
|
1283
1801
|
if (!transition) return { acquired: false, attached: false, provisional: true };
|
|
1284
1802
|
try {
|
|
1285
1803
|
const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
|
|
1286
1804
|
const recheckedLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
1805
|
+
const recheckedForeignOwner = classifyForeignLiveOwner({
|
|
1806
|
+
state: rechecked,
|
|
1807
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1808
|
+
chatId: input.chatId,
|
|
1809
|
+
pidAlive,
|
|
1810
|
+
pidIncarnation,
|
|
1811
|
+
});
|
|
1812
|
+
if (recheckedForeignOwner) {
|
|
1813
|
+
return recheckedForeignOwner === "identity_mismatch"
|
|
1814
|
+
? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
|
|
1815
|
+
: { acquired: false, attached: false, blocked: true };
|
|
1816
|
+
}
|
|
1817
|
+
if (
|
|
1818
|
+
await isLiveNoncanonicalParentState({
|
|
1819
|
+
fs: fsImpl,
|
|
1820
|
+
statePath: paths.state,
|
|
1821
|
+
state: rechecked,
|
|
1822
|
+
pidAlive,
|
|
1823
|
+
pidIncarnation,
|
|
1824
|
+
tokenFingerprint: input.tokenFingerprint,
|
|
1825
|
+
chatId: input.chatId,
|
|
1826
|
+
})
|
|
1827
|
+
)
|
|
1828
|
+
return { acquired: false, attached: false, blocked: true };
|
|
1287
1829
|
const recheckedDecision = isLegacyParentDaemonState(rechecked) ? undefined : attachDecision(rechecked);
|
|
1288
1830
|
if (
|
|
1289
1831
|
recheckedDecision &&
|
|
@@ -1297,13 +1839,19 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1297
1839
|
// A newer initializer may have replaced the pathname while that tombstone
|
|
1298
1840
|
// remained, and must receive the same liveness/freshness protection as any
|
|
1299
1841
|
// other live reservation.
|
|
1300
|
-
const stoppedLockMatches = ownershipLockMatchesStoppedState(recheckedLock, rechecked);
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1842
|
+
const stoppedLockMatches = ownershipLockMatchesStoppedState(recheckedLock, rechecked, pidAlive);
|
|
1843
|
+
// A v0.10 parent owns a legacy { pid, startedAt } lock. An exact
|
|
1844
|
+
// state/lock pair is authority only to attest or retry handoff of that
|
|
1845
|
+
// owner; it is never attached or unlinked here.
|
|
1846
|
+
const legacyHandoffLockMatches = legacyOwnershipLockMatchesHandoffState(recheckedLock, rechecked);
|
|
1847
|
+
const lockDecision =
|
|
1848
|
+
stoppedLockMatches || legacyHandoffLockMatches
|
|
1849
|
+
? undefined
|
|
1850
|
+
: liveOwnershipLockDecision({ lock: recheckedLock, pidAlive, pidIncarnation });
|
|
1304
1851
|
if (lockDecision) return lockDecision;
|
|
1305
1852
|
if (
|
|
1306
1853
|
!stoppedLockMatches &&
|
|
1854
|
+
!legacyHandoffLockMatches &&
|
|
1307
1855
|
!(await ownershipLockIsReclaimable({
|
|
1308
1856
|
fs: fsImpl,
|
|
1309
1857
|
path: paths.lock,
|
|
@@ -1318,6 +1866,7 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1318
1866
|
const legacyDecision = await legacyParentHandoffDecision({
|
|
1319
1867
|
fs: fsImpl,
|
|
1320
1868
|
statePath: paths.state,
|
|
1869
|
+
lockPath: paths.lock,
|
|
1321
1870
|
state: rechecked,
|
|
1322
1871
|
now: now(),
|
|
1323
1872
|
pidAlive,
|
|
@@ -1329,18 +1878,6 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1329
1878
|
} else if (recheckedDecision) {
|
|
1330
1879
|
return recheckedDecision;
|
|
1331
1880
|
}
|
|
1332
|
-
const recheckedForeignOwner = classifyForeignLiveOwner({
|
|
1333
|
-
state: rechecked,
|
|
1334
|
-
tokenFingerprint: input.tokenFingerprint,
|
|
1335
|
-
chatId: input.chatId,
|
|
1336
|
-
pidAlive,
|
|
1337
|
-
pidIncarnation,
|
|
1338
|
-
});
|
|
1339
|
-
if (recheckedForeignOwner) {
|
|
1340
|
-
return recheckedForeignOwner === "identity_mismatch"
|
|
1341
|
-
? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
|
|
1342
|
-
: { acquired: false, attached: false, blocked: true };
|
|
1343
|
-
}
|
|
1344
1881
|
if (
|
|
1345
1882
|
hasSafeDaemonStateShape(rechecked) &&
|
|
1346
1883
|
rechecked.stoppedAt === undefined &&
|
|
@@ -1354,7 +1891,8 @@ export async function acquireDaemonOwnership(input: {
|
|
|
1354
1891
|
if (currentLock.kind !== "missing") {
|
|
1355
1892
|
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
|
|
1356
1893
|
return { acquired: false, attached: false, provisional: true };
|
|
1357
|
-
await fsImpl
|
|
1894
|
+
if (!(await unlinkOwnershipLockExactly(fsImpl, paths.lock, currentLock)))
|
|
1895
|
+
return { acquired: false, attached: false, provisional: true };
|
|
1358
1896
|
}
|
|
1359
1897
|
const ownershipLock: OwnershipLockMetadata = {
|
|
1360
1898
|
pid,
|
|
@@ -1590,14 +2128,15 @@ export async function retireProvisionalDaemonOwnership(input: {
|
|
|
1590
2128
|
)
|
|
1591
2129
|
return false;
|
|
1592
2130
|
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
|
|
2131
|
+
const lock = await readOwnershipLock(fsImpl, paths.lock);
|
|
2132
|
+
if (!ownershipLockMatchesState(lock, state)) return false;
|
|
1593
2133
|
await writeJsonAtomic(fsImpl, paths.state, {
|
|
1594
2134
|
...state,
|
|
1595
2135
|
ownershipPhase: "retired",
|
|
1596
2136
|
stoppedAt: (input.now ?? Date.now)(),
|
|
1597
2137
|
});
|
|
1598
|
-
if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
|
|
1599
|
-
|
|
1600
|
-
return true;
|
|
2138
|
+
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
|
|
2139
|
+
return await unlinkOwnershipLockExactly(fsImpl, paths.lock, lock);
|
|
1601
2140
|
} finally {
|
|
1602
2141
|
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1603
2142
|
}
|
|
@@ -1754,6 +2293,8 @@ export async function confirmTelegramDaemonSpawn(input: {
|
|
|
1754
2293
|
sleep?: (ms: number) => Promise<void>;
|
|
1755
2294
|
waitStepMs?: number;
|
|
1756
2295
|
timeoutMs?: number;
|
|
2296
|
+
/** Retain an unproven no-PID child lease during a successor handoff. */
|
|
2297
|
+
preserveOnUnprovenChildExit?: boolean;
|
|
1757
2298
|
}): Promise<boolean> {
|
|
1758
2299
|
if (input.spawned.result !== "owner_spawned") return true;
|
|
1759
2300
|
const childPid = input.spawned.acquisition.pid;
|
|
@@ -1775,6 +2316,14 @@ export async function confirmTelegramDaemonSpawn(input: {
|
|
|
1775
2316
|
timeoutMs: input.timeoutMs,
|
|
1776
2317
|
});
|
|
1777
2318
|
if (ready) return true;
|
|
2319
|
+
// An identified live child remains fenced. A no-PID launch retains its lease
|
|
2320
|
+
// only when a successor handoff explicitly requests that protection; ordinary
|
|
2321
|
+
// confirmation keeps the historical cleanup behavior.
|
|
2322
|
+
if (
|
|
2323
|
+
(hasExactChildPid && (input.pidAlive ?? defaultPidAlive)(childPid as number)) ||
|
|
2324
|
+
(!hasExactChildPid && input.preserveOnUnprovenChildExit)
|
|
2325
|
+
)
|
|
2326
|
+
return false;
|
|
1778
2327
|
const launcherPid = input.spawned.acquisition.launcherPid ?? input.pid;
|
|
1779
2328
|
const retired = await retireProvisionalDaemonOwnership({
|
|
1780
2329
|
settings: input.settings,
|
|
@@ -1856,9 +2405,11 @@ export async function releaseDaemonOwnership(input: {
|
|
|
1856
2405
|
)
|
|
1857
2406
|
return;
|
|
1858
2407
|
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return;
|
|
2408
|
+
const lock = await readOwnershipLock(fsImpl, paths.lock);
|
|
2409
|
+
if (!ownershipLockMatchesState(lock, state)) return;
|
|
1859
2410
|
await writeJsonAtomic(fsImpl, paths.state, { ...state, stoppedAt: (input.now ?? Date.now)() });
|
|
1860
2411
|
if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
|
|
1861
|
-
await fsImpl
|
|
2412
|
+
await unlinkOwnershipLockExactly(fsImpl, paths.lock, lock);
|
|
1862
2413
|
} finally {
|
|
1863
2414
|
await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
|
|
1864
2415
|
}
|
|
@@ -1881,6 +2432,57 @@ export async function readDaemonRoots(
|
|
|
1881
2432
|
return roots?.roots ?? [];
|
|
1882
2433
|
}
|
|
1883
2434
|
|
|
2435
|
+
/** Injectable readers for {@link resolveTelegramSetupPreflight}, defaulting to the real OS/state probes. */
|
|
2436
|
+
export interface ResolveTelegramSetupPreflightDeps {
|
|
2437
|
+
readDaemonState?: (settings: Settings) => Promise<DaemonState | undefined>;
|
|
2438
|
+
pidAlive?: (pid: number) => boolean;
|
|
2439
|
+
pidIncarnation?: (pid: number) => string | undefined;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
/**
|
|
2443
|
+
* Build the Telegram setup preflight from persisted daemon state. The daemon is
|
|
2444
|
+
* reported live ONLY when its PID is alive AND its current process incarnation
|
|
2445
|
+
* still matches the persisted incarnation. Skipping the incarnation check makes
|
|
2446
|
+
* a stale state file whose PID has been recycled by an unrelated process
|
|
2447
|
+
* masquerade as a live owner, which wrongly blocks discovery pairing. Both the
|
|
2448
|
+
* `notify setup` CLI and the /settings Notifications tab share this resolver so
|
|
2449
|
+
* pairing behaves identically on both surfaces.
|
|
2450
|
+
*/
|
|
2451
|
+
export async function resolveTelegramSetupPreflight(
|
|
2452
|
+
settings: Settings,
|
|
2453
|
+
deps: ResolveTelegramSetupPreflightDeps = {},
|
|
2454
|
+
): Promise<TelegramSetupPreflight> {
|
|
2455
|
+
const storedChatId = getNotificationConfig(settings).chatId;
|
|
2456
|
+
const pidAlive = deps.pidAlive ?? defaultPidAlive;
|
|
2457
|
+
const pidIncarnation = deps.pidIncarnation ?? defaultPidIncarnation;
|
|
2458
|
+
try {
|
|
2459
|
+
const state = await (deps.readDaemonState ?? readDaemonState)(settings);
|
|
2460
|
+
if (!state) return { storedChatId };
|
|
2461
|
+
const validPid = Number.isSafeInteger(state.pid) && state.pid > 0;
|
|
2462
|
+
if (!validPid || !pidAlive(state.pid)) return { storedChatId };
|
|
2463
|
+
const persistedIncarnation = state.incarnation;
|
|
2464
|
+
const currentIncarnation = pidIncarnation(state.pid);
|
|
2465
|
+
if (
|
|
2466
|
+
!isProcessIncarnation(persistedIncarnation) ||
|
|
2467
|
+
!isProcessIncarnation(currentIncarnation) ||
|
|
2468
|
+
persistedIncarnation !== currentIncarnation
|
|
2469
|
+
)
|
|
2470
|
+
return { storedChatId };
|
|
2471
|
+
return {
|
|
2472
|
+
storedChatId,
|
|
2473
|
+
daemon: {
|
|
2474
|
+
live: true,
|
|
2475
|
+
tokenFingerprint: typeof state.tokenFingerprint === "string" ? state.tokenFingerprint : undefined,
|
|
2476
|
+
chatId: typeof state.chatId === "string" ? state.chatId : undefined,
|
|
2477
|
+
},
|
|
2478
|
+
};
|
|
2479
|
+
} catch {
|
|
2480
|
+
// A state read failure is not proof of a live daemon; proceed normally. The
|
|
2481
|
+
// daemon's own 409 handling remains the backstop against poller contention.
|
|
2482
|
+
return { storedChatId };
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
|
|
1884
2486
|
function defaultPidAlive(pid: number): boolean {
|
|
1885
2487
|
try {
|
|
1886
2488
|
process.kill(pid, 0);
|
|
@@ -1945,7 +2547,13 @@ export interface TelegramSpawnAcquisition {
|
|
|
1945
2547
|
|
|
1946
2548
|
export type TelegramSpawnOwnerResult =
|
|
1947
2549
|
| { result: "owner_spawned"; acquisition: TelegramSpawnAcquisition; runtime: DaemonRuntimeInfo; warnings: string[] }
|
|
1948
|
-
| {
|
|
2550
|
+
| {
|
|
2551
|
+
result: "attached";
|
|
2552
|
+
runtime: DaemonRuntimeInfo;
|
|
2553
|
+
warnings: string[];
|
|
2554
|
+
reloadRequired?: boolean;
|
|
2555
|
+
legacyReloadRequired?: boolean;
|
|
2556
|
+
}
|
|
1949
2557
|
| { result: "blocked"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean };
|
|
1950
2558
|
|
|
1951
2559
|
/**
|
|
@@ -2028,6 +2636,7 @@ export async function spawnTelegramDaemonOwner(
|
|
|
2028
2636
|
runtime: buildTelegramDaemonSpawnArgs({ execPath, ownerId: "", agentDir }).runtime,
|
|
2029
2637
|
warnings: [],
|
|
2030
2638
|
reloadRequired: ownership.reloadRequired,
|
|
2639
|
+
legacyReloadRequired: ownership.legacyReloadRequired,
|
|
2031
2640
|
};
|
|
2032
2641
|
}
|
|
2033
2642
|
const launcherPid = deps.pid ?? process.pid;
|
|
@@ -2180,7 +2789,7 @@ export async function reclaimDeadDaemonOwner(input: {
|
|
|
2180
2789
|
if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
|
|
2181
2790
|
return { recovered: false, reason: "transition-contended" };
|
|
2182
2791
|
for (const endpoint of endpoints)
|
|
2183
|
-
if (!(await fsImpl
|
|
2792
|
+
if (!(await exactUnlinkAcceptedWithRetainedEvidence(fsImpl, endpoint.file, endpoint.identity)))
|
|
2184
2793
|
return { recovered: false, reason: "endpoint-changed" };
|
|
2185
2794
|
if (currentLock.kind === "missing") return { recovered: true, reason: "cleared" };
|
|
2186
2795
|
const exactLock = await fsImpl.readEndpointFile!(paths.lock).catch(() => undefined);
|
|
@@ -2191,7 +2800,7 @@ export async function reclaimDeadDaemonOwner(input: {
|
|
|
2191
2800
|
!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
|
|
2192
2801
|
)
|
|
2193
2802
|
return { recovered: false, reason: "lock-changed" };
|
|
2194
|
-
return (await fsImpl
|
|
2803
|
+
return (await exactUnlinkAcceptedWithRetainedEvidence(fsImpl, paths.lock, exactLock.identity))
|
|
2195
2804
|
? { recovered: true, reason: "cleared" }
|
|
2196
2805
|
: { recovered: false, reason: "lock-changed" };
|
|
2197
2806
|
} finally {
|
|
@@ -2211,6 +2820,13 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
2211
2820
|
if (!isTelegramConfigured(cfg)) return "disabled";
|
|
2212
2821
|
const root = notificationRootForCwd(input.cwd);
|
|
2213
2822
|
const fp = tokenFingerprint(cfg.botToken);
|
|
2823
|
+
// A live v0.10 parent has no stable process authority on Windows. Never turn an
|
|
2824
|
+
// unproven cooperative handoff into destructive cleanup or a replacement spawn.
|
|
2825
|
+
if ((deps.platform ?? process.platform) === "win32") {
|
|
2826
|
+
const parentState: unknown = await readDaemonState(input.settings, deps.fs);
|
|
2827
|
+
if (isParentDaemonState(parentState) && (deps.pidAlive ?? defaultPidAlive)(parentState.pid))
|
|
2828
|
+
return "blocked_identity";
|
|
2829
|
+
}
|
|
2214
2830
|
// Windows can retain dead launcher metadata without an ownership lock; reclaim
|
|
2215
2831
|
// its dead discovery records before the replacement can publish a new owner.
|
|
2216
2832
|
if ((deps.platform ?? process.platform) === "win32" && !deps.fs) {
|
|
@@ -2259,7 +2875,11 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
2259
2875
|
waitStepMs: deps.waitStepMs,
|
|
2260
2876
|
timeoutMs: deps.readinessTimeoutMs,
|
|
2261
2877
|
});
|
|
2262
|
-
|
|
2878
|
+
// A legacy parent cannot satisfy the current ready-state shape, but the
|
|
2879
|
+
// bounded wait gives its heartbeat a second observation window. Retry the
|
|
2880
|
+
// acquisition once so legacyParentHandoffDecision can consume that
|
|
2881
|
+
// attestation before startup treats the owner as blocked.
|
|
2882
|
+
if (ready || isLegacyParentDaemonState(provisional)) {
|
|
2263
2883
|
spawned = await withTelegramSetupLease(
|
|
2264
2884
|
cfg.botToken,
|
|
2265
2885
|
async () =>
|
|
@@ -2300,9 +2920,78 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
2300
2920
|
if (spawned.result === "attached" && spawned.reloadRequired) {
|
|
2301
2921
|
const previous = await readNotificationRootRegistration({ ...input, fs: deps.fs });
|
|
2302
2922
|
await registerNotificationRoot({ ...input, fs: deps.fs });
|
|
2303
|
-
const
|
|
2304
|
-
const
|
|
2305
|
-
|
|
2923
|
+
const fsImpl = deps.fs ?? nodeFs;
|
|
2924
|
+
const now = deps.now ?? Date.now;
|
|
2925
|
+
const pidAlive = deps.pidAlive ?? defaultPidAlive;
|
|
2926
|
+
const pidIncarnation = deps.pidIncarnation ?? defaultPidIncarnation;
|
|
2927
|
+
const reloadAttemptPath = path.join(
|
|
2928
|
+
daemonPaths(input.settings.getAgentDir()).dir,
|
|
2929
|
+
"telegram-daemon.reload-attempt.json",
|
|
2930
|
+
);
|
|
2931
|
+
await ensureDir(fsImpl, daemonPaths(input.settings.getAgentDir()).dir);
|
|
2932
|
+
const reloadWaitStepMs = Math.max(deps.waitStepMs ?? 100, 1);
|
|
2933
|
+
const reloadFreshnessWaitMs = Math.max(deps.readinessTimeoutMs ?? RELOAD_FRESHNESS_WAIT_MS, reloadWaitStepMs);
|
|
2934
|
+
const reloadReadinessTimeoutMs = deps.readinessTimeoutMs ?? RELOAD_FRESHNESS_WAIT_MS;
|
|
2935
|
+
const reloadResult = await withFileLock(
|
|
2936
|
+
reloadAttemptPath,
|
|
2937
|
+
async () => {
|
|
2938
|
+
const currentState = await readDaemonState(input.settings, fsImpl);
|
|
2939
|
+
const previousAttempt = await readJson<{
|
|
2940
|
+
lastReloadAt?: number;
|
|
2941
|
+
ownerId?: string;
|
|
2942
|
+
targetGeneration?: number;
|
|
2943
|
+
}>(fsImpl, reloadAttemptPath).catch(() => undefined);
|
|
2944
|
+
const reloadNow = now();
|
|
2945
|
+
const cooldownApplies =
|
|
2946
|
+
previousAttempt?.targetGeneration === DAEMON_GENERATION &&
|
|
2947
|
+
typeof previousAttempt.lastReloadAt === "number" &&
|
|
2948
|
+
Number.isFinite(previousAttempt.lastReloadAt) &&
|
|
2949
|
+
reloadNow - previousAttempt.lastReloadAt < AUTOMATIC_RELOAD_COOLDOWN_MS;
|
|
2950
|
+
if (cooldownApplies) {
|
|
2951
|
+
// A sibling ensure may have just finished its reload. Its replacement
|
|
2952
|
+
// daemon heartbeats only after startup, so wait briefly for the fresh
|
|
2953
|
+
// ready owner instead of issuing a duplicate reload in the gap.
|
|
2954
|
+
const sleep = deps.sleep ?? Bun.sleep;
|
|
2955
|
+
const waitStepMs = reloadWaitStepMs;
|
|
2956
|
+
const waitBudgetMs = reloadFreshnessWaitMs;
|
|
2957
|
+
const deadline = reloadNow + waitBudgetMs;
|
|
2958
|
+
// Iteration-capped so a frozen `now` cannot spin forever.
|
|
2959
|
+
const maxWaits = Math.ceil(waitBudgetMs / waitStepMs);
|
|
2960
|
+
for (let waits = 0; waits <= maxWaits; waits++) {
|
|
2961
|
+
const state = await readDaemonState(input.settings, fsImpl);
|
|
2962
|
+
const t = now();
|
|
2963
|
+
if (
|
|
2964
|
+
isFreshLiveOwner({
|
|
2965
|
+
state,
|
|
2966
|
+
now: t,
|
|
2967
|
+
tokenFingerprint: fp,
|
|
2968
|
+
chatId: cfg.chatId,
|
|
2969
|
+
pidAlive,
|
|
2970
|
+
pidIncarnation,
|
|
2971
|
+
})
|
|
2972
|
+
)
|
|
2973
|
+
return "attached" as const;
|
|
2974
|
+
if (t >= deadline) break;
|
|
2975
|
+
await sleep(waitStepMs);
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
await writeJsonAtomic(fsImpl, reloadAttemptPath, {
|
|
2979
|
+
lastReloadAt: reloadNow,
|
|
2980
|
+
ownerId: currentState?.ownerId ?? "",
|
|
2981
|
+
targetGeneration: DAEMON_GENERATION,
|
|
2982
|
+
});
|
|
2983
|
+
const controller = new TelegramDaemonController(input.settings, telegramControllerDeps(deps));
|
|
2984
|
+
const upgrade = await controller.reloadForGenerationUpgrade({}, spawned.legacyReloadRequired === true);
|
|
2985
|
+
return upgrade.outcome === "ready" ? ("reloaded" as const) : upgrade;
|
|
2986
|
+
},
|
|
2987
|
+
reloadReservationLockOptions({
|
|
2988
|
+
freshnessWaitMs: reloadFreshnessWaitMs,
|
|
2989
|
+
readinessTimeoutMs: reloadReadinessTimeoutMs,
|
|
2990
|
+
retryDelayMs: reloadWaitStepMs,
|
|
2991
|
+
}),
|
|
2992
|
+
);
|
|
2993
|
+
if (reloadResult === "attached") return "attached";
|
|
2994
|
+
if (reloadResult !== "reloaded") {
|
|
2306
2995
|
await restoreNotificationRootRegistration({
|
|
2307
2996
|
settings: input.settings,
|
|
2308
2997
|
sessionId: input.sessionId,
|
|
@@ -2310,7 +2999,7 @@ export async function ensureTelegramDaemonRunningDetailed(
|
|
|
2310
2999
|
previous,
|
|
2311
3000
|
fs: deps.fs,
|
|
2312
3001
|
});
|
|
2313
|
-
throw new Error(`Unable to replace stale Telegram daemon: ${
|
|
3002
|
+
throw new Error(`Unable to replace stale Telegram daemon: ${reloadResult.operation.message}`);
|
|
2314
3003
|
}
|
|
2315
3004
|
return "reloaded";
|
|
2316
3005
|
}
|
|
@@ -2363,12 +3052,28 @@ export async function ensureTelegramDaemonRunning(
|
|
|
2363
3052
|
}
|
|
2364
3053
|
|
|
2365
3054
|
function telegramControllerDeps(deps: TelegramDaemonDeps): TelegramDaemonControlDeps {
|
|
3055
|
+
// The legacy signal seam has no authority metadata of its own. Pair it with
|
|
3056
|
+
// the captured process incarnation so the controller retains its identity
|
|
3057
|
+
// fence; Windows continues through its hard-authority refusal path.
|
|
3058
|
+
const cooperativeSignalReference =
|
|
3059
|
+
deps.sendSignal && (deps.platform ?? process.platform) !== "win32"
|
|
3060
|
+
? (pid: number): DaemonProcessReference | undefined => {
|
|
3061
|
+
const incarnation = deps.pidIncarnation?.(pid);
|
|
3062
|
+
if (!isProcessIncarnation(incarnation)) return undefined;
|
|
3063
|
+
return {
|
|
3064
|
+
incarnation,
|
|
3065
|
+
termination: "cooperative",
|
|
3066
|
+
signalRoot: signal => deps.sendSignal!(pid, signal),
|
|
3067
|
+
};
|
|
3068
|
+
}
|
|
3069
|
+
: undefined;
|
|
2366
3070
|
return {
|
|
2367
3071
|
fs: deps.fs,
|
|
2368
3072
|
now: deps.now,
|
|
2369
3073
|
pidAlive: deps.pidAlive,
|
|
2370
|
-
processReference: deps.processReference,
|
|
3074
|
+
processReference: cooperativeSignalReference ?? deps.processReference,
|
|
2371
3075
|
pidIncarnation: deps.pidIncarnation,
|
|
3076
|
+
platform: deps.platform,
|
|
2372
3077
|
spawn: deps.spawn,
|
|
2373
3078
|
execPath: deps.execPath,
|
|
2374
3079
|
ownerPid: deps.pid,
|
|
@@ -2963,6 +3668,12 @@ export class TelegramNotificationDaemon {
|
|
|
2963
3668
|
private readonly botApi: BotApi;
|
|
2964
3669
|
private readonly effects = new TelegramEffectSupervisor();
|
|
2965
3670
|
private readonly topics = new TopicRegistry();
|
|
3671
|
+
/**
|
|
3672
|
+
* Telegram may accept createForumTopic while returning an unusable success
|
|
3673
|
+
* payload. Remember that ambiguity per endpoint so later frames cannot repeat
|
|
3674
|
+
* the external side effect. Explicit API failures remain retryable.
|
|
3675
|
+
*/
|
|
3676
|
+
readonly #malformedTopicCreateEndpoints = new Map<string, string>();
|
|
2966
3677
|
/** Serializes registry snapshots so an older atomic write cannot overwrite newer rename state. */
|
|
2967
3678
|
/** Legacy sockets have no durable token, so explicit teardown is their revocation fence. */
|
|
2968
3679
|
private readonly droppedSessions = new WeakSet<SessionSocket>();
|
|
@@ -3830,13 +4541,20 @@ export class TelegramNotificationDaemon {
|
|
|
3830
4541
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
3831
4542
|
const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
|
|
3832
4543
|
const endpointSessionIds = new Set<string>();
|
|
4544
|
+
// Permanent absences prune; only transient I/O keeps orphan reconciliation
|
|
4545
|
+
// gated (so one deleted worktree cannot disable cleanup forever) (#2956).
|
|
3833
4546
|
let allRootsReadable = true;
|
|
4547
|
+
const permanentlyMissingRoots: string[] = [];
|
|
3834
4548
|
for (const root of rootState?.roots ?? []) {
|
|
3835
4549
|
const dir = path.join(root, "sdk");
|
|
3836
4550
|
let files: string[];
|
|
3837
4551
|
try {
|
|
3838
4552
|
files = await this.fsImpl.readdir(dir);
|
|
3839
|
-
} catch {
|
|
4553
|
+
} catch (error) {
|
|
4554
|
+
if (isPermanentMissingPathError(error)) {
|
|
4555
|
+
permanentlyMissingRoots.push(root);
|
|
4556
|
+
continue;
|
|
4557
|
+
}
|
|
3840
4558
|
allRootsReadable = false;
|
|
3841
4559
|
continue;
|
|
3842
4560
|
}
|
|
@@ -3885,6 +4603,27 @@ export class TelegramNotificationDaemon {
|
|
|
3885
4603
|
} catch {}
|
|
3886
4604
|
}
|
|
3887
4605
|
}
|
|
4606
|
+
if (permanentlyMissingRoots.length > 0) {
|
|
4607
|
+
try {
|
|
4608
|
+
await pruneMissingNotificationRoots({
|
|
4609
|
+
settings: this.opts.settings,
|
|
4610
|
+
fs: this.fsImpl,
|
|
4611
|
+
candidates: permanentlyMissingRoots,
|
|
4612
|
+
});
|
|
4613
|
+
} catch (error) {
|
|
4614
|
+
logger.warn(`notifications: dead-root prune failed: ${sanitizeDiagnostic(String(error))}`);
|
|
4615
|
+
}
|
|
4616
|
+
}
|
|
4617
|
+
// Best-effort periodic reap of retained exact-unlink quarantines (#2956).
|
|
4618
|
+
try {
|
|
4619
|
+
await reapStaleNotificationArtifacts({
|
|
4620
|
+
settings: this.opts.settings,
|
|
4621
|
+
fs: this.fsImpl,
|
|
4622
|
+
now: this.opts.now,
|
|
4623
|
+
});
|
|
4624
|
+
} catch (error) {
|
|
4625
|
+
logger.warn(`notifications: leak-artifact reap failed: ${sanitizeDiagnostic(String(error))}`);
|
|
4626
|
+
}
|
|
3888
4627
|
if (allRootsReadable) {
|
|
3889
4628
|
for (const sessionId of this.topics.sessionIds()) {
|
|
3890
4629
|
const owner = this.logicalSessionOwners.get(sessionId);
|
|
@@ -3978,18 +4717,15 @@ export class TelegramNotificationDaemon {
|
|
|
3978
4717
|
);
|
|
3979
4718
|
} catch {}
|
|
3980
4719
|
}
|
|
3981
|
-
// Preserve the transport-session contract: ordinary endpoints own a topic
|
|
3982
|
-
// as soon as they connect. A later authenticated logical rekey is the only
|
|
3983
|
-
// path that may recover a different logical session's durable topic.
|
|
3984
4720
|
void (async () => {
|
|
3985
4721
|
if (this.#logicalSessionId(session) !== sessionId) return;
|
|
3986
|
-
const
|
|
4722
|
+
const topic = this.topics.get(sessionId);
|
|
4723
|
+
if (!topic || topic.authorityState === "delete_pending" || topic.bindingMalformed) return;
|
|
3987
4724
|
const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
|
|
3988
|
-
if (
|
|
3989
|
-
await this.flushPendingThreadedFrames(sessionId, topicLease);
|
|
4725
|
+
if (topicLease?.topicId === topic.topicId) await this.flushPendingThreadedFrames(sessionId, topicLease);
|
|
3990
4726
|
})().catch(err =>
|
|
3991
4727
|
logger.warn(
|
|
3992
|
-
`notifications: Telegram
|
|
4728
|
+
`notifications: Telegram topic reattach flush failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
|
|
3993
4729
|
),
|
|
3994
4730
|
);
|
|
3995
4731
|
});
|
|
@@ -4870,19 +5606,16 @@ export class TelegramNotificationDaemon {
|
|
|
4870
5606
|
this.legacyTopicOwners.set(previousSessionId, session);
|
|
4871
5607
|
this.preservedInitiatorTopics.add(previousSessionId);
|
|
4872
5608
|
}
|
|
4873
|
-
if (candidateSessionId === session.sessionId
|
|
5609
|
+
if (candidateSessionId === session.sessionId)
|
|
4874
5610
|
void (async () => {
|
|
4875
|
-
const
|
|
4876
|
-
|
|
4877
|
-
this.topicNameFor(candidateSessionId, {}),
|
|
4878
|
-
session,
|
|
4879
|
-
);
|
|
5611
|
+
const topic = this.topics.get(candidateSessionId);
|
|
5612
|
+
if (!topic || topic.authorityState === "delete_pending" || topic.bindingMalformed) return;
|
|
4880
5613
|
const topicLease = this.topicAuthorityLeaseFromRegistry(candidateSessionId);
|
|
4881
|
-
if (
|
|
5614
|
+
if (topicLease?.topicId === topic.topicId)
|
|
4882
5615
|
await this.flushPendingThreadedFrames(candidateSessionId, topicLease);
|
|
4883
5616
|
})().catch(err =>
|
|
4884
5617
|
logger.warn(
|
|
4885
|
-
`notifications: Telegram recovered topic
|
|
5618
|
+
`notifications: Telegram recovered topic reattach flush failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
|
|
4886
5619
|
),
|
|
4887
5620
|
);
|
|
4888
5621
|
return true;
|
|
@@ -5117,6 +5850,9 @@ export class TelegramNotificationDaemon {
|
|
|
5117
5850
|
if (session && sessionId === session.sessionId && this.#logicalSessionId(session) !== sessionId) return undefined;
|
|
5118
5851
|
const capturedCreationLease = creationLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
|
|
5119
5852
|
if (session?.logicalSessionIdTrusted && !capturedCreationLease) return undefined;
|
|
5853
|
+
const creationEndpointKey = session?.endpointDigest ?? session?.endpointKey ?? "unbound";
|
|
5854
|
+
if (this.#malformedTopicCreateEndpoints.get(sessionId) === creationEndpointKey)
|
|
5855
|
+
throw new Error("createForumTopic: invalid message_thread_id");
|
|
5120
5856
|
const existing = this.topics.get(sessionId);
|
|
5121
5857
|
if (existing?.authorityState === "delete_pending" || existing?.bindingMalformed) return undefined;
|
|
5122
5858
|
if (existing) return existing.topicId;
|
|
@@ -5140,10 +5876,17 @@ export class TelegramNotificationDaemon {
|
|
|
5140
5876
|
throw new Error("topic authority was revoked during creation");
|
|
5141
5877
|
const res = await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name });
|
|
5142
5878
|
if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
|
|
5143
|
-
const
|
|
5144
|
-
|
|
5879
|
+
const response = res as {
|
|
5880
|
+
ok?: unknown;
|
|
5881
|
+
result?: { message_thread_id?: unknown };
|
|
5882
|
+
};
|
|
5883
|
+
const tid = response.result?.message_thread_id;
|
|
5884
|
+
if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0) {
|
|
5885
|
+
if (response.ok === true) this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey);
|
|
5145
5886
|
throw new Error("createForumTopic: invalid message_thread_id");
|
|
5887
|
+
}
|
|
5146
5888
|
acceptedTopicId = String(tid);
|
|
5889
|
+
this.#malformedTopicCreateEndpoints.delete(sessionId);
|
|
5147
5890
|
if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
|
|
5148
5891
|
if (
|
|
5149
5892
|
!this.topics.fenceAcceptedCreateForLease(
|
|
@@ -6476,21 +7219,6 @@ export class TelegramNotificationDaemon {
|
|
|
6476
7219
|
const latestIdentity = identityIndex < 0 ? undefined : replayed[identityIndex];
|
|
6477
7220
|
const replayIdentitySessionId = latestIdentity?.sessionId as string | undefined;
|
|
6478
7221
|
const endpointBinding = this.#endpointBinding(session);
|
|
6479
|
-
// An open transport owns an eager topic-create claim. An identity-less replay
|
|
6480
|
-
// that races that claim must await its own public creation before classifying
|
|
6481
|
-
// endpoint authority; otherwise the registry correctly sees an in-flight
|
|
6482
|
-
// claim as ambiguous and rejects a valid bootstrap.
|
|
6483
|
-
if (!this.topics.get(session.sessionId) && this.#ownsLiveOpenEndpoint(session, endpointBinding)) {
|
|
6484
|
-
try {
|
|
6485
|
-
await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, {}), session);
|
|
6486
|
-
} catch (error) {
|
|
6487
|
-
logger.warn(
|
|
6488
|
-
`notifications: Telegram replay topic creation failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
|
|
6489
|
-
);
|
|
6490
|
-
this.dropSession(session, "replay_topic_creation_failed");
|
|
6491
|
-
return;
|
|
6492
|
-
}
|
|
6493
|
-
}
|
|
6494
7222
|
// Identity-less replay may resume only the exact transport owner. A
|
|
6495
7223
|
// rekeyed A→B transport remains denied unless replay proves B.
|
|
6496
7224
|
const endpointAuthority = this.#endpointAuthority(endpointBinding, session);
|
|
@@ -6974,6 +7702,7 @@ export class TelegramNotificationDaemon {
|
|
|
6974
7702
|
id: msg.id,
|
|
6975
7703
|
question: msg.question,
|
|
6976
7704
|
options: msg.options,
|
|
7705
|
+
recommendedIndex: msg.recommendedIndex,
|
|
6977
7706
|
controls,
|
|
6978
7707
|
summary: msg.summary,
|
|
6979
7708
|
});
|
|
@@ -7025,6 +7754,7 @@ export class TelegramNotificationDaemon {
|
|
|
7025
7754
|
kind,
|
|
7026
7755
|
question: msg.question,
|
|
7027
7756
|
options: msg.options,
|
|
7757
|
+
recommendedIndex: msg.recommendedIndex,
|
|
7028
7758
|
summary: msg.summary,
|
|
7029
7759
|
}),
|
|
7030
7760
|
replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
|
|
@@ -7818,6 +8548,17 @@ export class TelegramNotificationDaemon {
|
|
|
7818
8548
|
ownershipProved = true;
|
|
7819
8549
|
this.running = !this.stopRequested;
|
|
7820
8550
|
if (!this.running) return;
|
|
8551
|
+
// Self-heal durable notification state before any scan/poll work so
|
|
8552
|
+
// `daemon reload` recovers dead roots + leak artifacts (#2956).
|
|
8553
|
+
try {
|
|
8554
|
+
await healTelegramDaemonNotificationState({
|
|
8555
|
+
settings: this.opts.settings,
|
|
8556
|
+
fs: this.fsImpl,
|
|
8557
|
+
now: this.opts.now,
|
|
8558
|
+
});
|
|
8559
|
+
} catch (error) {
|
|
8560
|
+
logger.warn(`notifications: startup self-heal failed: ${sanitizeDiagnostic(String(error))}`);
|
|
8561
|
+
}
|
|
7821
8562
|
// Owner-only: start lifecycle control immediately after ownership proof,
|
|
7822
8563
|
// before timers or pre-poll startup work can invalidate this run.
|
|
7823
8564
|
// Best-effort; notification delivery remains available on failure.
|